349 lines · 12.4 KB · TypeScript
RawDownload
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<T> = { -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>()(  "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<DesktopSettings>;    readonly get: Effect.Effect<DesktopSettings>;    readonly setServerExposureMode: (      mode: DesktopServerExposureMode,    ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;    readonly setTailscaleServe: (input: {      readonly enabled: boolean;      readonly port: Option.Option<number>;    }) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;    readonly setUpdateChannel: (      channel: DesktopUpdateChannel,    ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;  }>()("@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<DesktopSettingsDocument> = {};   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<number> },): 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<DesktopSettings> {  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<void, DesktopSettingsWriteError> {  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<DesktopSettingsChange, DesktopSettingsWriteError> =>    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)),      });    }),  );