| 1 | import { |
| 2 | DesktopServerExposureModeSchema, |
| 3 | DesktopUpdateChannelSchema, |
| 4 | type DesktopServerExposureMode, |
| 5 | type DesktopUpdateChannel, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 8 | import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; |
| 9 | import * as Context from "effect/Context"; |
| 10 | import * as Crypto from "effect/Crypto"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as FileSystem from "effect/FileSystem"; |
| 13 | import * as Layer from "effect/Layer"; |
| 14 | import * as Option from "effect/Option"; |
| 15 | import * as Path from "effect/Path"; |
| 16 | import * as Schema from "effect/Schema"; |
| 17 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 18 | |
| 19 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 20 | import { |
| 21 | DEFAULT_LINUX_PASSWORD_STORE, |
| 22 | normalizeLinuxPasswordStorePreference, |
| 23 | type LinuxPasswordStorePreference, |
| 24 | } from "../linuxSecretStorage.ts"; |
| 25 | import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; |
| 26 | import { isValidDistroName } from "../wsl/wslPathParsing.ts"; |
| 27 | |
| 28 | export interface DesktopSettings { |
| 29 | readonly localEnvironmentEnabled: boolean; |
| 30 | readonly linuxPasswordStore: LinuxPasswordStorePreference; |
| 31 | readonly mainWindowBounds: DesktopWindowBounds | null; |
| 32 | readonly mainWindowMaximized: boolean; |
| 33 | readonly serverExposureMode: DesktopServerExposureMode; |
| 34 | readonly tailscaleServeEnabled: boolean; |
| 35 | readonly tailscaleServePort: number; |
| 36 | readonly updateChannel: DesktopUpdateChannel; |
| 37 | readonly updateChannelConfiguredByUser: boolean; |
| 38 | // Was a "local" | "wsl" swap mode in an earlier iteration of the WSL |
| 39 | // integration. We now run Windows and WSL backends side by side, so the |
| 40 | // setting is just whether the WSL backend should be running alongside the |
| 41 | // primary. Persisted documents that still carry the legacy `wslMode: "wsl"` |
| 42 | // value are migrated to `wslBackendEnabled: true` on load. |
| 43 | readonly wslBackendEnabled: boolean; |
| 44 | readonly wslDistro: string | null; |
| 45 | // When true (and wslBackendEnabled is also true) the desktop runs only |
| 46 | // the WSL backend as the primary, and the Windows-side Node backend is |
| 47 | // not started. Designed for users who develop entirely inside WSL and |
| 48 | // don't want a second backend process running. Defaults to false so |
| 49 | // existing setups stay on the parallel-backends behavior. Changing |
| 50 | // this requires a desktop restart because the pool's primary spec is |
| 51 | // chosen once at layer init. |
| 52 | readonly wslOnly: boolean; |
| 53 | } |
| 54 | |
| 55 | export interface DesktopSettingsChange { |
| 56 | readonly settings: DesktopSettings; |
| 57 | readonly changed: boolean; |
| 58 | } |
| 59 | |
| 60 | const DEFAULT_TAILSCALE_SERVE_PORT = 443; |
| 61 | const MIN_MAIN_WINDOW_SIZE = { |
| 62 | width: 840, |
| 63 | height: 620, |
| 64 | } as const; |
| 65 | export const DesktopWindowBoundsSchema = Schema.Struct({ |
| 66 | x: Schema.Int, |
| 67 | y: Schema.Int, |
| 68 | width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.width)), |
| 69 | height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.height)), |
| 70 | }); |
| 71 | export type DesktopWindowBounds = typeof DesktopWindowBoundsSchema.Type; |
| 72 | export const DEFAULT_MAIN_WINDOW_SIZE = { |
| 73 | width: 1100, |
| 74 | height: 780, |
| 75 | } as const; |
| 76 | |
| 77 | export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { |
| 78 | localEnvironmentEnabled: true, |
| 79 | linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, |
| 80 | mainWindowBounds: null, |
| 81 | mainWindowMaximized: false, |
| 82 | serverExposureMode: "local-only", |
| 83 | tailscaleServeEnabled: false, |
| 84 | tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, |
| 85 | updateChannel: "latest", |
| 86 | updateChannelConfiguredByUser: false, |
| 87 | wslBackendEnabled: false, |
| 88 | wslDistro: null, |
| 89 | wslOnly: false, |
| 90 | }; |
| 91 | |
| 92 | const DesktopWindowBoundsDocument = Schema.Struct({ |
| 93 | x: Schema.Number, |
| 94 | y: Schema.Number, |
| 95 | width: Schema.Number, |
| 96 | height: Schema.Number, |
| 97 | }); |
| 98 | |
| 99 | const DesktopSettingsDocument = Schema.Struct({ |
| 100 | localEnvironmentEnabled: Schema.optionalKey(Schema.Boolean), |
| 101 | linuxPasswordStore: Schema.optionalKey(Schema.Unknown), |
| 102 | mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), |
| 103 | mainWindowMaximized: Schema.optionalKey(Schema.Boolean), |
| 104 | serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), |
| 105 | tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), |
| 106 | tailscaleServePort: Schema.optionalKey(Schema.Number), |
| 107 | updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), |
| 108 | updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), |
| 109 | // Newer form of the WSL toggle. `wslMode` is still accepted on load so |
| 110 | // existing on-disk settings keep working; on the next persist we write the |
| 111 | // new boolean and the legacy key drops out. |
| 112 | wslBackendEnabled: Schema.optionalKey(Schema.Boolean), |
| 113 | wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), |
| 114 | wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), |
| 115 | wslOnly: Schema.optionalKey(Schema.Boolean), |
| 116 | }); |
| 117 | |
| 118 | type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; |
| 119 | type Mutable<T> = { -readonly [K in keyof T]: T[K] }; |
| 120 | |
| 121 | const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument); |
| 122 | const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson); |
| 123 | const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson); |
| 124 | const decodeDesktopWindowBounds = Schema.decodeUnknownOption(DesktopWindowBoundsSchema); |
| 125 | const desktopWindowBoundsEquivalence = Schema.toEquivalence(DesktopWindowBoundsSchema); |
| 126 | |
| 127 | const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({ |
| 128 | settings, |
| 129 | changed, |
| 130 | }); |
| 131 | |
| 132 | const DesktopSettingsWriteOperation = Schema.Literals([ |
| 133 | "create-temporary-file-name", |
| 134 | "resolve-symlink", |
| 135 | "encode-document", |
| 136 | "create-directory", |
| 137 | "write-temporary-file", |
| 138 | "replace-settings-file", |
| 139 | ]); |
| 140 | type DesktopSettingsWriteOperation = typeof DesktopSettingsWriteOperation.Type; |
| 141 | |
| 142 | export class DesktopSettingsWriteError extends Schema.TaggedError<DesktopSettingsWriteError>()( |
| 143 | "DesktopSettingsWriteError", |
| 144 | { |
| 145 | operation: DesktopSettingsWriteOperation, |
| 146 | path: Schema.String, |
| 147 | cause: Schema.Defect(), |
| 148 | }, |
| 149 | ) { |
| 150 | override get message(): string { |
| 151 | return `Desktop settings write failed during ${this.operation} at ${this.path}.`; |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | export class DesktopAppSettings extends Context.Service< |
| 156 | DesktopAppSettings, |
| 157 | { |
| 158 | readonly load: Effect.Effect<DesktopSettings>; |
| 159 | readonly get: Effect.Effect<DesktopSettings>; |
| 160 | readonly setLocalEnvironmentEnabled: ( |
| 161 | enabled: boolean, |
| 162 | ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; |
| 163 | readonly setMainWindowBounds: ( |
| 164 | bounds: DesktopWindowBounds, |
| 165 | isMaximized: boolean, |
| 166 | ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; |
| 167 | readonly setServerExposureMode: ( |
| 168 | mode: DesktopServerExposureMode, |
| 169 | ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; |
| 170 | readonly setTailscaleServe: (input: { |
| 171 | readonly enabled: boolean; |
| 172 | readonly port: Option.Option<number>; |
| 173 | }) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; |
| 174 | readonly setUpdateChannel: ( |
| 175 | channel: DesktopUpdateChannel, |
| 176 | ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; |
| 177 | readonly setWslBackendEnabled: ( |
| 178 | enabled: boolean, |
| 179 | ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; |
| 180 | readonly setWslDistro: ( |
| 181 | distro: string | null, |
| 182 | ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; |
| 183 | readonly setWslOnly: ( |
| 184 | enabled: boolean, |
| 185 | ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; |
| 186 | readonly applyWslWindowsFallback: Effect.Effect< |
| 187 | DesktopSettingsChange, |
| 188 | DesktopSettingsWriteError |
| 189 | >; |
| 190 | readonly applyWslWindowsFallbackInMemory: Effect.Effect<DesktopSettingsChange>; |
| 191 | } |
| 192 | >()("@t3tools/desktop/settings/DesktopAppSettings") {} |
| 193 | |
| 194 | export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { |
| 195 | return { |
| 196 | ...DEFAULT_DESKTOP_SETTINGS, |
| 197 | updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), |
| 198 | }; |
| 199 | } |
| 200 | |
| 201 | function normalizeTailscaleServePort(value: unknown): number { |
| 202 | return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535 |
| 203 | ? value |
| 204 | : DEFAULT_TAILSCALE_SERVE_PORT; |
| 205 | } |
| 206 | |
| 207 | function normalizeWslDistro(value: unknown): string | null { |
| 208 | return typeof value === "string" && isValidDistroName(value) ? value : null; |
| 209 | } |
| 210 | |
| 211 | export function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | null { |
| 212 | return Option.getOrNull(decodeDesktopWindowBounds(value)); |
| 213 | } |
| 214 | |
| 215 | function normalizeDesktopSettingsDocument( |
| 216 | parsed: DesktopSettingsDocument, |
| 217 | appVersion: string, |
| 218 | ): DesktopSettings { |
| 219 | const defaultSettings = resolveDefaultDesktopSettings(appVersion); |
| 220 | const mainWindowBounds = normalizeMainWindowBounds(parsed.mainWindowBounds); |
| 221 | const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel); |
| 222 | const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; |
| 223 | const updateChannelConfiguredByUser = |
| 224 | parsed.updateChannelConfiguredByUser === true || |
| 225 | (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly")); |
| 226 | |
| 227 | // Newer form wins when both are present; otherwise fall back to the legacy |
| 228 | // `wslMode === "wsl"` signal so users coming off the swap-mode build keep |
| 229 | // their WSL backend enabled. |
| 230 | const wslBackendEnabled = |
| 231 | parsed.wslBackendEnabled === true || |
| 232 | (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); |
| 233 | |
| 234 | return { |
| 235 | localEnvironmentEnabled: parsed.localEnvironmentEnabled !== false, |
| 236 | linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), |
| 237 | mainWindowBounds, |
| 238 | mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, |
| 239 | serverExposureMode: |
| 240 | parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", |
| 241 | tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, |
| 242 | tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), |
| 243 | updateChannel: updateChannelConfiguredByUser |
| 244 | ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) |
| 245 | : defaultSettings.updateChannel, |
| 246 | updateChannelConfiguredByUser, |
| 247 | wslBackendEnabled, |
| 248 | wslDistro: normalizeWslDistro(parsed.wslDistro), |
| 249 | wslOnly: parsed.wslOnly === true, |
| 250 | }; |
| 251 | } |
| 252 | |
| 253 | function toDesktopSettingsDocument( |
| 254 | settings: DesktopSettings, |
| 255 | defaults: DesktopSettings, |
| 256 | ): DesktopSettingsDocument { |
| 257 | const document: Mutable<DesktopSettingsDocument> = {}; |
| 258 | |
| 259 | if (settings.localEnvironmentEnabled !== defaults.localEnvironmentEnabled) { |
| 260 | document.localEnvironmentEnabled = settings.localEnvironmentEnabled; |
| 261 | } |
| 262 | |
| 263 | if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) { |
| 264 | document.linuxPasswordStore = settings.linuxPasswordStore; |
| 265 | } |
| 266 | if (settings.mainWindowBounds !== null) { |
| 267 | document.mainWindowBounds = settings.mainWindowBounds; |
| 268 | } |
| 269 | if (settings.mainWindowMaximized) { |
| 270 | document.mainWindowMaximized = true; |
| 271 | } |
| 272 | if (settings.serverExposureMode !== defaults.serverExposureMode) { |
| 273 | document.serverExposureMode = settings.serverExposureMode; |
| 274 | } |
| 275 | if (settings.tailscaleServeEnabled !== defaults.tailscaleServeEnabled) { |
| 276 | document.tailscaleServeEnabled = settings.tailscaleServeEnabled; |
| 277 | } |
| 278 | if (settings.tailscaleServePort !== defaults.tailscaleServePort) { |
| 279 | document.tailscaleServePort = settings.tailscaleServePort; |
| 280 | } |
| 281 | if (settings.updateChannel !== defaults.updateChannel) { |
| 282 | document.updateChannel = settings.updateChannel; |
| 283 | } |
| 284 | if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) { |
| 285 | document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser; |
| 286 | } |
| 287 | if (settings.wslBackendEnabled !== defaults.wslBackendEnabled) { |
| 288 | document.wslBackendEnabled = settings.wslBackendEnabled; |
| 289 | } |
| 290 | if (settings.wslDistro !== defaults.wslDistro) { |
| 291 | document.wslDistro = settings.wslDistro; |
| 292 | } |
| 293 | if (settings.wslOnly !== defaults.wslOnly) { |
| 294 | document.wslOnly = settings.wslOnly; |
| 295 | } |
| 296 | |
| 297 | return document; |
| 298 | } |
| 299 | |
| 300 | function setServerExposureMode( |
| 301 | settings: DesktopSettings, |
| 302 | requestedMode: DesktopServerExposureMode, |
| 303 | ): DesktopSettings { |
| 304 | return settings.serverExposureMode === requestedMode |
| 305 | ? settings |
| 306 | : { |
| 307 | ...settings, |
| 308 | serverExposureMode: requestedMode, |
| 309 | }; |
| 310 | } |
| 311 | |
| 312 | function setMainWindowBounds( |
| 313 | settings: DesktopSettings, |
| 314 | bounds: DesktopWindowBounds, |
| 315 | isMaximized: boolean, |
| 316 | ): DesktopSettings { |
| 317 | return settings.mainWindowBounds !== null && |
| 318 | desktopWindowBoundsEquivalence(settings.mainWindowBounds, bounds) && |
| 319 | settings.mainWindowMaximized === isMaximized |
| 320 | ? settings |
| 321 | : { |
| 322 | ...settings, |
| 323 | mainWindowBounds: bounds, |
| 324 | mainWindowMaximized: isMaximized, |
| 325 | }; |
| 326 | } |
| 327 | |
| 328 | function setTailscaleServe( |
| 329 | settings: DesktopSettings, |
| 330 | input: { readonly enabled: boolean; readonly port: Option.Option<number> }, |
| 331 | ): DesktopSettings { |
| 332 | const port = Option.match(input.port, { |
| 333 | onNone: () => settings.tailscaleServePort, |
| 334 | onSome: normalizeTailscaleServePort, |
| 335 | }); |
| 336 | return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port |
| 337 | ? settings |
| 338 | : { |
| 339 | ...settings, |
| 340 | tailscaleServeEnabled: input.enabled, |
| 341 | tailscaleServePort: port, |
| 342 | }; |
| 343 | } |
| 344 | |
| 345 | function setUpdateChannel( |
| 346 | settings: DesktopSettings, |
| 347 | requestedChannel: DesktopUpdateChannel, |
| 348 | ): DesktopSettings { |
| 349 | return settings.updateChannel === requestedChannel |
| 350 | ? settings |
| 351 | : { |
| 352 | ...settings, |
| 353 | updateChannel: requestedChannel, |
| 354 | updateChannelConfiguredByUser: true, |
| 355 | }; |
| 356 | } |
| 357 | |
| 358 | function setWslBackendEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings { |
| 359 | return settings.wslBackendEnabled === enabled |
| 360 | ? settings |
| 361 | : { |
| 362 | ...settings, |
| 363 | wslBackendEnabled: enabled, |
| 364 | }; |
| 365 | } |
| 366 | |
| 367 | function setWslDistro(settings: DesktopSettings, distro: string | null): DesktopSettings { |
| 368 | const normalized = normalizeWslDistro(distro); |
| 369 | return settings.wslDistro === normalized |
| 370 | ? settings |
| 371 | : { |
| 372 | ...settings, |
| 373 | wslDistro: normalized, |
| 374 | }; |
| 375 | } |
| 376 | |
| 377 | function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSettings { |
| 378 | return settings.wslOnly === enabled |
| 379 | ? settings |
| 380 | : { |
| 381 | ...settings, |
| 382 | wslOnly: enabled, |
| 383 | }; |
| 384 | } |
| 385 | |
| 386 | function setLocalEnvironmentEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings { |
| 387 | return settings.localEnvironmentEnabled === enabled |
| 388 | ? settings |
| 389 | : { ...settings, localEnvironmentEnabled: enabled }; |
| 390 | } |
| 391 | |
| 392 | function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { |
| 393 | return setWslOnly(setWslBackendEnabled(settings, false), false); |
| 394 | } |
| 395 | |
| 396 | function readSettings( |
| 397 | fileSystem: FileSystem.FileSystem, |
| 398 | settingsPath: string, |
| 399 | appVersion: string, |
| 400 | ): Effect.Effect<DesktopSettings> { |
| 401 | const defaultSettings = resolveDefaultDesktopSettings(appVersion); |
| 402 | |
| 403 | return fileSystem.readFileString(settingsPath).pipe( |
| 404 | Effect.option, |
| 405 | Effect.flatMap( |
| 406 | Option.match({ |
| 407 | onNone: () => Effect.succeed(defaultSettings), |
| 408 | onSome: (raw) => |
| 409 | decodeDesktopSettingsJson(raw).pipe( |
| 410 | Effect.map((parsed) => normalizeDesktopSettingsDocument(parsed, appVersion)), |
| 411 | Effect.orElseSucceed(() => defaultSettings), |
| 412 | ), |
| 413 | }), |
| 414 | ), |
| 415 | ); |
| 416 | } |
| 417 | |
| 418 | const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (input: { |
| 419 | readonly fileSystem: FileSystem.FileSystem; |
| 420 | readonly path: Path.Path; |
| 421 | readonly settingsPath: string; |
| 422 | readonly settings: DesktopSettings; |
| 423 | readonly defaultSettings: DesktopSettings; |
| 424 | readonly suffix: string; |
| 425 | }): Effect.fn.Return<void, DesktopSettingsWriteError> { |
| 426 | const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe( |
| 427 | Effect.provideService(FileSystem.FileSystem, input.fileSystem), |
| 428 | Effect.provideService(Path.Path, input.path), |
| 429 | Effect.mapError( |
| 430 | (cause) => |
| 431 | new DesktopSettingsWriteError({ |
| 432 | operation: "resolve-symlink", |
| 433 | path: input.settingsPath, |
| 434 | cause, |
| 435 | }), |
| 436 | ), |
| 437 | ); |
| 438 | const directory = input.path.dirname(targetPath); |
| 439 | const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`; |
| 440 | const encoded = yield* encodeDesktopSettingsJson( |
| 441 | toDesktopSettingsDocument(input.settings, input.defaultSettings), |
| 442 | ).pipe( |
| 443 | Effect.mapError( |
| 444 | (cause) => |
| 445 | new DesktopSettingsWriteError({ |
| 446 | operation: "encode-document", |
| 447 | path: input.settingsPath, |
| 448 | cause, |
| 449 | }), |
| 450 | ), |
| 451 | ); |
| 452 | yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( |
| 453 | Effect.mapError( |
| 454 | (cause) => |
| 455 | new DesktopSettingsWriteError({ |
| 456 | operation: "create-directory", |
| 457 | path: directory, |
| 458 | cause, |
| 459 | }), |
| 460 | ), |
| 461 | ); |
| 462 | yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( |
| 463 | Effect.mapError( |
| 464 | (cause) => |
| 465 | new DesktopSettingsWriteError({ |
| 466 | operation: "write-temporary-file", |
| 467 | path: tempPath, |
| 468 | cause, |
| 469 | }), |
| 470 | ), |
| 471 | ); |
| 472 | yield* input.fileSystem.rename(tempPath, targetPath).pipe( |
| 473 | Effect.mapError( |
| 474 | (cause) => |
| 475 | new DesktopSettingsWriteError({ |
| 476 | operation: "replace-settings-file", |
| 477 | path: input.settingsPath, |
| 478 | cause, |
| 479 | }), |
| 480 | ), |
| 481 | ); |
| 482 | }); |
| 483 | |
| 484 | /** @public Service construction is part of the canonical Effect module API. */ |
| 485 | export const make = Effect.gen(function* () { |
| 486 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 487 | const fileSystem = yield* FileSystem.FileSystem; |
| 488 | const path = yield* Path.Path; |
| 489 | const crypto = yield* Crypto.Crypto; |
| 490 | const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); |
| 491 | |
| 492 | const updateInMemory = (update: (settings: DesktopSettings) => DesktopSettings) => |
| 493 | SynchronizedRef.modify(settingsRef, (settings) => { |
| 494 | const nextSettings = update(settings); |
| 495 | return [settingsChange(nextSettings, nextSettings !== settings), nextSettings] as const; |
| 496 | }); |
| 497 | |
| 498 | const persist = ( |
| 499 | update: (settings: DesktopSettings) => DesktopSettings, |
| 500 | ): Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError> => |
| 501 | SynchronizedRef.modifyEffect(settingsRef, (settings) => { |
| 502 | const nextSettings = update(settings); |
| 503 | if (nextSettings === settings) { |
| 504 | return Effect.succeed([settingsChange(settings, false), settings] as const); |
| 505 | } |
| 506 | |
| 507 | return crypto.randomUUIDv4().pipe( |
| 508 | Effect.map((uuid) => uuid.replace(/-/g, "")), |
| 509 | Effect.mapError( |
| 510 | (cause) => |
| 511 | new DesktopSettingsWriteError({ |
| 512 | operation: "create-temporary-file-name", |
| 513 | path: environment.desktopSettingsPath, |
| 514 | cause, |
| 515 | }), |
| 516 | ), |
| 517 | Effect.flatMap((suffix) => |
| 518 | writeSettings({ |
| 519 | fileSystem, |
| 520 | path, |
| 521 | settingsPath: environment.desktopSettingsPath, |
| 522 | settings: nextSettings, |
| 523 | defaultSettings: environment.defaultDesktopSettings, |
| 524 | suffix, |
| 525 | }), |
| 526 | ), |
| 527 | Effect.as([settingsChange(nextSettings, true), nextSettings] as const), |
| 528 | ); |
| 529 | }); |
| 530 | |
| 531 | return DesktopAppSettings.of({ |
| 532 | get: SynchronizedRef.get(settingsRef), |
| 533 | load: Effect.gen(function* () { |
| 534 | const settings = yield* readSettings( |
| 535 | fileSystem, |
| 536 | environment.desktopSettingsPath, |
| 537 | environment.appVersion, |
| 538 | ); |
| 539 | return yield* SynchronizedRef.setAndGet(settingsRef, settings); |
| 540 | }).pipe(Effect.withSpan("desktop.settings.load")), |
| 541 | setMainWindowBounds: (bounds, isMaximized) => |
| 542 | persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe( |
| 543 | Effect.withSpan("desktop.settings.setMainWindowBounds", { |
| 544 | attributes: { |
| 545 | x: bounds.x, |
| 546 | y: bounds.y, |
| 547 | width: bounds.width, |
| 548 | height: bounds.height, |
| 549 | isMaximized, |
| 550 | }, |
| 551 | }), |
| 552 | ), |
| 553 | setServerExposureMode: (mode) => |
| 554 | persist((settings) => setServerExposureMode(settings, mode)).pipe( |
| 555 | Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), |
| 556 | ), |
| 557 | setTailscaleServe: (input) => |
| 558 | persist((settings) => setTailscaleServe(settings, input)).pipe( |
| 559 | Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), |
| 560 | ), |
| 561 | setUpdateChannel: (channel) => |
| 562 | persist((settings) => setUpdateChannel(settings, channel)).pipe( |
| 563 | Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), |
| 564 | ), |
| 565 | setWslBackendEnabled: (enabled) => |
| 566 | persist((settings) => setWslBackendEnabled(settings, enabled)).pipe( |
| 567 | Effect.withSpan("desktop.settings.setWslBackendEnabled", { attributes: { enabled } }), |
| 568 | ), |
| 569 | setWslDistro: (distro) => |
| 570 | persist((settings) => setWslDistro(settings, distro)).pipe( |
| 571 | Effect.withSpan("desktop.settings.setWslDistro", { |
| 572 | attributes: { distro: distro ?? null }, |
| 573 | }), |
| 574 | ), |
| 575 | setWslOnly: (enabled) => |
| 576 | persist((settings) => setWslOnly(settings, enabled)).pipe( |
| 577 | Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }), |
| 578 | ), |
| 579 | setLocalEnvironmentEnabled: (enabled) => |
| 580 | persist((settings) => setLocalEnvironmentEnabled(settings, enabled)).pipe( |
| 581 | Effect.withSpan("desktop.settings.setLocalEnvironmentEnabled", { attributes: { enabled } }), |
| 582 | ), |
| 583 | applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( |
| 584 | Effect.withSpan("desktop.settings.applyWslWindowsFallback"), |
| 585 | ), |
| 586 | applyWslWindowsFallbackInMemory: updateInMemory(applyWslWindowsFallback).pipe( |
| 587 | Effect.withSpan("desktop.settings.applyWslWindowsFallbackInMemory"), |
| 588 | ), |
| 589 | }); |
| 590 | }); |
| 591 | |
| 592 | export const layer = Layer.effect(DesktopAppSettings, make); |
| 593 | |
| 594 | export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SETTINGS) => |
| 595 | Layer.effect( |
| 596 | DesktopAppSettings, |
| 597 | Effect.gen(function* () { |
| 598 | const settingsRef = yield* SynchronizedRef.make(initialSettings); |
| 599 | const update = (f: (settings: DesktopSettings) => DesktopSettings) => |
| 600 | SynchronizedRef.modify(settingsRef, (settings) => { |
| 601 | const nextSettings = f(settings); |
| 602 | return [ |
| 603 | { |
| 604 | settings: nextSettings, |
| 605 | changed: nextSettings !== settings, |
| 606 | }, |
| 607 | nextSettings, |
| 608 | ] as const; |
| 609 | }); |
| 610 | |
| 611 | return DesktopAppSettings.of({ |
| 612 | get: SynchronizedRef.get(settingsRef), |
| 613 | load: SynchronizedRef.get(settingsRef), |
| 614 | setMainWindowBounds: (bounds, isMaximized) => |
| 615 | update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), |
| 616 | setServerExposureMode: (mode) => |
| 617 | update((settings) => setServerExposureMode(settings, mode)), |
| 618 | setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), |
| 619 | setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), |
| 620 | setWslBackendEnabled: (enabled) => |
| 621 | update((settings) => setWslBackendEnabled(settings, enabled)), |
| 622 | setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), |
| 623 | setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), |
| 624 | setLocalEnvironmentEnabled: (enabled) => |
| 625 | update((settings) => setLocalEnvironmentEnabled(settings, enabled)), |
| 626 | applyWslWindowsFallback: update(applyWslWindowsFallback), |
| 627 | applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), |
| 628 | }); |
| 629 | }), |
| 630 | ); |