import { DesktopServerExposureModeSchema, DesktopUpdateChannelSchema, type DesktopServerExposureMode, type DesktopUpdateChannel, } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; export interface DesktopSettings { readonly serverExposureMode: DesktopServerExposureMode; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; readonly updateChannel: DesktopUpdateChannel; readonly updateChannelConfiguredByUser: boolean; } export interface DesktopSettingsChange { readonly settings: DesktopSettings; readonly changed: boolean; } export const DEFAULT_TAILSCALE_SERVE_PORT = 443; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, updateChannel: "latest", updateChannelConfiguredByUser: false, }; const DesktopSettingsDocument = Schema.Struct({ serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), }); type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; type Mutable = { -readonly [K in keyof T]: T[K] }; const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument); const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson); const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson); const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({ settings, changed, }); const DesktopSettingsWriteOperation = Schema.Literals([ "create-temporary-file-name", "encode-document", "create-directory", "write-temporary-file", "replace-settings-file", ]); type DesktopSettingsWriteOperation = typeof DesktopSettingsWriteOperation.Type; export class DesktopSettingsWriteError extends Schema.TaggedErrorClass()( "DesktopSettingsWriteError", { operation: DesktopSettingsWriteOperation, path: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Desktop settings write failed during ${this.operation} at ${this.path}.`; } } const writeError = ( operation: DesktopSettingsWriteOperation, path: string, cause: unknown, ): DesktopSettingsWriteError => new DesktopSettingsWriteError({ operation, path, cause }); export class DesktopAppSettings extends Context.Service< DesktopAppSettings, { readonly load: Effect.Effect; readonly get: Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; readonly setTailscaleServe: (input: { readonly enabled: boolean; readonly port: Option.Option; }) => Effect.Effect; readonly setUpdateChannel: ( channel: DesktopUpdateChannel, ) => Effect.Effect; } >()("@t3tools/desktop/settings/DesktopAppSettings") {} export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { return { ...DEFAULT_DESKTOP_SETTINGS, updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), }; } function normalizeTailscaleServePort(value: unknown): number { return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535 ? value : DEFAULT_TAILSCALE_SERVE_PORT; } function normalizeDesktopSettingsDocument( parsed: DesktopSettingsDocument, appVersion: string, ): DesktopSettings { const defaultSettings = resolveDefaultDesktopSettings(appVersion); const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel); const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; const updateChannelConfiguredByUser = parsed.updateChannelConfiguredByUser === true || (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly")); return { serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), updateChannel: updateChannelConfiguredByUser ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) : defaultSettings.updateChannel, updateChannelConfiguredByUser, }; } function toDesktopSettingsDocument( settings: DesktopSettings, defaults: DesktopSettings, ): DesktopSettingsDocument { const document: Mutable = {}; if (settings.serverExposureMode !== defaults.serverExposureMode) { document.serverExposureMode = settings.serverExposureMode; } if (settings.tailscaleServeEnabled !== defaults.tailscaleServeEnabled) { document.tailscaleServeEnabled = settings.tailscaleServeEnabled; } if (settings.tailscaleServePort !== defaults.tailscaleServePort) { document.tailscaleServePort = settings.tailscaleServePort; } if (settings.updateChannel !== defaults.updateChannel) { document.updateChannel = settings.updateChannel; } if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) { document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser; } return document; } function setServerExposureMode( settings: DesktopSettings, requestedMode: DesktopServerExposureMode, ): DesktopSettings { return settings.serverExposureMode === requestedMode ? settings : { ...settings, serverExposureMode: requestedMode, }; } function setTailscaleServe( settings: DesktopSettings, input: { readonly enabled: boolean; readonly port: Option.Option }, ): DesktopSettings { const port = Option.match(input.port, { onNone: () => settings.tailscaleServePort, onSome: normalizeTailscaleServePort, }); return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port ? settings : { ...settings, tailscaleServeEnabled: input.enabled, tailscaleServePort: port, }; } function setUpdateChannel( settings: DesktopSettings, requestedChannel: DesktopUpdateChannel, ): DesktopSettings { return settings.updateChannel === requestedChannel ? settings : { ...settings, updateChannel: requestedChannel, updateChannelConfiguredByUser: true, }; } function readSettings( fileSystem: FileSystem.FileSystem, settingsPath: string, appVersion: string, ): Effect.Effect { const defaultSettings = resolveDefaultDesktopSettings(appVersion); return fileSystem.readFileString(settingsPath).pipe( Effect.option, Effect.flatMap( Option.match({ onNone: () => Effect.succeed(defaultSettings), onSome: (raw) => decodeDesktopSettingsJson(raw).pipe( Effect.map((parsed) => normalizeDesktopSettingsDocument(parsed, appVersion)), Effect.orElseSucceed(() => defaultSettings), ), }), ), ); } const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (input: { readonly fileSystem: FileSystem.FileSystem; readonly path: Path.Path; readonly settingsPath: string; readonly settings: DesktopSettings; readonly defaultSettings: DesktopSettings; readonly suffix: string; }): Effect.fn.Return { const directory = input.path.dirname(input.settingsPath); const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeDesktopSettingsJson( toDesktopSettingsDocument(input.settings, input.defaultSettings), ).pipe(Effect.mapError((cause) => writeError("encode-document", input.settingsPath, cause))); yield* input.fileSystem .makeDirectory(directory, { recursive: true }) .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); yield* input.fileSystem .writeFileString(tempPath, `${encoded}\n`) .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); yield* input.fileSystem .rename(tempPath, input.settingsPath) .pipe( Effect.mapError((cause) => writeError("replace-settings-file", input.settingsPath, cause)), ); }); export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); const persist = ( update: (settings: DesktopSettings) => DesktopSettings, ): Effect.Effect => SynchronizedRef.modifyEffect(settingsRef, (settings) => { const nextSettings = update(settings); if (nextSettings === settings) { return Effect.succeed([settingsChange(settings, false), settings] as const); } return crypto.randomUUIDv4.pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), Effect.mapError((cause) => writeError("create-temporary-file-name", environment.desktopSettingsPath, cause), ), Effect.flatMap((suffix) => writeSettings({ fileSystem, path, settingsPath: environment.desktopSettingsPath, settings: nextSettings, defaultSettings: environment.defaultDesktopSettings, suffix, }), ), Effect.as([settingsChange(nextSettings, true), nextSettings] as const), ); }); return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: Effect.gen(function* () { const settings = yield* readSettings( fileSystem, environment.desktopSettingsPath, environment.appVersion, ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), setServerExposureMode: (mode) => persist((settings) => setServerExposureMode(settings, mode)).pipe( Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), ), setTailscaleServe: (input) => persist((settings) => setTailscaleServe(settings, input)).pipe( Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), ), setUpdateChannel: (channel) => persist((settings) => setUpdateChannel(settings, channel)).pipe( Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), ), }); }); export const layer = Layer.effect(DesktopAppSettings, make); export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SETTINGS) => Layer.effect( DesktopAppSettings, Effect.gen(function* () { const settingsRef = yield* SynchronizedRef.make(initialSettings); const update = (f: (settings: DesktopSettings) => DesktopSettings) => SynchronizedRef.modify(settingsRef, (settings) => { const nextSettings = f(settings); return [ { settings: nextSettings, changed: nextSettings !== settings, }, nextSettings, ] as const; }); return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: SynchronizedRef.get(settingsRef), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), }); }), );