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)), }); }), );
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)),
});
}),
);