import { DesktopServerExposureModeSchema, DesktopUpdateChannelSchema, type DesktopServerExposureMode, type DesktopUpdateChannel,} from "@t3tools/contracts";import { fromLenientJson } from "@t3tools/shared/schemaJson";import * as Context from "effect/Context";import * as Data from "effect/Data";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 PlatformError from "effect/PlatformError";import * as Random from "effect/Random";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,}); export class DesktopSettingsWriteError extends Data.TaggedError("DesktopSettingsWriteError")<{ readonly cause: PlatformError.PlatformError | Schema.SchemaError;}> { override get message() { return `Failed to write desktop settings: ${this.cause.message}`; }} export interface DesktopAppSettingsShape { 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>;} export class DesktopAppSettings extends Context.Service< DesktopAppSettings, DesktopAppSettingsShape>()("t3/desktop/AppSettings") {} 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.catch(() => Effect.succeed(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;}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> { const directory = input.path.dirname(input.settingsPath); const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`; const encoded = yield* encodeDesktopSettingsJson( toDesktopSettingsDocument(input.settings, input.defaultSettings), ); yield* input.fileSystem.makeDirectory(directory, { recursive: true }); yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); yield* input.fileSystem.rename(tempPath, input.settingsPath);}); export const layer = Layer.effect( DesktopAppSettings, Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; 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 writeSettings({ fileSystem, path, settingsPath: environment.desktopSettingsPath, settings: nextSettings, defaultSettings: environment.defaultDesktopSettings, }).pipe( Effect.mapError((cause) => new DesktopSettingsWriteError({ cause })), 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 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 Data from "effect/Data";
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 PlatformError from "effect/PlatformError";
import * as Random from "effect/Random";
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,
});
export class DesktopSettingsWriteError extends Data.TaggedError("DesktopSettingsWriteError")<{
readonly cause: PlatformError.PlatformError | Schema.SchemaError;
}> {
override get message() {
return `Failed to write desktop settings: ${this.cause.message}`;
}
}
export interface DesktopAppSettingsShape {
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>;
}
export class DesktopAppSettings extends Context.Service<
DesktopAppSettings,
DesktopAppSettingsShape
>()("t3/desktop/AppSettings") {}
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.catch(() => Effect.succeed(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;
}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
const directory = input.path.dirname(input.settingsPath);
const suffix = (yield* Random.nextUUIDv4).replace(/-/g, "");
const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`;
const encoded = yield* encodeDesktopSettingsJson(
toDesktopSettingsDocument(input.settings, input.defaultSettings),
);
yield* input.fileSystem.makeDirectory(directory, { recursive: true });
yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
yield* input.fileSystem.rename(tempPath, input.settingsPath);
});
export const layer = Layer.effect(
DesktopAppSettings,
Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
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 writeSettings({
fileSystem,
path,
settingsPath: environment.desktopSettingsPath,
settings: nextSettings,
defaultSettings: environment.defaultDesktopSettings,
}).pipe(
Effect.mapError((cause) => new DesktopSettingsWriteError({ cause })),
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 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)),
});
}),
);