diff --git a/.gitignore b/.gitignore index 9e14e917910..e16286c0736 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ __screenshots__/ .tanstack squashfs-root/ .vercel +dist-electron/ +.electron-runtime/ diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore deleted file mode 100644 index 45b848af652..00000000000 --- a/apps/desktop/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -dist-electron/ -.electron-runtime/ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cd20211bfef..843209363f3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -17,10 +17,12 @@ "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", - "electron": "40.9.3", + "electron": "41.5.0", "electron-updater": "^6.6.2" }, "devDependencies": { + "@effect/language-service": "catalog:", + "@effect/vitest": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts new file mode 100644 index 00000000000..e13ae08d5ff --- /dev/null +++ b/apps/desktop/src/app/DesktopApp.ts @@ -0,0 +1,240 @@ +import * as Cause from "effect/Cause"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; + +import * as NetService from "@t3tools/shared/Net"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; +import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; +import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopObservability from "./DesktopObservability.ts"; +import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; + +const DEFAULT_DESKTOP_BACKEND_PORT = 3773; +const MAX_TCP_PORT = 65_535; +const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const; + +const makeDesktopRunId = Random.nextUUIDv4.pipe( + Effect.map((value) => value.replaceAll("-", "").slice(0, 12)), +); + +class DesktopBackendPortUnavailableError extends Data.TaggedError( + "DesktopBackendPortUnavailableError", +)<{ + readonly startPort: number; + readonly maxPort: number; + readonly hosts: readonly string[]; +}> { + override get message() { + return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`; + } +} + +class DesktopDevelopmentBackendPortRequiredError extends Data.TaggedError( + "DesktopDevelopmentBackendPortRequiredError", +)<{}> { + override get message() { + return "T3CODE_PORT is required in desktop development."; + } +} + +const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = + DesktopObservability.makeComponentLogger("desktop-bootstrap"); + +const { logInfo: logStartupInfo, logError: logStartupError } = + DesktopObservability.makeComponentLogger("desktop-startup"); + +const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* ( + configuredPort: Option.Option, +) { + if (Option.isSome(configuredPort)) { + return { + port: configuredPort.value, + selectedByScan: false, + } as const; + } + + const net = yield* NetService.NetService; + for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) { + let availableOnEveryHost = true; + + for (const host of DESKTOP_BACKEND_PORT_PROBE_HOSTS) { + if (!(yield* net.canListenOnHost(port, host))) { + availableOnEveryHost = false; + break; + } + } + + if (availableOnEveryHost) { + return { + port, + selectedByScan: true, + } as const; + } + } + + return yield* new DesktopBackendPortUnavailableError({ + startPort: DEFAULT_DESKTOP_BACKEND_PORT, + maxPort: MAX_TCP_PORT, + hosts: DESKTOP_BACKEND_PORT_PROBE_HOSTS, + }); +}); + +const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupError")(function* ( + stage: string, + error: unknown, +): Effect.fn.Return< + void, + never, + | DesktopLifecycle.DesktopShutdown + | DesktopState.DesktopState + | ElectronApp.ElectronApp + | ElectronDialog.ElectronDialog +> { + const shutdown = yield* DesktopLifecycle.DesktopShutdown; + const state = yield* DesktopState.DesktopState; + const electronApp = yield* ElectronApp.ElectronApp; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const message = error instanceof Error ? error.message : String(error); + const detail = + error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; + yield* logStartupError("fatal startup error", { + stage, + message, + ...(detail.length > 0 ? { detail } : {}), + }); + const wasQuitting = yield* Ref.getAndSet(state.quitting, true); + if (!wasQuitting) { + yield* electronDialog.showErrorBox( + "T3 Code failed to start", + `Stage: ${stage}\n${message}${detail}`, + ); + } + yield* shutdown.request; + yield* electronApp.quit; +}); + +const fatalStartupCause = (stage: string, cause: Cause.Cause) => + handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); + +const bootstrap = Effect.gen(function* () { + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const state = yield* DesktopState.DesktopState; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* logBootstrapInfo("bootstrap start"); + + if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { + return yield* new DesktopDevelopmentBackendPortRequiredError(); + } + + const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); + const backendPort = backendPortSelection.port; + yield* logBootstrapInfo( + backendPortSelection.selectedByScan + ? "selected backend port via sequential scan" + : "using configured backend port", + { + port: backendPort, + ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}), + }, + ); + + const settings = yield* desktopSettings.get; + if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { + yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", { + mode: settings.serverExposureMode, + }); + } + const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); + const backendConfig = yield* serverExposure.backendConfig; + yield* logBootstrapInfo("bootstrap resolved backend endpoint", { + baseUrl: backendConfig.httpBaseUrl.href, + }); + if (serverExposureState.endpointUrl) { + yield* logBootstrapInfo("bootstrap enabled network access", { + endpointUrl: serverExposureState.endpointUrl, + }); + } else if (settings.serverExposureMode === "network-accessible") { + yield* logBootstrapWarning( + "bootstrap fell back to local-only because no advertised network host was available", + ); + } + + yield* installDesktopIpcHandlers; + yield* logBootstrapInfo("bootstrap ipc handlers registered"); + + if (!(yield* Ref.get(state.quitting))) { + yield* backendManager.start; + yield* logBootstrapInfo("bootstrap backend start requested"); + } +}).pipe(Effect.withSpan("desktop.bootstrap")); + +const startup = Effect.gen(function* () { + const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; + const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; + const electronApp = yield* ElectronApp.ElectronApp; + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const updates = yield* DesktopUpdates.DesktopUpdates; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + + yield* shellEnvironment.installIntoProcess; + const userDataPath = yield* appIdentity.resolveUserDataPath; + yield* electronApp.setPath("userData", userDataPath); + yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); + yield* desktopSettings.load; + + if (environment.platform === "linux") { + yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); + } + + yield* appIdentity.configure; + yield* lifecycle.register; + + yield* electronApp.whenReady.pipe( + Effect.withSpan("desktop.electron.whenReady"), + Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), + ); + yield* logStartupInfo("app ready"); + yield* appIdentity.configure; + yield* applicationMenu.configure; + yield* electronProtocol.registerDesktopFileProtocol; + yield* updates.configure; + yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); +}).pipe(Effect.withSpan("desktop.startup")); + +const scopedProgram = Effect.scoped( + Effect.gen(function* () { + const runId = yield* makeDesktopRunId; + yield* Effect.annotateLogsScoped({ scope: "desktop", runId }); + yield* Effect.annotateCurrentSpan({ scope: "desktop", runId }); + + const shutdown = yield* DesktopLifecycle.DesktopShutdown; + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + + yield* Effect.addFinalizer(() => + backendManager.stop().pipe(Effect.ensuring(shutdown.markComplete)), + ); + + yield* startup; + yield* shutdown.awaitRequest; + }), +); + +export const program = scopedProgram.pipe(Effect.withSpan("desktop.app")); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts new file mode 100644 index 00000000000..f95fd1bef71 --- /dev/null +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -0,0 +1,176 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +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 type * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; +import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const defaultEnvironmentInput = { + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + isPackaged: true, + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + runningUnderArm64Translation: false, +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +type TestEnvironmentInput = Partial & { + readonly env?: Record; +}; + +interface ElectronAppCalls { + readonly setAboutPanelOptions: Array; + readonly setDockIcon: string[]; + readonly setName: string[]; +} + +const makeElectronAppLayer = (calls: ElectronAppCalls) => + Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("T3 Code"), + whenReady: Effect.void, + quit: Effect.void, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: (name) => + Effect.sync(() => { + calls.setName.push(name); + }), + setAboutPanelOptions: (options) => + Effect.sync(() => { + calls.setAboutPanelOptions.push(options); + }), + setAppUserModelId: () => Effect.void, + setDesktopName: () => Effect.void, + setDockIcon: (iconPath) => + Effect.sync(() => { + calls.setDockIcon.push(iconPath); + }), + appendCommandLineSwitch: () => Effect.void, + on: () => Effect.void, + } satisfies ElectronApp.ElectronAppShape); + +const makeAssetsLayer = (png: Option.Option) => + Layer.succeed(DesktopAssets.DesktopAssets, { + iconPaths: Effect.succeed({ + ico: Option.none(), + icns: Option.none(), + png, + }), + resolveResourcePath: () => Effect.succeed(Option.none()), + } satisfies DesktopAssets.DesktopAssetsShape); + +const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => { + const { env, ...environmentOverrides } = overrides; + return DesktopEnvironment.layer({ + ...defaultEnvironmentInput, + ...environmentOverrides, + }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + ...env, + }), + ), + ), + ); +}; + +const withIdentity = ( + effect: Effect.Effect< + A, + E, + | R + | DesktopAppIdentity.DesktopAppIdentity + | DesktopEnvironment.DesktopEnvironment + | FileSystem.FileSystem + >, + input: { + readonly calls?: ElectronAppCalls; + readonly environment?: TestEnvironmentInput; + readonly legacyPathExists?: boolean; + readonly packageJson?: string; + readonly pngIconPath?: Option.Option; + } = {}, +) => { + const calls: ElectronAppCalls = input.calls ?? { + setAboutPanelOptions: [], + setDockIcon: [], + setName: [], + }; + + return effect.pipe( + Effect.provide( + DesktopAppIdentity.layer.pipe( + Layer.provideMerge( + FileSystem.layerNoop({ + exists: (path) => + Effect.succeed(input.legacyPathExists === true && path.includes("T3 Code (Alpha)")), + readFileString: () => + Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'), + }), + ), + Layer.provideMerge(makeAssetsLayer(input.pngIconPath ?? Option.none())), + Layer.provideMerge(makeElectronAppLayer(calls)), + Layer.provideMerge(makeEnvironmentLayer(input.environment)), + ), + ), + ); +}; + +describe("DesktopAppIdentity", () => { + it.effect("keeps using the legacy userData path when it already exists", () => + withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const userDataPath = yield* identity.resolveUserDataPath; + + assert.equal(userDataPath, "/Users/alice/Library/Application Support/T3 Code (Alpha)"); + }), + { legacyPathExists: true }, + ), + ); + + it.effect("configures app identity from the environment commit override", () => { + const calls: ElectronAppCalls = { + setAboutPanelOptions: [], + setDockIcon: [], + setName: [], + }; + + return withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + yield* identity.configure; + + assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]); + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)"); + assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); + assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); + assert.deepEqual(calls.setDockIcon, ["/icon.png"]); + }), + { + calls, + environment: { + env: { + T3CODE_COMMIT_HASH: "0123456789abcdef", + }, + }, + pngIconPath: Option.some("/icon.png"), + }, + ); + }); +}); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts new file mode 100644 index 00000000000..0b9b8196651 --- /dev/null +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -0,0 +1,127 @@ +import * as Context from "effect/Context"; +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; +const COMMIT_HASH_DISPLAY_LENGTH = 12; + +const AppPackageMetadata = Schema.Struct({ + t3codeCommitHash: Schema.optional(Schema.String), +}); + +export interface DesktopAppIdentityShape { + readonly resolveUserDataPath: Effect.Effect; + readonly configure: Effect.Effect; +} + +export class DesktopAppIdentity extends Context.Service< + DesktopAppIdentity, + DesktopAppIdentityShape +>()("t3/desktop/AppIdentity") {} + +const normalizeCommitHash = (value: string): Option.Option => { + const trimmed = value.trim(); + return COMMIT_HASH_PATTERN.test(trimmed) + ? Option.some(trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase()) + : Option.none(); +}; + +const make = Effect.gen(function* () { + const assets = yield* DesktopAssets.DesktopAssets; + const electronApp = yield* ElectronApp.ElectronApp; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const commitHashCache = yield* Ref.make>>(Option.none()); + + const resolveEmbeddedCommitHash = Effect.gen(function* () { + const packageJsonPath = environment.path.join(environment.appRoot, "package.json"); + const raw = yield* fileSystem.readFileString(packageJsonPath).pipe(Effect.option); + return yield* Option.match(raw, { + onNone: () => Effect.succeed(Option.none()), + onSome: (value) => + Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata))(value).pipe( + Effect.map((parsed) => + Option.fromNullishOr(parsed.t3codeCommitHash).pipe(Option.flatMap(normalizeCommitHash)), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ), + }); + }); + + const resolveAboutCommitHash = Effect.gen(function* () { + const cached = yield* Ref.get(commitHashCache); + if (Option.isSome(cached)) { + return cached.value; + } + + const override = Option.flatMap(environment.commitHashOverride, normalizeCommitHash); + if (Option.isSome(override)) { + yield* Ref.set(commitHashCache, Option.some(override)); + return override; + } + + if (!environment.isPackaged) { + const empty = Option.none(); + yield* Ref.set(commitHashCache, Option.some(empty)); + return empty; + } + + const commitHash = yield* resolveEmbeddedCommitHash; + yield* Ref.set(commitHashCache, Option.some(commitHash)); + return commitHash; + }); + + const resolveUserDataPath = Effect.gen(function* () { + const legacyPath = environment.path.join( + environment.appDataDirectory, + environment.legacyUserDataDirName, + ); + const legacyPathExists = yield* fileSystem + .exists(legacyPath) + .pipe(Effect.orElseSucceed(() => false)); + return legacyPathExists + ? legacyPath + : environment.path.join(environment.appDataDirectory, environment.userDataDirName); + }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); + + const configure = Effect.gen(function* () { + const commitHash = yield* resolveAboutCommitHash; + yield* electronApp.setName(environment.displayName); + yield* electronApp.setAboutPanelOptions({ + applicationName: environment.displayName, + applicationVersion: environment.appVersion, + version: Option.getOrElse(commitHash, () => "unknown"), + }); + + if (environment.platform === "win32") { + yield* electronApp.setAppUserModelId(environment.appUserModelId); + } + + if (environment.platform === "linux") { + yield* electronApp.setDesktopName(environment.linuxDesktopEntryName); + } + + if (environment.platform === "darwin") { + const iconPaths = yield* assets.iconPaths; + yield* Option.match(iconPaths.png, { + onNone: () => Effect.void, + onSome: electronApp.setDockIcon, + }); + } + }).pipe(Effect.withSpan("desktop.appIdentity.configure")); + + return DesktopAppIdentity.of({ + resolveUserDataPath, + configure, + }); +}); + +export const layer = Layer.effect(DesktopAppIdentity, make); diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts new file mode 100644 index 00000000000..60ff477d34f --- /dev/null +++ b/apps/desktop/src/app/DesktopAssets.ts @@ -0,0 +1,85 @@ +import * as Context from "effect/Context"; +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 DesktopEnvironment from "./DesktopEnvironment.ts"; + +export interface DesktopIconPaths { + readonly ico: Option.Option; + readonly icns: Option.Option; + readonly png: Option.Option; +} + +export interface DesktopAssetsShape { + readonly iconPaths: Effect.Effect; + readonly resolveResourcePath: (fileName: string) => Effect.Effect>; +} + +export class DesktopAssets extends Context.Service()( + "t3/desktop/Assets", +) {} + +const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(function* ( + fileName: string, +): Effect.fn.Return< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment +> { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const candidates = environment.resolveResourcePathCandidates(fileName); + for (const candidate of candidates) { + const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + return Option.some(candidate); + } + } + return Option.none(); +}); + +const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( + ext: keyof DesktopIconPaths, +): Effect.fn.Return< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment +> { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (environment.isDevelopment && process.platform === "darwin" && ext === "png") { + const developmentDockIconPath = environment.developmentDockIconPath; + const developmentDockIconExists = yield* fileSystem + .exists(developmentDockIconPath) + .pipe(Effect.orElseSucceed(() => false)); + if (developmentDockIconExists) { + return Option.some(developmentDockIconPath); + } + } + + return yield* resolveResourcePath(`icon.${ext}`); +}); + +const make = Effect.gen(function* () { + const context = yield* Effect.context< + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment + >(); + const [ico, icns, png] = yield* Effect.all( + [resolveIconPath("ico"), resolveIconPath("icns"), resolveIconPath("png")] as const, + { concurrency: "unbounded" }, + ); + const iconPaths = { ico, icns, png } satisfies DesktopIconPaths; + + return DesktopAssets.of({ + iconPaths: Effect.succeed(iconPaths), + resolveResourcePath: Effect.fn("desktop.assets.resolveResourcePath.scoped")( + function* (fileName) { + return yield* resolveResourcePath(fileName).pipe(Effect.provide(context)); + }, + ), + }); +}); + +export const layer = Layer.effect(DesktopAssets, make); diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts new file mode 100644 index 00000000000..a9218314018 --- /dev/null +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -0,0 +1,58 @@ +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Option from "effect/Option"; + +const trimNonEmptyOption = (value: string): Option.Option => { + const trimmed = value.trim(); + return trimmed.length > 0 ? Option.some(trimmed) : Option.none(); +}; + +const trimmedString = (name: string) => + Config.string(name).pipe(Config.option, Config.map(Option.flatMap(trimNonEmptyOption))); + +const optionalBoolean = (name: string) => + Config.boolean(name).pipe(Config.option, Config.map(Option.getOrElse(() => false))); + +const commaSeparatedStrings = (name: string) => + trimmedString(name).pipe( + Config.map( + Option.match({ + onNone: () => [], + onSome: (value) => + value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + }), + ), + ); + +const compactEnv = (env: Readonly>): Record => + Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); + +export const DesktopConfig = Config.all({ + appDataDirectory: trimmedString("APPDATA"), + xdgConfigHome: trimmedString("XDG_CONFIG_HOME"), + t3Home: trimmedString("T3CODE_HOME"), + devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option), + devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"), + configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option), + commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"), + desktopLanHostOverride: trimmedString("T3CODE_DESKTOP_LAN_HOST"), + desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"), + otlpTracesUrl: trimmedString("T3CODE_OTLP_TRACES_URL"), + otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( + Config.withDefault(10_000), + ), + appImagePath: trimmedString("APPIMAGE"), + disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), + mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), + mockUpdateServerPort: Config.port("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe( + Config.withDefault(3000), + ), +}); + +export const layerTest = (env: Readonly>) => + ConfigProvider.layer(ConfigProvider.fromEnv({ env: compactEnv(env) })); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts new file mode 100644 index 00000000000..427b8848833 --- /dev/null +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -0,0 +1,117 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; + +const defaultInput = { + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion: "0.0.22", + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + isPackaged: false, + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + runningUnderArm64Translation: false, +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +const makeEnvironmentLayer = ( + overrides: Partial = {}, + env: Record = {}, +) => + DesktopEnvironment.layer({ + ...defaultInput, + ...overrides, + }).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest(env)))); + +const makeEnvironment = ( + overrides: Partial = {}, + env: Record = {}, +) => + Effect.gen(function* () { + return yield* DesktopEnvironment.DesktopEnvironment; + }).pipe(Effect.provide(makeEnvironmentLayer(overrides, env))); + +describe("DesktopEnvironment", () => { + it.effect("derives state paths and development identity inside Effect", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment( + {}, + { + T3CODE_HOME: " /tmp/t3 ", + T3CODE_COMMIT_HASH: " 0123456789abcdef ", + T3CODE_PORT: "4949", + VITE_DEV_SERVER_URL: "http://localhost:5173", + T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", + T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", + T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500", + }, + ); + + assert.equal(environment.isDevelopment, true); + assert.equal(environment.appDataDirectory, "/Users/alice/Library/Application Support"); + assert.equal(environment.baseDir, "/tmp/t3"); + assert.equal(environment.stateDir, "/tmp/t3/dev"); + assert.equal(environment.desktopSettingsPath, "/tmp/t3/dev/desktop-settings.json"); + assert.equal(environment.clientSettingsPath, "/tmp/t3/dev/client-settings.json"); + assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json"); + assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json"); + assert.equal(environment.logDir, "/tmp/t3/dev/logs"); + assert.equal(environment.rootDir, "/repo"); + assert.equal(environment.appRoot, "/repo"); + assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); + assert.equal(environment.backendCwd, "/repo"); + assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); + assert.equal(environment.linuxWmClass, "t3code-dev"); + assert.deepEqual( + Option.map(environment.devServerUrl, (url) => url.href), + Option.some("http://localhost:5173/"), + ); + assert.deepEqual(environment.devRemoteT3ServerEntryPath, Option.some("/remote/server.mjs")); + assert.deepEqual(environment.configuredBackendPort, Option.some(4949)); + assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); + assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces")); + assert.equal(environment.otlpExportIntervalMs, 2500); + }), + ); + + it.effect("derives production state paths under userdata", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment( + {}, + { + T3CODE_HOME: "/tmp/t3", + }, + ); + + assert.equal(environment.isDevelopment, false); + assert.equal(environment.stateDir, "/tmp/t3/userdata"); + assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); + assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); + }), + ); + + it.effect("resolves picker defaults without nullish sentinels", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment(); + + assert.deepEqual(environment.resolvePickFolderDefaultPath(null), Option.none()); + assert.deepEqual( + environment.resolvePickFolderDefaultPath({ initialPath: " " }), + Option.none(), + ); + assert.deepEqual( + environment.resolvePickFolderDefaultPath({ initialPath: "~" }), + Option.some("/Users/alice"), + ); + assert.deepEqual( + environment.resolvePickFolderDefaultPath({ initialPath: "~/project" }), + Option.some("/Users/alice/project"), + ); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts new file mode 100644 index 00000000000..a5212f25358 --- /dev/null +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -0,0 +1,249 @@ +import type { + DesktopAppBranding, + DesktopAppStageLabel, + DesktopRuntimeArch, + DesktopRuntimeInfo, +} from "@t3tools/contracts"; +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { + type DesktopSettings, + resolveDefaultDesktopSettings, +} from "../settings/DesktopAppSettings.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; + +export interface MakeDesktopEnvironmentInput { + readonly dirname: string; + readonly homeDirectory: string; + readonly platform: NodeJS.Platform; + readonly processArch: string; + readonly appVersion: string; + readonly appPath: string; + readonly isPackaged: boolean; + readonly resourcesPath: string; + readonly runningUnderArm64Translation: boolean; +} + +export interface DesktopEnvironmentShape { + readonly path: Path.Path; + readonly dirname: string; + readonly platform: NodeJS.Platform; + readonly processArch: string; + readonly isPackaged: boolean; + readonly isDevelopment: boolean; + readonly appVersion: string; + readonly appPath: string; + readonly resourcesPath: string; + readonly homeDirectory: string; + readonly appDataDirectory: string; + readonly baseDir: string; + readonly stateDir: string; + readonly desktopSettingsPath: string; + readonly clientSettingsPath: string; + readonly savedEnvironmentRegistryPath: string; + readonly serverSettingsPath: string; + readonly logDir: string; + readonly rootDir: string; + readonly appRoot: string; + readonly backendEntryPath: string; + readonly backendCwd: string; + readonly preloadPath: string; + readonly appUpdateYmlPath: string; + readonly devServerUrl: Option.Option; + readonly devRemoteT3ServerEntryPath: Option.Option; + readonly configuredBackendPort: Option.Option; + readonly commitHashOverride: Option.Option; + readonly otlpTracesUrl: Option.Option; + readonly otlpExportIntervalMs: number; + readonly branding: DesktopAppBranding; + readonly displayName: string; + readonly appUserModelId: string; + readonly linuxDesktopEntryName: string; + readonly linuxWmClass: string; + readonly userDataDirName: string; + readonly legacyUserDataDirName: string; + readonly defaultDesktopSettings: DesktopSettings; + readonly runtimeInfo: DesktopRuntimeInfo; + readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option; + readonly resolveResourcePathCandidates: (fileName: string) => readonly string[]; + readonly developmentDockIconPath: string; +} + +export class DesktopEnvironment extends Context.Service< + DesktopEnvironment, + DesktopEnvironmentShape +>()("t3/desktop/Environment") {} + +const APP_BASE_NAME = "T3 Code"; + +function resolveDesktopAppStageLabel(input: { + readonly isDevelopment: boolean; + readonly appVersion: string; +}): DesktopAppStageLabel { + if (input.isDevelopment) { + return "Dev"; + } + + return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; +} + +function resolveDesktopAppBranding(input: { + readonly isDevelopment: boolean; + readonly appVersion: string; +}): DesktopAppBranding { + const stageLabel = resolveDesktopAppStageLabel(input); + return { + baseName: APP_BASE_NAME, + stageLabel, + displayName: `${APP_BASE_NAME} (${stageLabel})`, + }; +} + +function normalizeDesktopArch(arch: string): DesktopRuntimeArch { + if (arch === "arm64") return "arm64"; + if (arch === "x64") return "x64"; + return "other"; +} + +function resolveDesktopRuntimeInfo(input: { + readonly platform: NodeJS.Platform; + readonly processArch: string; + readonly runningUnderArm64Translation: boolean; +}): DesktopRuntimeInfo { + const appArch = normalizeDesktopArch(input.processArch); + + if (input.platform !== "darwin") { + return { + hostArch: appArch, + appArch, + runningUnderArm64Translation: false, + }; + } + + const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch; + + return { + hostArch, + appArch, + runningUnderArm64Translation: input.runningUnderArm64Translation, + }; +} + +const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( + input: MakeDesktopEnvironmentInput, +): Effect.fn.Return { + const path = yield* Path.Path; + const config = yield* DesktopConfig.DesktopConfig; + const homeDirectory = input.homeDirectory; + const devServerUrl = config.devServerUrl; + const isDevelopment = Option.isSome(devServerUrl); + const appDataDirectory = + input.platform === "win32" + ? Option.getOrElse(config.appDataDirectory, () => + path.join(homeDirectory, "AppData", "Roaming"), + ) + : input.platform === "darwin" + ? path.join(homeDirectory, "Library", "Application Support") + : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config")); + const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3")); + const rootDir = path.resolve(input.dirname, "../../.."); + const appRoot = input.isPackaged ? input.appPath : rootDir; + const branding = resolveDesktopAppBranding({ + isDevelopment, + appVersion: input.appVersion, + }); + const displayName = branding.displayName; + const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata"); + const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; + const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; + const resourcesPath = input.resourcesPath; + + return DesktopEnvironment.of({ + path, + dirname: input.dirname, + platform: input.platform, + processArch: input.processArch, + isPackaged: input.isPackaged, + isDevelopment, + appVersion: input.appVersion, + appPath: input.appPath, + resourcesPath, + homeDirectory, + appDataDirectory, + baseDir, + stateDir, + desktopSettingsPath: path.join(stateDir, "desktop-settings.json"), + clientSettingsPath: path.join(stateDir, "client-settings.json"), + savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), + serverSettingsPath: path.join(stateDir, "settings.json"), + logDir: path.join(stateDir, "logs"), + rootDir, + appRoot, + backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), + backendCwd: input.isPackaged ? homeDirectory : appRoot, + preloadPath: path.join(input.dirname, "preload.cjs"), + appUpdateYmlPath: input.isPackaged + ? path.join(resourcesPath, "app-update.yml") + : path.join(input.appPath, "dev-app-update.yml"), + devServerUrl, + devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath, + configuredBackendPort: config.configuredBackendPort, + commitHashOverride: config.commitHashOverride, + otlpTracesUrl: config.otlpTracesUrl, + otlpExportIntervalMs: config.otlpExportIntervalMs, + branding, + displayName, + appUserModelId: isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", + linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", + linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", + userDataDirName, + legacyUserDataDirName, + defaultDesktopSettings: resolveDefaultDesktopSettings(input.appVersion), + runtimeInfo: resolveDesktopRuntimeInfo({ + platform: input.platform, + processArch: input.processArch, + runningUnderArm64Translation: input.runningUnderArm64Translation, + }), + resolvePickFolderDefaultPath: (rawOptions) => { + if (typeof rawOptions !== "object" || rawOptions === null) { + return Option.none(); + } + + const { initialPath } = rawOptions as { initialPath?: unknown }; + if (typeof initialPath !== "string") { + return Option.none(); + } + + const trimmedPath = initialPath.trim(); + if (trimmedPath.length === 0) { + return Option.none(); + } + + if (trimmedPath === "~") { + return Option.some(homeDirectory); + } + + if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) { + return Option.some(path.join(homeDirectory, trimmedPath.slice(2))); + } + + return Option.some(path.resolve(trimmedPath)); + }, + resolveResourcePathCandidates: (fileName) => [ + path.join(input.dirname, "../resources", fileName), + path.join(input.dirname, "../prod-resources", fileName), + path.join(resourcesPath, "resources", fileName), + path.join(resourcesPath, fileName), + ], + developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"), + }); +}); + +export const layer = (input: MakeDesktopEnvironmentInput) => + Layer.effect(DesktopEnvironment, makeDesktopEnvironment(input)); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts new file mode 100644 index 00000000000..b9a7636a411 --- /dev/null +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -0,0 +1,233 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; + +import type * as Electron from "electron"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopObservability from "./DesktopObservability.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; + +export interface DesktopShutdownShape { + readonly request: Effect.Effect; + readonly awaitRequest: Effect.Effect; + readonly markComplete: Effect.Effect; + readonly awaitComplete: Effect.Effect; + readonly isComplete: Effect.Effect; +} + +export class DesktopShutdown extends Context.Service()( + "t3/desktop/Shutdown", +) {} + +const makeShutdown = Effect.gen(function* () { + const requested = yield* Deferred.make(); + const completed = yield* Deferred.make(); + const completedRef = yield* Ref.make(false); + + return DesktopShutdown.of({ + request: Deferred.succeed(requested, undefined).pipe(Effect.asVoid), + awaitRequest: Deferred.await(requested), + markComplete: Ref.set(completedRef, true).pipe( + Effect.andThen(Deferred.succeed(completed, undefined)), + Effect.asVoid, + ), + awaitComplete: Deferred.await(completed), + isComplete: Ref.get(completedRef), + }); +}); + +export const layerShutdown = Layer.effect(DesktopShutdown, makeShutdown); + +export type DesktopLifecycleRuntimeServices = + | DesktopEnvironment.DesktopEnvironment + | DesktopShutdown + | DesktopState.DesktopState + | DesktopWindow.DesktopWindow + | ElectronApp.ElectronApp + | ElectronTheme.ElectronTheme; + +export interface DesktopLifecycleShape { + readonly relaunch: ( + reason: string, + ) => Effect.Effect; + readonly register: Effect.Effect; +} + +export class DesktopLifecycle extends Context.Service()( + "t3/desktop/Lifecycle", +) {} + +const { logInfo: logLifecycleInfo, logError: logLifecycleError } = + DesktopObservability.makeComponentLogger("desktop-lifecycle"); + +function addScopedListener>( + target: unknown, + eventName: string, + listener: (...args: Args) => void, +): Effect.Effect { + const eventTarget = target as { + on: (eventName: string, listener: (...args: Array) => void) => unknown; + removeListener: (eventName: string, listener: (...args: Array) => void) => unknown; + }; + const untypedListener = listener as unknown as (...args: Array) => void; + return Effect.acquireRelease( + Effect.sync(() => { + eventTarget.on(eventName, untypedListener); + }), + () => + Effect.sync(() => { + eventTarget.removeListener(eventName, untypedListener); + }), + ).pipe(Effect.asVoid); +} + +const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( + function* (): Effect.fn.Return { + const shutdown = yield* DesktopShutdown; + yield* shutdown.request; + yield* shutdown.awaitComplete; + }, +); + +function handleBeforeQuit( + event: Electron.Event, + runEffect: (effect: Effect.Effect) => Promise, + allowQuit: () => boolean, + markQuitAllowed: () => void, +): void { + if (allowQuit()) { + void runEffect( + Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + yield* Ref.set(state.quitting, true); + yield* logLifecycleInfo("before-quit received"); + }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), + ); + return; + } + + event.preventDefault(); + void runEffect( + Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + yield* Ref.set(state.quitting, true); + yield* logLifecycleInfo("before-quit received"); + yield* requestDesktopShutdownAndWait(); + }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), + ).finally(() => { + markQuitAllowed(); + void runEffect( + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.quit; + }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")), + ); + }); +} + +function quitFromSignal( + signal: "SIGINT" | "SIGTERM", + runEffect: (effect: Effect.Effect) => Promise, +): void { + void runEffect( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ signal }); + const electronApp = yield* ElectronApp.ElectronApp; + const state = yield* DesktopState.DesktopState; + const wasQuitting = yield* Ref.getAndSet(state.quitting, true); + if (wasQuitting) return; + yield* logLifecycleInfo("process signal received", { signal }); + yield* requestDesktopShutdownAndWait(); + yield* electronApp.quit; + }).pipe(Effect.withSpan("desktop.lifecycle.processSignal")), + ); +} + +export const layer = Layer.succeed( + DesktopLifecycle, + DesktopLifecycle.of({ + relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { + const electronApp = yield* ElectronApp.ElectronApp; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const state = yield* DesktopState.DesktopState; + yield* logLifecycleInfo("desktop relaunch requested", { reason }); + yield* Effect.gen(function* () { + yield* Effect.yieldNow; + yield* Ref.set(state.quitting, true); + yield* requestDesktopShutdownAndWait(); + if (environment.isDevelopment) { + yield* electronApp.exit(75); + return; + } + yield* electronApp.relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), + }); + yield* electronApp.exit(0); + }).pipe( + Effect.catchCause((cause) => + logLifecycleError("desktop relaunch failed", { + cause: Cause.pretty(cause), + }), + ), + Effect.forkDetach, + Effect.asVoid, + ); + }), + register: Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronApp = yield* ElectronApp.ElectronApp; + const electronTheme = yield* ElectronTheme.ElectronTheme; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const context = yield* Effect.context(); + const runEffect = Effect.runPromiseWith(context); + let quitAllowed = false; + yield* electronTheme.onUpdated(() => { + void runEffect( + desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), + ); + }); + yield* electronApp.on("before-quit", (event: Electron.Event) => { + handleBeforeQuit( + event, + runEffect, + () => quitAllowed, + () => { + quitAllowed = true; + }, + ); + }); + yield* electronApp.on("activate", () => { + void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate"))); + }); + yield* electronApp.on("window-all-closed", () => { + void runEffect( + Effect.gen(function* () { + const app = yield* ElectronApp.ElectronApp; + const state = yield* DesktopState.DesktopState; + if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { + yield* app.quit; + } + }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), + ); + }); + + if (environment.platform !== "win32") { + yield* addScopedListener(process, "SIGINT", () => { + quitFromSignal("SIGINT", runEffect); + }); + yield* addScopedListener(process, "SIGTERM", () => { + quitFromSignal("SIGTERM", runEffect); + }); + } + }).pipe(Effect.withSpan("desktop.lifecycle.register")), + }), +); diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts new file mode 100644 index 00000000000..a78de48d5e1 --- /dev/null +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -0,0 +1,162 @@ +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopObservability from "./DesktopObservability.ts"; + +const DesktopBackendChildLogRecord = Schema.Struct({ + message: Schema.String, + level: Schema.Literals(["INFO", "ERROR"]), + timestamp: Schema.String, + annotations: Schema.Record(Schema.String, Schema.Unknown), + spans: Schema.Record(Schema.String, Schema.Unknown), + fiberId: Schema.String, +}); + +const decodeDesktopBackendChildLogRecord = Schema.decodeEffect( + Schema.fromJsonString(DesktopBackendChildLogRecord), +); + +const TraceRecordLine = Schema.Struct({ + name: Schema.String, + attributes: Schema.Record(Schema.String, Schema.Unknown), + events: Schema.Array( + Schema.Struct({ + name: Schema.String, + attributes: Schema.Record(Schema.String, Schema.Unknown), + }), + ), +}); + +const decodeTraceRecordLine = Schema.decodeUnknownSync(Schema.fromJsonString(TraceRecordLine)); + +const environmentInput = (baseDir: string) => + ({ + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: baseDir, + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: false, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, + }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +const makeEnvironmentLayer = (baseDir: string) => + DesktopEnvironment.layer(environmentInput(baseDir)).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_HOME: baseDir, + VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", + }), + ), + ), + ); + +describe("DesktopObservability", () => { + it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir); + const tracePath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "desktop.trace.ndjson"); + }).pipe(Effect.provide(environmentLayer)); + const logPath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "desktop-main.log"); + }).pipe(Effect.provide(environmentLayer)); + + yield* Effect.scoped( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ "desktop.test": true }); + yield* Effect.logInfo("desktop trace event"); + }).pipe( + Effect.withSpan("desktop-observability-test"), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + const records = (yield* fileSystem.readFileString(tracePath)) + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => decodeTraceRecordLine(line)); + const record = records.find((entry) => entry.name === "desktop-observability-test"); + + assert.notEqual(record, undefined); + if (!record) { + return; + } + assert.equal(record.attributes["desktop.test"], true); + assert.equal( + record.events.some((event) => event.name === "desktop trace event"), + true, + ); + assert.isFalse(yield* fileSystem.exists(logPath)); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + ), + ); + + it.effect("persists backend child output as structured JSON records in development", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-output-log-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir); + const logPath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "server-child.log"); + }).pipe(Effect.provide(environmentLayer)); + + yield* Effect.gen(function* () { + const outputLog = yield* DesktopObservability.DesktopBackendOutputLog; + yield* outputLog.writeSessionBoundary({ + phase: "START", + details: "pid=123 port=3773 cwd=/repo", + }); + yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("hello server\n")); + }).pipe( + Effect.annotateLogs({ runId: "test-run" }), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ); + + const log = yield* fileSystem.readFileString(logPath); + const lines = log.trimEnd().split("\n"); + const boundary = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? ""); + const output = yield* decodeDesktopBackendChildLogRecord(lines[1] ?? ""); + + assert.equal(boundary.message, "backend child process session start"); + assert.equal(boundary.level, "INFO"); + assert.equal(boundary.annotations.component, "desktop-backend-child"); + assert.equal(boundary.annotations.runId, "test-run"); + assert.equal(boundary.annotations.phase, "START"); + assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo"); + + assert.equal(output.message, "backend child process output"); + assert.equal(output.level, "INFO"); + assert.equal(output.annotations.component, "desktop-backend-child"); + assert.equal(output.annotations.runId, "test-run"); + assert.equal(output.annotations.stream, "stdout"); + assert.equal(output.annotations.text, "hello server\n"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + ), + ); +}); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts new file mode 100644 index 00000000000..4eeb76bd62a --- /dev/null +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -0,0 +1,395 @@ +import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; +import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Tracer from "effect/Tracer"; +import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; +const DESKTOP_LOG_FILE_MAX_FILES = 10; +const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; +const DESKTOP_TRACE_BATCH_WINDOW_MS = 200; + +export interface RotatingLogFileWriter { + readonly writeBytes: (chunk: Uint8Array) => Effect.Effect; + readonly writeText: (chunk: string) => Effect.Effect; +} + +export interface DesktopBackendOutputLogShape { + readonly writeSessionBoundary: (input: { + readonly phase: "START" | "END"; + readonly details: string; + }) => Effect.Effect; + readonly writeOutputChunk: ( + streamName: "stdout" | "stderr", + chunk: Uint8Array, + ) => Effect.Effect; +} + +export class DesktopBackendOutputLog extends Context.Service< + DesktopBackendOutputLog, + DesktopBackendOutputLogShape +>()("t3/desktop/BackendOutputLog") {} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export type DesktopLogAnnotations = Record; + +export interface DesktopComponentLogger { + readonly annotate: ( + effect: Effect.Effect, + annotations?: DesktopLogAnnotations, + ) => Effect.Effect; + readonly logDebug: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect; + readonly logInfo: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect; + readonly logWarning: ( + message: string, + annotations?: DesktopLogAnnotations, + ) => Effect.Effect; + readonly logError: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect; +} + +export function makeComponentLogger(component: string): DesktopComponentLogger { + const annotate: DesktopComponentLogger["annotate"] = (effect, annotations) => + effect.pipe( + Effect.annotateLogs({ + component, + ...annotations, + }), + ); + + return { + annotate, + logDebug: (message, annotations) => annotate(Effect.logDebug(message), annotations), + logInfo: (message, annotations) => annotate(Effect.logInfo(message), annotations), + logWarning: (message, annotations) => annotate(Effect.logWarning(message), annotations), + logError: (message, annotations) => annotate(Effect.logError(message), annotations), + }; +} + +class DesktopLogFileWriterConfigurationError extends Data.TaggedError( + "DesktopLogFileWriterConfigurationError", +)<{ + readonly option: "maxBytes" | "maxFiles"; + readonly value: number; +}> { + override get message() { + return `${this.option} must be >= 1 (received ${this.value})`; + } +} + +type DesktopLogFileWriterError = + | DesktopLogFileWriterConfigurationError + | PlatformError.PlatformError; + +const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); + +const DesktopBackendChildLogRecord = Schema.Struct({ + message: Schema.String, + level: Schema.Literals(["INFO", "ERROR"]), + timestamp: Schema.String, + annotations: Schema.Record(Schema.String, Schema.Unknown), + spans: Schema.Record(Schema.String, Schema.Unknown), + fiberId: Schema.String, +}); + +const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( + Schema.fromJsonString(DesktopBackendChildLogRecord), +); + +const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { + writeSessionBoundary: () => Effect.void, + writeOutputChunk: () => Effect.void, +}; + +const currentDesktopRunId = Effect.gen(function* () { + const annotations = yield* References.CurrentLogAnnotations; + const runId = annotations.runId; + return typeof runId === "string" && runId.length > 0 ? runId : "unknown"; +}); + +const refreshFileSize = ( + fileSystem: FileSystem.FileSystem, + filePath: string, +): Effect.Effect => + fileSystem.stat(filePath).pipe( + Effect.map((stat) => Number(stat.size)), + Effect.orElseSucceed(() => 0), + ); + +const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { + readonly filePath: string; + readonly maxBytes?: number; + readonly maxFiles?: number; +}): Effect.fn.Return< + RotatingLogFileWriter, + DesktopLogFileWriterError, + FileSystem.FileSystem | Path.Path +> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES; + const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES; + const directory = path.dirname(input.filePath); + const baseName = path.basename(input.filePath); + + if (maxBytes < 1) { + return yield* new DesktopLogFileWriterConfigurationError({ + option: "maxBytes", + value: maxBytes, + }); + } + if (maxFiles < 1) { + return yield* new DesktopLogFileWriterConfigurationError({ + option: "maxFiles", + value: maxFiles, + }); + } + + yield* fileSystem.makeDirectory(directory, { recursive: true }); + + const withSuffix = (index: number) => `${input.filePath}.${index}`; + const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); + const mutex = yield* Semaphore.make(1); + + const pruneOverflowBackups = Effect.gen(function* () { + const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); + for (const entry of entries) { + if (!entry.startsWith(`${baseName}.`)) continue; + const suffix = Number(entry.slice(baseName.length + 1)); + if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; + yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); + } + }); + + const rotate = Effect.gen(function* () { + yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore); + for (let index = maxFiles - 1; index >= 1; index -= 1) { + const source = withSuffix(index); + const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false)); + if (sourceExists) { + yield* fileSystem.rename(source, withSuffix(index + 1)); + } + } + const currentExists = yield* fileSystem + .exists(input.filePath) + .pipe(Effect.orElseSucceed(() => false)); + if (currentExists) { + yield* fileSystem.rename(input.filePath, withSuffix(1)); + } + yield* Ref.set(currentSize, 0); + }).pipe( + Effect.catch(() => + refreshFileSize(fileSystem, input.filePath).pipe( + Effect.flatMap((size) => Ref.set(currentSize, size)), + ), + ), + ); + + const writeBytes = (chunk: Uint8Array): Effect.Effect => { + if (chunk.byteLength === 0) return Effect.void; + + return mutex.withPermits(1)( + Effect.gen(function* () { + const beforeSize = yield* Ref.get(currentSize); + if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) { + yield* rotate; + } + + yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" }); + const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength; + yield* Ref.set(currentSize, afterSize); + + if (afterSize > maxBytes) { + yield* rotate; + } + }).pipe( + Effect.catch(() => + refreshFileSize(fileSystem, input.filePath).pipe( + Effect.flatMap((size) => Ref.set(currentSize, size)), + ), + ), + ), + ); + }; + + yield* pruneOverflowBackups; + + return { + writeBytes, + writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), + } satisfies RotatingLogFileWriter; +}); + +const readPersistedOtlpTracesUrl: Effect.Effect< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment +> = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); + if (Option.isNone(raw)) { + return Option.none(); + } + + const parsed = parsePersistedServerObservabilitySettings(raw.value); + return Option.fromNullishOr(parsed.otlpTracesUrl); +}); + +const resolveOtlpTracesUrl = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (Option.isSome(environment.otlpTracesUrl)) { + return environment.otlpTracesUrl; + } + return yield* readPersistedOtlpTracesUrl; +}); + +const writeDevelopmentConsoleOutput = ( + streamName: "stdout" | "stderr", + chunk: Uint8Array, +): Effect.Effect => + Effect.sync(() => { + const output = streamName === "stderr" ? process.stderr : process.stdout; + output.write(chunk); + }).pipe(Effect.ignore); + +const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( + function* ( + logFile: RotatingLogFileWriter, + input: { + readonly message: string; + readonly level: "INFO" | "ERROR"; + readonly annotations: Record; + }, + ): Effect.fn.Return { + return yield* Effect.gen(function* () { + const timestamp = DateTime.formatIso(yield* DateTime.now); + const encoded = yield* encodeDesktopBackendChildLogRecord({ + message: input.message, + level: input.level, + timestamp, + annotations: input.annotations, + spans: {}, + fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, + }); + yield* logFile.writeText(`${encoded}\n`); + }).pipe(Effect.ignore({ log: true })); + }, +); + +const backendOutputLogLayer = Layer.effect( + DesktopBackendOutputLog, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + + const writer = yield* makeRotatingLogFileWriter({ + filePath: environment.path.join(environment.logDir, "server-child.log"), + }).pipe(Effect.option); + + return Option.match(writer, { + onNone: () => DesktopBackendOutputLogNoop, + onSome: (logFile) => + ({ + writeSessionBoundary: Effect.fn( + "desktop.observability.backendOutput.writeSessionBoundary", + )(function* ({ phase, details }) { + const runId = yield* currentDesktopRunId; + yield* writeBackendChildLogRecord(logFile, { + message: `backend child process session ${phase.toLowerCase()}`, + level: "INFO", + annotations: { + component: "desktop-backend-child", + runId, + phase, + details: sanitizeLogValue(details), + }, + }); + }), + writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( + function* (streamName, chunk) { + if (environment.isDevelopment) { + yield* writeDevelopmentConsoleOutput(streamName, chunk); + } + const runId = yield* currentDesktopRunId; + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process output", + level: streamName === "stderr" ? "ERROR" : "INFO", + annotations: { + component: "desktop-backend-child", + runId, + stream: streamName, + text: textDecoder.decode(chunk), + }, + }); + }, + ), + }) satisfies DesktopBackendOutputLogShape, + }); + }), +); + +const desktopLoggerLayer = Layer.mergeAll( + Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Info"), +); + +const tracerLayer = Layer.unwrap( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const otlpTracesUrl = yield* resolveOtlpTracesUrl; + const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson"); + const sink = yield* makeTraceSink({ + filePath: tracePath, + maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, + maxFiles: DESKTOP_LOG_FILE_MAX_FILES, + batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, + }); + const delegate = Option.isNone(otlpTracesUrl) + ? undefined + : yield* OtlpTracer.make({ + url: otlpTracesUrl.value, + exportInterval: `${environment.otlpExportIntervalMs} millis`, + resource: { + serviceName: "desktop", + attributes: { + "service.runtime": "desktop", + "service.mode": environment.isDevelopment ? "development" : "packaged", + }, + }, + }); + const tracer = yield* makeLocalFileTracer({ + filePath: tracePath, + maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, + maxFiles: DESKTOP_LOG_FILE_MAX_FILES, + batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, + sink, + ...(delegate ? { delegate } : {}), + }); + + return Layer.succeed(Tracer.Tracer, tracer); + }), +).pipe(Layer.provideMerge(OtlpSerialization.layerJson)); + +export const layer = Layer.mergeAll( + backendOutputLogLayer, + desktopLoggerLayer, + tracerLayer, + Layer.succeed(Tracer.MinimumTraceLevel, "Info"), + Layer.succeed(References.TracerTimingEnabled, true), +); diff --git a/apps/desktop/src/app/DesktopState.ts b/apps/desktop/src/app/DesktopState.ts new file mode 100644 index 00000000000..43960ada65f --- /dev/null +++ b/apps/desktop/src/app/DesktopState.ts @@ -0,0 +1,21 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +export interface DesktopStateShape { + readonly backendReady: Ref.Ref; + readonly quitting: Ref.Ref; +} + +export class DesktopState extends Context.Service()( + "t3/desktop/State", +) {} + +export const layer = Layer.effect( + DesktopState, + Effect.all({ + backendReady: Ref.make(false), + quitting: Ref.make(false), + }), +); diff --git a/apps/desktop/src/appBranding.test.ts b/apps/desktop/src/appBranding.test.ts deleted file mode 100644 index 5e3e3a5a159..00000000000 --- a/apps/desktop/src/appBranding.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding.ts"; - -describe("resolveDesktopAppStageLabel", () => { - it("uses Dev in desktop development", () => { - expect( - resolveDesktopAppStageLabel({ - isDevelopment: true, - appVersion: "0.0.17-nightly.20260414.1", - }), - ).toBe("Dev"); - }); - - it("uses Nightly for packaged nightly builds", () => { - expect( - resolveDesktopAppStageLabel({ - isDevelopment: false, - appVersion: "0.0.17-nightly.20260414.1", - }), - ).toBe("Nightly"); - }); - - it("uses Alpha for packaged stable builds", () => { - expect( - resolveDesktopAppStageLabel({ - isDevelopment: false, - appVersion: "0.0.17", - }), - ).toBe("Alpha"); - }); -}); - -describe("resolveDesktopAppBranding", () => { - it("returns a complete desktop branding payload", () => { - expect( - resolveDesktopAppBranding({ - isDevelopment: false, - appVersion: "0.0.17-nightly.20260414.1", - }), - ).toEqual({ - baseName: "T3 Code", - stageLabel: "Nightly", - displayName: "T3 Code (Nightly)", - }); - }); -}); diff --git a/apps/desktop/src/appBranding.ts b/apps/desktop/src/appBranding.ts deleted file mode 100644 index 3cb1539f761..00000000000 --- a/apps/desktop/src/appBranding.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contracts"; - -import { isNightlyDesktopVersion } from "./updateChannels.ts"; - -const APP_BASE_NAME = "T3 Code"; - -export function resolveDesktopAppStageLabel(input: { - readonly isDevelopment: boolean; - readonly appVersion: string; -}): DesktopAppStageLabel { - if (input.isDevelopment) { - return "Dev"; - } - - return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; -} - -export function resolveDesktopAppBranding(input: { - readonly isDevelopment: boolean; - readonly appVersion: string; -}): DesktopAppBranding { - const stageLabel = resolveDesktopAppStageLabel(input); - return { - baseName: APP_BASE_NAME, - stageLabel, - displayName: `${APP_BASE_NAME} (${stageLabel})`, - }; -} diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts new file mode 100644 index 00000000000..de336ffb89c --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -0,0 +1,195 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; + +const PersistedServerObservabilitySettingsDocument = Schema.Struct({ + observability: Schema.Struct({ + otlpTracesUrl: Schema.String, + otlpMetricsUrl: Schema.String, + }), +}); + +const encodePersistedServerObservabilitySettingsDocument = Schema.encodeEffect( + Schema.fromJsonString(PersistedServerObservabilitySettingsDocument), +); + +const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { + getState: Effect.die("unexpected getState"), + backendConfig: Effect.succeed({ + port: 4888, + bindHost: "0.0.0.0", + httpBaseUrl: new URL("http://127.0.0.1:4888"), + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + }), + configureFromSettings: () => Effect.die("unexpected configureFromSettings"), + setMode: () => Effect.die("unexpected setMode"), + setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), + getAdvertisedEndpoints: Effect.succeed([]), +} satisfies DesktopServerExposure.DesktopServerExposureShape); + +function makeEnvironmentLayer( + baseDir: string, + options?: { + readonly isPackaged?: boolean; + readonly devServerUrl?: string; + }, +) { + return DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: options?.isPackaged ?? true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_HOME: baseDir, + T3CODE_PORT: "9999", + T3CODE_MODE: "desktop", + T3CODE_DESKTOP_LAN_HOST: "192.168.1.50", + VITE_DEV_SERVER_URL: options?.devServerUrl, + }), + ), + ), + ); +} + +const withHarness = ( + effect: Effect.Effect< + A, + E, + | R + | DesktopEnvironment.DesktopEnvironment + | FileSystem.FileSystem + | DesktopBackendConfiguration.DesktopBackendConfiguration + >, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + return yield* effect.pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(makeEnvironmentLayer(baseDir)), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + +describe("DesktopBackendConfiguration", () => { + it.effect("resolves backend start config with a stable scoped bootstrap token", () => + withHarness( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + + const first = yield* configuration.resolve; + const second = yield* configuration.resolve; + + assert.equal(first.executablePath, process.execPath); + assert.equal(first.entryPath, environment.backendEntryPath); + assert.equal(first.cwd, environment.backendCwd); + assert.equal(first.captureOutput, true); + assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1"); + assert.isUndefined(first.env.T3CODE_PORT); + assert.isUndefined(first.env.T3CODE_MODE); + assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST); + + assert.equal(first.bootstrap.mode, "desktop"); + assert.equal(first.bootstrap.noBrowser, true); + assert.equal(first.bootstrap.port, 4888); + assert.equal(first.bootstrap.host, "0.0.0.0"); + assert.equal(first.bootstrap.t3Home, environment.baseDir); + assert.equal(first.bootstrap.tailscaleServeEnabled, true); + assert.equal(first.bootstrap.tailscaleServePort, 8443); + assert.match(first.bootstrap.desktopBootstrapToken, /^[0-9a-f]{48}$/i); + assert.equal(second.bootstrap.desktopBootstrapToken, first.bootstrap.desktopBootstrapToken); + }), + ), + ); + + it.effect("includes persisted backend observability endpoints when present", () => + withHarness( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + + yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), { + recursive: true, + }); + yield* fileSystem.writeFileString( + environment.serverSettingsPath, + yield* encodePersistedServerObservabilitySettingsDocument({ + observability: { + otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", + otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", + }, + }), + ); + + const config = yield* configuration.resolve; + assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); + assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); + }), + ), + ); + + it.effect("omits backend observability endpoints when settings are missing", () => + withHarness( + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolve; + + assert.isUndefined(config.bootstrap.otlpTracesUrl); + assert.isUndefined(config.bootstrap.otlpMetricsUrl); + }), + ), + ); + + it.effect("captures backend output in development so child process logs can be persisted", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolve; + assert.equal(config.captureOutput, true); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + isPackaged: false, + devServerUrl: "http://127.0.0.1:5733", + }), + ), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts new file mode 100644 index 00000000000..45103bee92e --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -0,0 +1,170 @@ +import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import * as Context from "effect/Context"; +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 Random from "effect/Random"; +import * as Ref from "effect/Ref"; + +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; + +export interface DesktopBackendConfigurationShape { + readonly resolve: Effect.Effect; +} + +export class DesktopBackendConfiguration extends Context.Service< + DesktopBackendConfiguration, + DesktopBackendConfigurationShape +>()("t3/desktop/BackendConfiguration") {} + +interface BackendObservabilitySettings { + readonly otlpTracesUrl: Option.Option; + readonly otlpMetricsUrl: Option.Option; +} + +const emptyBackendObservabilitySettings: BackendObservabilitySettings = { + otlpTracesUrl: Option.none(), + otlpMetricsUrl: Option.none(), +}; + +const DESKTOP_BACKEND_ENV_NAMES = [ + "T3CODE_PORT", + "T3CODE_MODE", + "T3CODE_NO_BROWSER", + "T3CODE_HOST", + "T3CODE_DESKTOP_WS_URL", + "T3CODE_DESKTOP_LAN_ACCESS", + "T3CODE_DESKTOP_LAN_HOST", + "T3CODE_DESKTOP_HTTPS_ENDPOINTS", + "T3CODE_TAILSCALE_SERVE", + "T3CODE_TAILSCALE_SERVE_PORT", +] as const; + +const backendChildEnvPatch = (): Record => + Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); + +const { logWarning: logBackendConfigurationWarning } = DesktopObservability.makeComponentLogger( + "desktop-backend-configuration", +); + +const readPersistedBackendObservabilitySettings: Effect.Effect< + BackendObservabilitySettings, + never, + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment +> = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const exists = yield* fileSystem + .exists(environment.serverSettingsPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return emptyBackendObservabilitySettings; + } + + const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); + if (Option.isNone(raw)) { + yield* logBackendConfigurationWarning( + "failed to read persisted backend observability settings", + ); + return emptyBackendObservabilitySettings; + } + + const parsed = parsePersistedServerObservabilitySettings(raw.value); + return { + otlpTracesUrl: Option.fromNullishOr(parsed.otlpTracesUrl), + otlpMetricsUrl: Option.fromNullishOr(parsed.otlpMetricsUrl), + }; +}); + +const getOrCreateBootstrapToken = Effect.fn("desktop.backendConfiguration.bootstrapToken")( + function* (tokenRef: Ref.Ref>) { + const existing = yield* Ref.get(tokenRef); + if (Option.isSome(existing)) { + return existing.value; + } + + let token = ""; + while (token.length < 48) { + token += (yield* Random.nextUUIDv4).replace(/-/g, ""); + } + token = token.slice(0, 48); + yield* Ref.set(tokenRef, Option.some(token)); + return token; + }, +); + +const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolveStartConfig")( + function* (input: { + readonly bootstrapToken: string; + readonly observabilitySettings: BackendObservabilitySettings; + }): Effect.fn.Return< + DesktopBackendManager.DesktopBackendStartConfig, + never, + DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure + > { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const backendExposure = yield* serverExposure.backendConfig; + + return { + executablePath: process.execPath, + entryPath: environment.backendEntryPath, + cwd: environment.backendCwd, + env: { + ...backendChildEnvPatch(), + ELECTRON_RUN_AS_NODE: "1", + }, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: backendExposure.port, + t3Home: environment.baseDir, + host: backendExposure.bindHost, + desktopBootstrapToken: input.bootstrapToken, + tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, + tailscaleServePort: backendExposure.tailscaleServePort, + ...Option.match(input.observabilitySettings.otlpTracesUrl, { + onNone: () => ({}), + onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), + }), + ...Option.match(input.observabilitySettings.otlpMetricsUrl, { + onNone: () => ({}), + onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), + }), + }, + httpBaseUrl: backendExposure.httpBaseUrl, + captureOutput: true, + }; + }, +); + +export const layer = Layer.effect( + DesktopBackendConfiguration, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const tokenRef = yield* Ref.make(Option.none()); + + return DesktopBackendConfiguration.of({ + resolve: Effect.gen(function* () { + const bootstrapToken = yield* getOrCreateBootstrapToken(tokenRef); + const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + ); + return yield* resolveBackendStartConfig({ + bootstrapToken, + observabilitySettings, + }).pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), + ); + }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")), + }); + }), +); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts new file mode 100644 index 00000000000..cfd32fee664 --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -0,0 +1,490 @@ +import { + DesktopBackendBootstrap, + type DesktopBackendBootstrap as DesktopBackendBootstrapValue, +} from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +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 Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; + +const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { + executablePath: "/electron", + entryPath: "/server/bin.mjs", + cwd: "/server", + env: { ELECTRON_RUN_AS_NODE: "1" }, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3773, + t3Home: "/tmp/t3", + host: "127.0.0.1", + desktopBootstrapToken: "token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }, + httpBaseUrl: new URL("http://127.0.0.1:3773"), + captureOutput: true, +}; + +const configWithObservability: DesktopBackendBootstrapValue = { + ...baseConfig.bootstrap, + tailscaleServeEnabled: true, + otlpTracesUrl: "http://127.0.0.1:4318/v1/traces", +}; + +function makeProcess(options?: { + readonly stdout?: Stream.Stream; + readonly stderr?: Stream.Stream; + readonly exitCode?: Effect.Effect; + readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; +}): ChildProcessSpawner.ChildProcessHandle { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: options?.stdout ?? Stream.empty, + stderr: options?.stderr ?? Stream.empty, + all: Stream.merge(options?.stdout ?? Stream.empty, options?.stderr ?? Stream.empty), + exitCode: options?.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: options?.kill ?? (() => Effect.void), + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); +} + +function responseForRequest( + request: HttpClientRequest.HttpClientRequest, + status: number, +): HttpClientResponse.HttpClientResponse { + return HttpClientResponse.fromWeb(request, new Response(null, { status })); +} + +function httpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +const healthyHttpClientLayer = httpClientLayer((request) => + Effect.succeed(responseForRequest(request, 200)), +); + +function decodeBootstrap(raw: string) { + return Schema.decodeEffect(Schema.fromJsonString(DesktopBackendBootstrap))(raw); +} + +function makeManagerLayer(input: { + readonly spawnerLayer: Layer.Layer; + readonly httpClientLayer?: Layer.Layer; + readonly backendOutputLog?: Partial; + readonly desktopState?: DesktopState.DesktopStateShape; + readonly desktopWindow?: Partial; + readonly config?: DesktopBackendManager.DesktopBackendStartConfig; +}) { + return DesktopBackendManager.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + exists: () => Effect.succeed(true), + }), + Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { + resolve: Effect.succeed(input.config ?? baseConfig), + }), + input.spawnerLayer, + input.httpClientLayer ?? healthyHttpClientLayer, + input.desktopState + ? Layer.succeed(DesktopState.DesktopState, input.desktopState) + : DesktopState.layer, + Layer.succeed(DesktopObservability.DesktopBackendOutputLog, { + writeSessionBoundary: () => Effect.void, + writeOutputChunk: () => Effect.void, + ...input.backendOutputLog, + } satisfies DesktopObservability.DesktopBackendOutputLogShape), + Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected createMain"), + ensureMain: Effect.die("unexpected ensureMain"), + revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + handleBackendReady: Effect.void, + dispatchMenuAction: () => Effect.void, + syncAppearance: Effect.void, + ...input.desktopWindow, + } satisfies DesktopWindow.DesktopWindowShape), + ), + ), + ); +} + +describe("DesktopBackendManager", () => { + it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => + Effect.gen(function* () { + let spawnedCommand: ChildProcess.Command | undefined; + let bootstrapJson = ""; + let readyCount = 0; + const ready = yield* Deferred.make(); + const exited = yield* Queue.unbounded(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + spawnedCommand = command; + if (command._tag === "StandardCommand") { + const fd3 = command.options.additionalFds?.fd3; + if (fd3?.type === "input" && fd3.stream) { + bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); + } + } + + return makeProcess({ + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + config: { + ...baseConfig, + bootstrap: configWithObservability, + }, + spawnerLayer, + desktopWindow: { + handleBackendReady: Effect.sync(() => { + readyCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), + }, + backendOutputLog: { + writeSessionBoundary: ({ phase }) => + phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + yield* Queue.take(exited); + + assert.equal(readyCount, 1); + assert.isDefined(spawnedCommand); + if (spawnedCommand._tag !== "StandardCommand") { + throw new Error("Expected backend to spawn a standard command."); + } + + assert.equal(spawnedCommand.command, "/electron"); + assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]); + assert.equal(spawnedCommand.options.cwd, "/server"); + assert.equal(spawnedCommand.options.extendEnv, true); + assert.equal(spawnedCommand.options.stdout, "pipe"); + assert.equal(spawnedCommand.options.stderr, "pipe"); + assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); + assert.isDefined(spawnedCommand.options.forceKillAfter); + assert.equal( + Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), + 2_000, + ); + + assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); + }).pipe(Effect.provide(managerLayer)); + }), + ); + + it.effect("retries HTTP readiness before reporting the backend ready", () => + Effect.gen(function* () { + const requestUrls: Array = []; + const statuses = [503, 200]; + let readyCount = 0; + const firstRequest = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const exited = yield* Queue.unbounded(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer((request) => + Effect.gen(function* () { + const status = statuses.shift(); + assert.isDefined(status); + requestUrls.push(request.url); + yield* Deferred.succeed(firstRequest, void 0); + return responseForRequest(request, status); + }), + ), + desktopWindow: { + handleBackendReady: Effect.sync(() => { + readyCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), + }, + backendOutputLog: { + writeSessionBoundary: ({ phase }) => + phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + yield* Deferred.await(firstRequest); + + assert.equal(readyCount, 0); + assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]); + + yield* TestClock.adjust(Duration.millis(100)); + yield* Queue.take(exited); + + assert.equal(readyCount, 1); + assert.deepEqual(requestUrls, [ + "http://127.0.0.1:3773/.well-known/t3/environment", + "http://127.0.0.1:3773/.well-known/t3/environment", + ]); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + + it.effect("starts the configured backend and closes the scoped process on stop", () => + Effect.gen(function* () { + let startCount = 0; + let closedCount = 0; + const closed = yield* Deferred.make(); + const startedPids = yield* Queue.unbounded(); + const ready = yield* Deferred.make(); + const backendReady = yield* Ref.make(false); + const quitting = yield* Ref.make(false); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + startCount += 1; + yield* Queue.offer(startedPids, 123); + const close = Effect.sync(() => { + closedCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid); + + yield* Scope.addFinalizer(scope, close); + + return makeProcess({ + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + kill: () => close, + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + desktopState: { + backendReady, + quitting, + }, + desktopWindow: { + handleBackendReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + assert.isTrue(Option.isNone(yield* manager.currentConfig)); + + yield* manager.start; + assert.equal(yield* Queue.take(startedPids), 123); + yield* Deferred.await(ready); + assert.isTrue(yield* Ref.get(backendReady)); + assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig)); + + const runningSnapshot = yield* manager.snapshot; + assert.equal(runningSnapshot.ready, true); + assert.deepEqual(runningSnapshot.activePid, Option.some(123)); + + yield* manager.stop(); + assert.equal(startCount, 1); + assert.equal(closedCount, 1); + + const stoppedSnapshot = yield* manager.snapshot; + assert.isFalse(yield* Ref.get(backendReady)); + assert.equal(stoppedSnapshot.desiredRunning, false); + assert.equal(stoppedSnapshot.ready, false); + assert.equal(Option.isNone(stoppedSnapshot.activePid), true); + }).pipe(Effect.provide(managerLayer)); + }), + ); + + it.effect("restarts an unexpectedly exited backend with the Effect clock", () => + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Queue.offer(starts, startCount).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + + assert.equal(yield* Queue.take(starts), 1); + + yield* TestClock.adjust(Duration.millis(499)); + assert.equal(yield* Queue.size(starts), 0); + yield* TestClock.adjust(Duration.millis(1)); + assert.equal(yield* Queue.take(starts), 2); + + yield* TestClock.adjust(Duration.millis(999)); + assert.equal(yield* Queue.size(starts), 0); + yield* TestClock.adjust(Duration.millis(1)); + assert.equal(yield* Queue.take(starts), 3); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + + it.effect("cancels a scheduled restart when start is requested manually", () => + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const secondClosed = yield* Deferred.make(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + startCount += 1; + yield* Queue.offer(starts, startCount); + + if (startCount === 1) { + return makeProcess({ + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + }); + } + + const scope = yield* Scope.Scope; + const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid); + yield* Scope.addFinalizer(scope, close); + return makeProcess({ + exitCode: Deferred.await(secondClosed).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + kill: () => close, + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + + assert.equal(yield* Queue.take(starts), 1); + + yield* manager.start; + assert.equal(yield* Queue.take(starts), 2); + + yield* manager.stop(); + yield* TestClock.adjust(Duration.millis(500)); + + assert.equal(yield* Queue.size(starts), 0); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + + it.effect("does not restart after stop cancels a scheduled restart", () => + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Queue.offer(starts, startCount).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + assert.equal(yield* Queue.take(starts), 1); + + let restartScheduled = false; + while (!restartScheduled) { + restartScheduled = (yield* manager.snapshot).restartScheduled; + if (!restartScheduled) { + yield* Effect.yieldNow; + } + } + + yield* manager.stop(); + yield* TestClock.adjust(Duration.millis(500)); + + assert.equal(yield* Queue.size(starts), 0); + assert.equal((yield* manager.snapshot).desiredRunning, false); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); +}); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts new file mode 100644 index 00000000000..97931f42dbd --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -0,0 +1,596 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + DesktopBackendBootstrap, + type DesktopBackendBootstrap as DesktopBackendBootstrapValue, +} from "@t3tools/contracts"; + +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; + +const INITIAL_RESTART_DELAY = Duration.millis(500); +const MAX_RESTART_DELAY = Duration.seconds(10); +const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); +const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); +const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); +const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2); +const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; + +type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient; + +type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; + +export type BackendProcessOutputStream = "stdout" | "stderr"; + +export interface DesktopBackendStartConfig { + readonly executablePath: string; + readonly entryPath: string; + readonly cwd: string; + readonly env: Record; + readonly bootstrap: DesktopBackendBootstrapValue; + readonly httpBaseUrl: URL; + readonly captureOutput: boolean; +} + +interface BackendProcessExit { + readonly code: Option.Option; + readonly reason: string; + readonly result: Result.Result; +} + +export class BackendTimeoutError extends Data.TaggedError("BackendTimeoutError")<{ + readonly url: URL; +}> { + override get message() { + return `Timed out waiting for backend readiness at ${this.url.href}.`; + } +} + +class BackendProcessBootstrapEncodeError extends Data.TaggedError( + "BackendProcessBootstrapEncodeError", +)<{ + readonly cause: Schema.SchemaError; +}> { + override get message() { + return `Failed to encode desktop backend bootstrap payload: ${this.cause.message}`; + } +} + +class BackendProcessSpawnError extends Data.TaggedError("BackendProcessSpawnError")<{ + readonly cause: PlatformError.PlatformError; +}> { + override get message() { + return `Failed to spawn desktop backend process: ${this.cause.message}`; + } +} + +type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; + +interface RunBackendProcessOptions extends DesktopBackendStartConfig { + readonly readinessTimeout?: Duration.Duration; + readonly onStarted?: (pid: number) => Effect.Effect; + readonly onReady?: () => Effect.Effect; + readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect; + readonly onOutput?: ( + streamName: BackendProcessOutputStream, + chunk: Uint8Array, + ) => Effect.Effect; +} + +export interface DesktopBackendSnapshot { + readonly desiredRunning: boolean; + readonly ready: boolean; + readonly activePid: Option.Option; + readonly restartAttempt: number; + readonly restartScheduled: boolean; +} + +export interface DesktopBackendManagerShape { + readonly start: Effect.Effect; + readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; + readonly currentConfig: Effect.Effect>; + readonly snapshot: Effect.Effect; +} + +export class DesktopBackendManager extends Context.Service< + DesktopBackendManager, + DesktopBackendManagerShape +>()("t3/desktop/BackendManager") {} + +const { logWarning: logBackendManagerWarning, logError: logBackendManagerError } = + DesktopObservability.makeComponentLogger("desktop-backend-manager"); + +interface ActiveBackendRun { + readonly id: number; + readonly scope: Scope.Closeable; + readonly fiber: Option.Option>; + readonly pid: Option.Option; +} + +interface BackendManagerState { + readonly desiredRunning: boolean; + readonly ready: boolean; + readonly config: Option.Option; + readonly active: Option.Option; + readonly restartAttempt: number; + readonly restartFiber: Option.Option>; + readonly nextRunId: number; +} + +const initialState: BackendManagerState = { + desiredRunning: false, + ready: false, + config: Option.none(), + active: Option.none(), + restartAttempt: 0, + restartFiber: Option.none(), + nextRunId: 1, +}; + +const activePid = (active: Option.Option): Option.Option => + Option.flatMap(active, (run) => run.pid); + +const withActiveRun = + (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => + (state: BackendManagerState): BackendManagerState => ({ + ...state, + active: Option.map(state.active, (run) => (run.id === runId ? f(run) : run)), + }); + +const calculateRestartDelay = (attempt: number): Duration.Duration => + Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY); + +const closeRun = ( + run: ActiveBackendRun, + options?: { readonly timeout?: Duration.Duration }, +): Effect.Effect => { + const waitForFiber = Option.match(run.fiber, { + onNone: () => Effect.void, + onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid), + }); + const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber)); + + return ( + options?.timeout ? close.pipe(Effect.timeoutOption(options.timeout), Effect.asVoid) : close + ).pipe(Effect.ignore); +}; + +const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( + baseUrl: URL, + timeout: Duration.Duration, +): Effect.fn.Return { + const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl); + const client = (yield* HttpClient.HttpClient).pipe( + HttpClient.filterStatusOk, + HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)), + HttpClient.retry(Schedule.spaced(DEFAULT_BACKEND_READINESS_INTERVAL)), + ); + + yield* client.get(readinessUrl).pipe( + Effect.asVoid, + Effect.timeout(timeout), + Effect.mapError(() => new BackendTimeoutError({ url: readinessUrl })), + ); +}); + +function describeProcessExit( + result: Result.Result, +): BackendProcessExit { + if (Result.isSuccess(result)) { + return { + code: Option.some(result.success), + reason: `code=${result.success}`, + result, + }; + } + + return { + code: Option.none(), + reason: result.failure.message, + result, + }; +} + +function drainBackendOutput( + streamName: BackendProcessOutputStream, + stream: Stream.Stream, + onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect, +): Effect.Effect { + return stream.pipe( + Stream.runForEach((chunk) => onOutput(streamName, chunk)), + Effect.ignore, + ); +} + +const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); + +const runBackendProcess = Effect.fn("runBackendProcess")(function* ( + options: RunBackendProcessOptions, +): Effect.fn.Return { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( + Effect.mapError((cause) => new BackendProcessBootstrapEncodeError({ cause })), + ); + const onOutput = options.onOutput ?? (() => Effect.void); + const command = ChildProcess.make( + options.executablePath, + [options.entryPath, "--bootstrap-fd", "3"], + { + cwd: options.cwd, + env: options.env, + extendEnv: true, + // In Electron main, process.execPath points to the Electron binary. + // Run the child in Node mode so this backend process does not become a GUI app instance. + stdin: "ignore", + stdout: options.captureOutput ? "pipe" : "inherit", + stderr: options.captureOutput ? "pipe" : "inherit", + killSignal: "SIGTERM", + forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, + additionalFds: { + fd3: { + type: "input", + stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)), + }, + }, + }, + ); + + const handle = yield* spawner + .spawn(command) + .pipe(Effect.mapError((cause) => new BackendProcessSpawnError({ cause }))); + + yield* options.onStarted?.(handle.pid) ?? Effect.void; + if (options.captureOutput) { + yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); + yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); + } + yield* waitForHttpReady( + options.httpBaseUrl, + options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + ).pipe( + Effect.tap(() => options.onReady?.() ?? Effect.void), + Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), + Effect.forkScoped, + ); + + return describeProcessExit(yield* Effect.result(handle.exitCode)); +}); + +const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () { + const parentScope = yield* Scope.Scope; + const fileSystem = yield* FileSystem.FileSystem; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog; + const desktopState = yield* DesktopState.DesktopState; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const state = yield* Ref.make(initialState); + const mutex = yield* Semaphore.make(1); + + const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => + Ref.update(state, withActiveRun(runId, f)); + + const snapshot = Ref.get(state).pipe( + Effect.map( + (current): DesktopBackendSnapshot => ({ + desiredRunning: current.desiredRunning, + ready: current.ready, + activePid: activePid(current.active), + restartAttempt: current.restartAttempt, + restartScheduled: Option.isSome(current.restartFiber), + }), + ), + ); + const currentConfig = Ref.get(state).pipe(Effect.map((current) => current.config)); + + const cancelRestart = Effect.gen(function* () { + const restartFiber = yield* Ref.modify(state, (current) => [ + current.restartFiber, + { + ...current, + restartFiber: Option.none(), + }, + ]); + + yield* Option.match(restartFiber, { + onNone: () => Effect.void, + onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), + }); + }); + + const start: Effect.Effect = Effect.suspend(() => + mutex.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(state); + if (Option.isSome(current.active)) { + return; + } + + yield* Ref.set(desktopState.backendReady, false); + const config = yield* configuration.resolve; + const entryExists = yield* fileSystem + .exists(config.entryPath) + .pipe(Effect.orElseSucceed(() => false)); + + yield* cancelRestart; + yield* Ref.update(state, (latest) => ({ + ...latest, + desiredRunning: true, + ready: false, + config: Option.some(config), + })); + + if (!entryExists) { + yield* scheduleRestart(`missing server entry at ${config.entryPath}`); + return; + } + + const runScope = yield* Scope.make("sequential"); + const runId = yield* Ref.modify(state, (latest) => [ + latest.nextRunId, + { + ...latest, + active: Option.some({ + id: latest.nextRunId, + scope: runScope, + fiber: Option.none(), + pid: Option.none(), + } satisfies ActiveBackendRun), + nextRunId: latest.nextRunId + 1, + }, + ]); + + const finalizeRun = Effect.fn("desktop.backendManager.finalizeRun")(function* ( + reason: string, + ) { + yield* mutex.withPermits(1)( + Effect.gen(function* () { + const { isCurrentRun, nextState, pid } = yield* Ref.modify( + state, + ( + latest, + ): readonly [ + { + readonly isCurrentRun: boolean; + readonly nextState: BackendManagerState; + readonly pid: Option.Option; + }, + BackendManagerState, + ] => { + const currentRun = Option.getOrUndefined(latest.active); + if (currentRun?.id !== runId) { + return [ + { + isCurrentRun: false, + nextState: latest, + pid: Option.none(), + }, + latest, + ] as const; + } + + const next = { + ...latest, + active: Option.none(), + ready: false, + }; + return [ + { + isCurrentRun: true, + nextState: next, + pid: currentRun.pid, + }, + next, + ] as const; + }, + ); + + if (isCurrentRun) { + if (Option.isSome(pid)) { + yield* backendOutputLog.writeSessionBoundary({ + phase: "END", + details: `pid=${pid.value} ${reason}`, + }); + } + yield* Ref.set(desktopState.backendReady, false); + } + + if (isCurrentRun && nextState.desiredRunning) { + yield* scheduleRestart(reason); + } + }), + ); + }); + + const program = runBackendProcess({ + ...config, + onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) { + yield* updateActiveRun(runId, (run) => ({ + ...run, + pid: Option.some(pid), + })); + yield* backendOutputLog.writeSessionBoundary({ + phase: "START", + details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`, + }); + }), + onReady: Effect.fn("desktop.backendManager.onReady")(function* () { + const isCurrentRun = yield* Ref.modify(state, (latest) => { + const activeRun = Option.getOrUndefined(latest.active); + if (activeRun?.id !== runId) { + return [false, latest] as const; + } + + return [ + true, + { + ...latest, + restartAttempt: 0, + ready: true, + }, + ] as const; + }); + if (!isCurrentRun) { + return; + } + + yield* Ref.set(desktopState.backendReady, true); + yield* desktopWindow.handleBackendReady.pipe( + Effect.catch((error) => + logBackendManagerError("failed to open main window after backend readiness", { + message: error.message, + }), + ), + ); + }), + onReadinessFailure: (error) => + logBackendManagerWarning("backend readiness check failed during bootstrap", { + error: error.message, + }), + onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HttpClient.HttpClient, httpClient), + Scope.provide(runScope), + Effect.matchEffect({ + onFailure: (error) => finalizeRun(error.message), + onSuccess: (exit) => finalizeRun(exit.reason), + }), + Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), + ); + + const fiber = yield* Effect.forkIn(program, parentScope); + yield* updateActiveRun(runId, (run) => ({ + ...run, + fiber: Option.some(fiber), + })); + }), + ), + ).pipe(Effect.withSpan("desktop.backendManager.start")); + + const scheduleRestart = Effect.fn("desktop.backendManager.scheduleRestart")(function* ( + reason: string, + ) { + const scheduled = yield* Ref.modify(state, (latest) => { + if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) { + return [Option.none(), latest] as const; + } + + const delay = calculateRestartDelay(latest.restartAttempt); + return [ + Option.some(delay), + { + ...latest, + restartAttempt: latest.restartAttempt + 1, + }, + ] as const; + }); + + yield* Option.match(scheduled, { + onNone: () => Effect.void, + onSome: Effect.fn("desktop.backendManager.scheduleRestartFiber")(function* (delay) { + yield* logBackendManagerError("backend exited unexpectedly; restart scheduled", { + reason, + delayMs: Duration.toMillis(delay), + }); + const restartFiber = yield* Effect.forkIn( + Effect.sleep(delay).pipe( + Effect.andThen( + Ref.modify(state, (latest) => { + const shouldRestart = latest.desiredRunning; + return [ + shouldRestart, + { + ...latest, + restartFiber: Option.none(), + }, + ] as const; + }), + ), + Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), + Effect.catchCause((cause) => + logBackendManagerError("desktop backend restart fiber failed", { + cause: Cause.pretty(cause), + }), + ), + ), + parentScope, + ); + yield* Ref.update(state, (latest) => + Option.isNone(latest.restartFiber) + ? { + ...latest, + restartFiber: Option.some(restartFiber), + } + : latest, + ); + }), + }); + }); + + const stop = Effect.fn("desktop.backendManager.stop")(function* (options?: { + readonly timeout?: Duration.Duration; + }) { + const { active, restartFiber } = yield* mutex.withPermits(1)( + Effect.gen(function* () { + const result = yield* Ref.modify(state, (latest) => [ + { + active: latest.active, + restartFiber: latest.restartFiber, + }, + { + ...latest, + desiredRunning: false, + ready: false, + active: Option.none(), + restartFiber: Option.none>(), + }, + ]); + yield* Ref.set(desktopState.backendReady, false); + return result; + }), + ); + + yield* Option.match(restartFiber, { + onNone: () => Effect.void, + onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), + }); + yield* Option.match(active, { + onNone: () => Effect.void, + onSome: (run) => closeRun(run, options), + }); + }); + + yield* Effect.addFinalizer(() => stop()); + + return DesktopBackendManager.of({ + start, + stop, + currentConfig, + snapshot, + }); +}); + +export const layer = Layer.effect(DesktopBackendManager, makeDesktopBackendManager()); diff --git a/apps/desktop/src/backendPort.test.ts b/apps/desktop/src/backendPort.test.ts deleted file mode 100644 index 774e31b8066..00000000000 --- a/apps/desktop/src/backendPort.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { resolveDesktopBackendPort } from "./backendPort.ts"; - -describe("resolveDesktopBackendPort", () => { - it("returns the starting port when it is available", async () => { - const canListenOnHost = vi.fn(async (port: number) => port === 3773); - - await expect( - resolveDesktopBackendPort({ - host: "127.0.0.1", - startPort: 3773, - canListenOnHost, - }), - ).resolves.toBe(3773); - - expect(canListenOnHost).toHaveBeenCalledTimes(1); - expect(canListenOnHost).toHaveBeenCalledWith(3773, "127.0.0.1"); - }); - - it("increments sequentially until it finds an available port", async () => { - const canListenOnHost = vi.fn(async (port: number) => port === 3775); - - await expect( - resolveDesktopBackendPort({ - host: "127.0.0.1", - startPort: 3773, - canListenOnHost, - }), - ).resolves.toBe(3775); - - expect(canListenOnHost.mock.calls).toEqual([ - [3773, "127.0.0.1"], - [3774, "127.0.0.1"], - [3775, "127.0.0.1"], - ]); - }); - - it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => { - const canListenOnHost = vi.fn(async (port: number, host: string) => { - if (port === 3773 && host === "127.0.0.1") return true; - if (port === 3773 && host === "0.0.0.0") return false; - return port === 3774; - }); - - await expect( - resolveDesktopBackendPort({ - host: "127.0.0.1", - requiredHosts: ["0.0.0.0"], - startPort: 3773, - canListenOnHost, - }), - ).resolves.toBe(3774); - - expect(canListenOnHost.mock.calls).toEqual([ - [3773, "127.0.0.1"], - [3773, "0.0.0.0"], - [3774, "127.0.0.1"], - [3774, "0.0.0.0"], - ]); - }); - - it("checks overlapping hosts sequentially to avoid self-interference", async () => { - let inFlightCount = 0; - const canListenOnHost = vi.fn(async (_port: number, _host: string) => { - inFlightCount += 1; - const overlapped = inFlightCount > 1; - await Promise.resolve(); - inFlightCount -= 1; - return !overlapped; - }); - - await expect( - resolveDesktopBackendPort({ - host: "127.0.0.1", - requiredHosts: ["0.0.0.0", "::"], - startPort: 3773, - maxPort: 3773, - canListenOnHost, - }), - ).resolves.toBe(3773); - - expect(canListenOnHost.mock.calls).toEqual([ - [3773, "127.0.0.1"], - [3773, "0.0.0.0"], - [3773, "::"], - ]); - }); - - it("fails when the scan range is exhausted", async () => { - const canListenOnHost = vi.fn(async () => false); - - await expect( - resolveDesktopBackendPort({ - host: "127.0.0.1", - startPort: 65534, - maxPort: 65535, - canListenOnHost, - }), - ).rejects.toThrow( - "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535", - ); - - expect(canListenOnHost.mock.calls).toEqual([ - [65534, "127.0.0.1"], - [65535, "127.0.0.1"], - ]); - }); -}); diff --git a/apps/desktop/src/backendPort.ts b/apps/desktop/src/backendPort.ts deleted file mode 100644 index 1ce90a257fa..00000000000 --- a/apps/desktop/src/backendPort.ts +++ /dev/null @@ -1,83 +0,0 @@ -import * as Effect from "effect/Effect"; -import { NetService } from "@t3tools/shared/Net"; - -export const DEFAULT_DESKTOP_BACKEND_PORT = 3773; -const MAX_TCP_PORT = 65_535; - -export interface ResolveDesktopBackendPortOptions { - readonly host: string; - readonly startPort?: number; - readonly maxPort?: number; - readonly requiredHosts?: ReadonlyArray; - readonly canListenOnHost?: (port: number, host: string) => Promise; -} - -const defaultCanListenOnHost = async (port: number, host: string): Promise => - Effect.service(NetService).pipe( - Effect.flatMap((net) => net.canListenOnHost(port, host)), - Effect.provide(NetService.layer), - Effect.runPromise, - ); - -const isValidPort = (port: number): boolean => - Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT; - -const normalizeHosts = ( - host: string, - requiredHosts: ReadonlyArray, -): ReadonlyArray => - Array.from( - new Set( - [host, ...requiredHosts] - .map((candidate) => candidate.trim()) - .filter((candidate) => candidate.length > 0), - ), - ); - -async function canListenOnAllHosts( - port: number, - hosts: ReadonlyArray, - canListenOnHost: (port: number, host: string) => Promise, -): Promise { - for (const candidateHost of hosts) { - if (!(await canListenOnHost(port, candidateHost))) { - return false; - } - } - - return true; -} - -export async function resolveDesktopBackendPort({ - host, - startPort = DEFAULT_DESKTOP_BACKEND_PORT, - maxPort = MAX_TCP_PORT, - requiredHosts = [], - canListenOnHost = defaultCanListenOnHost, -}: ResolveDesktopBackendPortOptions): Promise { - if (!isValidPort(startPort)) { - throw new Error(`Invalid desktop backend start port: ${startPort}`); - } - - if (!isValidPort(maxPort)) { - throw new Error(`Invalid desktop backend max port: ${maxPort}`); - } - - if (maxPort < startPort) { - throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`); - } - - const hostsToCheck = normalizeHosts(host, requiredHosts); - - // Keep desktop startup predictable across app restarts by probing upward from - // the same preferred port instead of picking a fresh ephemeral port. - for (let port = startPort; port <= maxPort; port += 1) { - if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) { - return port; - } - } - - throw new Error( - `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`, - ); -} diff --git a/apps/desktop/src/backendReadiness.test.ts b/apps/desktop/src/backendReadiness.test.ts deleted file mode 100644 index 0d49842acba..00000000000 --- a/apps/desktop/src/backendReadiness.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { - BackendReadinessAbortedError, - isBackendReadinessAborted, - waitForHttpReady, -} from "./backendReadiness.ts"; - -describe("waitForHttpReady", () => { - it("returns once the backend serves the requested readiness path", async () => { - const fetchImpl = vi - .fn() - .mockResolvedValueOnce(new Response(null, { status: 503 })) - .mockResolvedValueOnce(new Response(null, { status: 200 })); - - await waitForHttpReady("http://127.0.0.1:3773", { - fetchImpl, - timeoutMs: 1_000, - intervalMs: 0, - }); - - expect(fetchImpl).toHaveBeenCalledTimes(2); - expect(fetchImpl).toHaveBeenNthCalledWith( - 1, - "http://127.0.0.1:3773/", - expect.objectContaining({ redirect: "manual" }), - ); - }); - - it("retries after a readiness request stalls past the per-request timeout", async () => { - const fetchImpl = vi - .fn() - .mockImplementationOnce( - (_input, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener( - "abort", - () => { - reject(new Error("request timed out")); - }, - { once: true }, - ); - }) as ReturnType, - ) - .mockResolvedValueOnce(new Response(null, { status: 200 })); - - await waitForHttpReady("http://127.0.0.1:3773", { - fetchImpl, - timeoutMs: 100, - intervalMs: 0, - requestTimeoutMs: 1, - }); - - expect(fetchImpl).toHaveBeenCalledTimes(2); - }); - - it("aborts an in-flight readiness wait", async () => { - const controller = new AbortController(); - const fetchImpl = vi.fn().mockImplementation( - () => - new Promise((_resolve, reject) => { - controller.signal.addEventListener( - "abort", - () => { - reject(new BackendReadinessAbortedError()); - }, - { once: true }, - ); - }) as ReturnType, - ); - - const waitPromise = waitForHttpReady("http://127.0.0.1:3773", { - fetchImpl, - timeoutMs: 1_000, - intervalMs: 0, - signal: controller.signal, - }); - - controller.abort(); - - await expect(waitPromise).rejects.toBeInstanceOf(BackendReadinessAbortedError); - }); - - it("recognizes aborted readiness errors", () => { - expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true); - expect(isBackendReadinessAborted(new Error("nope"))).toBe(false); - }); - - it("supports custom readiness predicates", async () => { - const fetchImpl = vi - .fn() - .mockResolvedValueOnce(new Response(null, { status: 200 })) - .mockResolvedValueOnce(new Response(null, { status: 204 })); - - await waitForHttpReady("http://127.0.0.1:3773", { - fetchImpl, - timeoutMs: 1_000, - intervalMs: 0, - path: "/api/healthz", - isReady: (response) => response.status === 204, - }); - - expect(fetchImpl).toHaveBeenNthCalledWith( - 1, - "http://127.0.0.1:3773/api/healthz", - expect.objectContaining({ redirect: "manual" }), - ); - expect(fetchImpl).toHaveBeenNthCalledWith( - 2, - "http://127.0.0.1:3773/api/healthz", - expect.objectContaining({ redirect: "manual" }), - ); - }); -}); diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts deleted file mode 100644 index 71c28929ebe..00000000000 --- a/apps/desktop/src/backendReadiness.ts +++ /dev/null @@ -1,107 +0,0 @@ -export interface WaitForHttpReadyOptions { - readonly timeoutMs?: number; - readonly intervalMs?: number; - readonly requestTimeoutMs?: number; - readonly fetchImpl?: typeof fetch; - readonly signal?: AbortSignal; - readonly path?: string; - readonly isReady?: (response: Response) => boolean; -} - -const DEFAULT_TIMEOUT_MS = 30_000; -const DEFAULT_INTERVAL_MS = 100; -const DEFAULT_REQUEST_TIMEOUT_MS = 1_000; - -export class BackendReadinessAbortedError extends Error { - constructor() { - super("Backend readiness wait was aborted."); - this.name = "BackendReadinessAbortedError"; - } -} - -function delay(ms: number, signal: AbortSignal | undefined): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - resolve(); - }, ms); - - const onAbort = () => { - cleanup(); - reject(new BackendReadinessAbortedError()); - }; - - const cleanup = () => { - clearTimeout(timer); - signal?.removeEventListener("abort", onAbort); - }; - - if (signal?.aborted) { - cleanup(); - reject(new BackendReadinessAbortedError()); - return; - } - - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} - -export function isBackendReadinessAborted(error: unknown): error is BackendReadinessAbortedError { - return error instanceof BackendReadinessAbortedError; -} - -export async function waitForHttpReady( - baseUrl: string, - options?: WaitForHttpReadyOptions, -): Promise { - const fetchImpl = options?.fetchImpl ?? fetch; - const signal = options?.signal; - const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS; - const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; - const readinessPath = options?.path ?? "/"; - const isReady = options?.isReady ?? ((response: Response) => response.ok); - const deadline = Date.now() + timeoutMs; - - for (;;) { - if (signal?.aborted) { - throw new BackendReadinessAbortedError(); - } - - const requestController = new AbortController(); - const requestTimeout = setTimeout(() => { - requestController.abort(); - }, requestTimeoutMs); - const abortRequest = () => { - requestController.abort(); - }; - signal?.addEventListener("abort", abortRequest, { once: true }); - - try { - const response = await fetchImpl(new URL(readinessPath, baseUrl).toString(), { - redirect: "manual", - signal: requestController.signal, - }); - if (isReady(response)) { - return; - } - } catch (error) { - if (isBackendReadinessAborted(error)) { - throw error; - } - if (signal?.aborted) { - throw new BackendReadinessAbortedError(); - } - // Retry until the backend becomes reachable or the deadline expires. - } finally { - clearTimeout(requestTimeout); - signal?.removeEventListener("abort", abortRequest); - } - - if (Date.now() >= deadline) { - throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`); - } - - await delay(intervalMs, signal); - } -} diff --git a/apps/desktop/src/backendStartupReadiness.test.ts b/apps/desktop/src/backendStartupReadiness.test.ts deleted file mode 100644 index 6d1df3d3ecd..00000000000 --- a/apps/desktop/src/backendStartupReadiness.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { BackendReadinessAbortedError } from "./backendReadiness.ts"; -import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; - -describe("waitForBackendStartupReady", () => { - it("falls back to the HTTP probe when no listening signal exists", async () => { - const waitForHttpReady = vi.fn<() => Promise>().mockResolvedValue(undefined); - const cancelHttpWait = vi.fn(); - - await expect( - waitForBackendStartupReady({ - waitForHttpReady, - cancelHttpWait, - }), - ).resolves.toBe("http"); - - expect(waitForHttpReady).toHaveBeenCalledTimes(1); - expect(cancelHttpWait).not.toHaveBeenCalled(); - }); - - it("uses the listening signal and cancels the HTTP probe", async () => { - let rejectHttpWait: ((error: unknown) => void) | null = null; - const waitForHttpReady = vi.fn( - () => - new Promise((_resolve, reject) => { - rejectHttpWait = reject; - }), - ); - const cancelHttpWait = vi.fn(() => { - rejectHttpWait?.(new BackendReadinessAbortedError()); - }); - - await expect( - waitForBackendStartupReady({ - listeningPromise: Promise.resolve(), - waitForHttpReady, - cancelHttpWait, - }), - ).resolves.toBe("listening"); - - expect(waitForHttpReady).toHaveBeenCalledTimes(1); - expect(cancelHttpWait).toHaveBeenCalledTimes(1); - }); - - it("rejects when the listening signal fails before HTTP readiness", async () => { - const error = new Error("backend exited"); - const waitForHttpReady = vi.fn(() => new Promise(() => {})); - - await expect( - waitForBackendStartupReady({ - listeningPromise: Promise.reject(error), - waitForHttpReady, - cancelHttpWait: vi.fn(), - }), - ).rejects.toBe(error); - }); -}); diff --git a/apps/desktop/src/backendStartupReadiness.ts b/apps/desktop/src/backendStartupReadiness.ts deleted file mode 100644 index 37a977431d0..00000000000 --- a/apps/desktop/src/backendStartupReadiness.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { isBackendReadinessAborted } from "./backendReadiness.ts"; - -export interface WaitForBackendStartupReadyOptions { - readonly listeningPromise?: Promise | null; - readonly waitForHttpReady: () => Promise; - readonly cancelHttpWait: () => void; -} - -export async function waitForBackendStartupReady( - options: WaitForBackendStartupReadyOptions, -): Promise<"listening" | "http"> { - const httpReadyPromise = options.waitForHttpReady(); - const listeningPromise = options.listeningPromise; - - if (!listeningPromise) { - await httpReadyPromise; - return "http"; - } - - return await new Promise<"listening" | "http">((resolve, reject) => { - let settled = false; - - const settleResolve = (source: "listening" | "http") => { - if (settled) { - return; - } - settled = true; - if (source === "listening") { - options.cancelHttpWait(); - } - resolve(source); - }; - - const settleReject = (error: unknown) => { - if (settled) { - return; - } - settled = true; - reject(error); - }; - - listeningPromise.then( - () => settleResolve("listening"), - (error) => settleReject(error), - ); - httpReadyPromise.then( - () => settleResolve("http"), - (error) => { - if (settled && isBackendReadinessAborted(error)) { - return; - } - settleReject(error); - }, - ); - }); -} diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts deleted file mode 100644 index 0b959d709fd..00000000000 --- a/apps/desktop/src/clientPersistence.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; - -import { - EnvironmentId, - type ClientSettings, - type PersistedSavedEnvironmentRecord, -} from "@t3tools/contracts"; -import { afterEach, describe, expect, it } from "vitest"; - -import { - readClientSettings, - readSavedEnvironmentRegistry, - readSavedEnvironmentSecret, - removeSavedEnvironmentSecret, - writeClientSettings, - writeSavedEnvironmentRegistry, - writeSavedEnvironmentSecret, - type DesktopSecretStorage, -} from "./clientPersistence.ts"; - -const tempDirectories: string[] = []; - -afterEach(() => { - for (const directory of tempDirectories.splice(0)) { - fs.rmSync(directory, { recursive: true, force: true }); - } -}); - -function makeTempPath(fileName: string): string { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-client-persistence-test-")); - tempDirectories.push(directory); - return path.join(directory, fileName); -} - -function makeSecretStorage(available: boolean): DesktopSecretStorage { - return { - isEncryptionAvailable: () => available, - encryptString: (value) => Buffer.from(`enc:${value}`, "utf8"), - decryptString: (value) => { - const decoded = value.toString("utf8"); - if (!decoded.startsWith("enc:")) { - throw new Error("invalid secret"); - } - return decoded.slice("enc:".length); - }, - }; -} - -const clientSettings: ClientSettings = { - autoOpenPlanSidebar: false, - confirmThreadArchive: true, - confirmThreadDelete: false, - dismissedProviderUpdateNotificationKeys: [], - diffIgnoreWhitespace: true, - diffWordWrap: true, - favorites: [], - providerModelPreferences: {}, - sidebarProjectGroupingMode: "repository_path", - sidebarProjectGroupingOverrides: { - "environment-1:/tmp/project-a": "separate", - }, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - sidebarThreadPreviewCount: 6, - timestampFormat: "24-hour", -}; - -const savedRegistryRecord: PersistedSavedEnvironmentRecord = { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: "2026-04-09T01:00:00.000Z", - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, -}; - -describe("clientPersistence", () => { - it("persists and reloads client settings", () => { - const settingsPath = makeTempPath("client-settings.json"); - - writeClientSettings(settingsPath, clientSettings); - - expect(readClientSettings(settingsPath)).toEqual(clientSettings); - }); - - it("persists and reloads saved environment metadata", () => { - const registryPath = makeTempPath("saved-environments.json"); - - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); - - expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); - }); - - it("persists encrypted saved environment secrets when encryption is available", () => { - const registryPath = makeTempPath("saved-environments.json"); - const secretStorage = makeSecretStorage(true); - - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); - - expect( - writeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage, - }), - ).toBe(true); - - expect( - readSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secretStorage, - }), - ).toBe("bearer-token"); - - expect(JSON.parse(fs.readFileSync(registryPath, "utf8"))).toEqual({ - records: [ - { - ...savedRegistryRecord, - encryptedBearerToken: Buffer.from("enc:bearer-token", "utf8").toString("base64"), - }, - ], - }); - }); - - it("preserves existing secrets when encryption is unavailable", () => { - const registryPath = makeTempPath("saved-environments.json"); - const availableSecretStorage = makeSecretStorage(true); - - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); - - writeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage: availableSecretStorage, - }); - - expect( - writeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "next-token", - secretStorage: makeSecretStorage(false), - }), - ).toBe(false); - - expect( - readSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secretStorage: availableSecretStorage, - }), - ).toBe("bearer-token"); - }); - - it("removes saved environment secrets", () => { - const registryPath = makeTempPath("saved-environments.json"); - const secretStorage = makeSecretStorage(true); - - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); - - writeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage, - }); - - removeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - }); - - expect( - readSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secretStorage, - }), - ).toBeNull(); - }); - - it("treats malformed secrets documents as empty", () => { - const registryPath = makeTempPath("saved-environments.json"); - fs.writeFileSync(registryPath, "{}\n", "utf8"); - - expect( - readSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secretStorage: makeSecretStorage(true), - }), - ).toBeNull(); - - expect(() => - removeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - }), - ).not.toThrow(); - }); - - it("returns false when writing a secret without metadata", () => { - const registryPath = makeTempPath("saved-environments.json"); - - expect( - writeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage: makeSecretStorage(true), - }), - ).toBe(false); - }); - - it("preserves encrypted secrets when metadata is rewritten", () => { - const registryPath = makeTempPath("saved-environments.json"); - const secretStorage = makeSecretStorage(true); - - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); - - writeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage, - }); - - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); - - expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); - expect( - readSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secretStorage, - }), - ).toBe("bearer-token"); - }); -}); diff --git a/apps/desktop/src/clientPersistence.ts b/apps/desktop/src/clientPersistence.ts deleted file mode 100644 index 09a3494dbc7..00000000000 --- a/apps/desktop/src/clientPersistence.ts +++ /dev/null @@ -1,238 +0,0 @@ -import * as FS from "node:fs"; -import * as Path from "node:path"; - -import { - ClientSettingsSchema, - type ClientSettings, - type PersistedSavedEnvironmentRecord, -} from "@t3tools/contracts"; -import { Predicate } from "effect"; -import * as Schema from "effect/Schema"; - -interface ClientSettingsDocument { - readonly settings: ClientSettings; -} - -interface PersistedSavedEnvironmentStorageRecord extends PersistedSavedEnvironmentRecord { - readonly encryptedBearerToken?: string; -} - -interface SavedEnvironmentRegistryDocument { - readonly records: readonly PersistedSavedEnvironmentStorageRecord[]; -} - -export interface DesktopSecretStorage { - readonly isEncryptionAvailable: () => boolean; - readonly encryptString: (value: string) => Buffer; - readonly decryptString: (value: Buffer) => string; -} - -function readJsonFile(filePath: string): T | null { - try { - if (!FS.existsSync(filePath)) { - return null; - } - return JSON.parse(FS.readFileSync(filePath, "utf8")) as T; - } catch { - return null; - } -} - -function writeJsonFile(filePath: string, value: unknown): void { - const directory = Path.dirname(filePath); - const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - FS.mkdirSync(directory, { recursive: true }); - FS.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); - FS.renameSync(tempPath, filePath); -} - -function isPersistedSavedEnvironmentStorageRecord( - value: unknown, -): value is PersistedSavedEnvironmentStorageRecord { - return ( - Predicate.isObject(value) && - typeof value.environmentId === "string" && - typeof value.label === "string" && - typeof value.httpBaseUrl === "string" && - typeof value.wsBaseUrl === "string" && - typeof value.createdAt === "string" && - (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") && - (value.desktopSsh === undefined || - (Predicate.isObject(value.desktopSsh) && - typeof value.desktopSsh.alias === "string" && - typeof value.desktopSsh.hostname === "string" && - (value.desktopSsh.username === null || typeof value.desktopSsh.username === "string") && - (value.desktopSsh.port === null || typeof value.desktopSsh.port === "number"))) && - (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string") - ); -} - -function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmentRegistryDocument { - const parsed = readJsonFile(filePath); - if (!Predicate.isObject(parsed)) { - return { records: [] }; - } - - return { - records: Array.isArray(parsed.records) - ? parsed.records.filter(isPersistedSavedEnvironmentStorageRecord) - : [], - }; -} - -function toPersistedSavedEnvironmentRecord( - record: PersistedSavedEnvironmentStorageRecord, -): PersistedSavedEnvironmentRecord { - const nextRecord = { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - }; - return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; -} - -export function readClientSettings(settingsPath: string): ClientSettings | null { - const raw = readJsonFile(settingsPath)?.settings; - if (!raw) { - return null; - } - try { - return Schema.decodeUnknownSync(ClientSettingsSchema)(raw); - } catch { - return null; - } -} - -export function writeClientSettings(settingsPath: string, settings: ClientSettings): void { - writeJsonFile(settingsPath, { settings } satisfies ClientSettingsDocument); -} - -export function readSavedEnvironmentRegistry( - registryPath: string, -): readonly PersistedSavedEnvironmentRecord[] { - return readSavedEnvironmentRegistryDocument(registryPath).records.map((record) => - toPersistedSavedEnvironmentRecord(record), - ); -} - -export function writeSavedEnvironmentRegistry( - registryPath: string, - records: readonly PersistedSavedEnvironmentRecord[], -): void { - const currentDocument = readSavedEnvironmentRegistryDocument(registryPath); - const encryptedBearerTokenById = new Map( - currentDocument.records.flatMap((record) => - record.encryptedBearerToken - ? [[record.environmentId, record.encryptedBearerToken] as const] - : [], - ), - ); - writeJsonFile(registryPath, { - records: records.map((record) => { - const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId); - return encryptedBearerToken - ? { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), - encryptedBearerToken, - } - : record; - }), - } satisfies SavedEnvironmentRegistryDocument); -} - -export function readSavedEnvironmentSecret(input: { - readonly registryPath: string; - readonly environmentId: string; - readonly secretStorage: DesktopSecretStorage; -}): string | null { - const document = readSavedEnvironmentRegistryDocument(input.registryPath); - const encoded = document.records.find( - (record) => record.environmentId === input.environmentId, - )?.encryptedBearerToken; - if (!encoded) { - return null; - } - - if (!input.secretStorage.isEncryptionAvailable()) { - return null; - } - - try { - return input.secretStorage.decryptString(Buffer.from(encoded, "base64")); - } catch { - return null; - } -} - -export function writeSavedEnvironmentSecret(input: { - readonly registryPath: string; - readonly environmentId: string; - readonly secret: string; - readonly secretStorage: DesktopSecretStorage; -}): boolean { - const document = readSavedEnvironmentRegistryDocument(input.registryPath); - - if (!input.secretStorage.isEncryptionAvailable()) { - return false; - } - - let found = false; - - writeJsonFile(input.registryPath, { - records: document.records.map((record) => { - if (record.environmentId !== input.environmentId) { - return record; - } - - found = true; - const encryptedBearerToken = input.secretStorage - .encryptString(input.secret) - .toString("base64"); - const nextRecord = { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - encryptedBearerToken, - }; - return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; - }), - } satisfies SavedEnvironmentRegistryDocument); - return found; -} - -export function removeSavedEnvironmentSecret(input: { - readonly registryPath: string; - readonly environmentId: string; -}): void { - const document = readSavedEnvironmentRegistryDocument(input.registryPath); - if ( - !document.records.some( - (record) => - record.environmentId === input.environmentId && record.encryptedBearerToken !== undefined, - ) - ) { - return; - } - - writeJsonFile(input.registryPath, { - records: document.records.map((record) => { - if (record.environmentId !== input.environmentId) { - return record; - } - - return toPersistedSavedEnvironmentRecord(record); - }), - } satisfies SavedEnvironmentRegistryDocument); -} diff --git a/apps/desktop/src/confirmDialog.test.ts b/apps/desktop/src/confirmDialog.test.ts deleted file mode 100644 index de1d23eb178..00000000000 --- a/apps/desktop/src/confirmDialog.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { BrowserWindow } from "electron"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { showMessageBoxMock } = vi.hoisted(() => ({ - showMessageBoxMock: vi.fn(), -})); - -vi.mock("electron", () => ({ - dialog: { - showMessageBox: showMessageBoxMock, - }, -})); - -import { showDesktopConfirmDialog } from "./confirmDialog.ts"; - -describe("showDesktopConfirmDialog", () => { - beforeEach(() => { - showMessageBoxMock.mockReset(); - }); - - it("returns false and does not open a dialog for empty messages", async () => { - const result = await showDesktopConfirmDialog(" ", null); - - expect(result).toBe(false); - expect(showMessageBoxMock).not.toHaveBeenCalled(); - }); - - it("opens a dialog for the focused window and returns true on confirm", async () => { - const ownerWindow = { id: 1 } as BrowserWindow; - showMessageBoxMock.mockResolvedValue({ response: 1 }); - - const result = await showDesktopConfirmDialog("Delete worktree?", ownerWindow); - - expect(result).toBe(true); - expect(showMessageBoxMock).toHaveBeenCalledWith( - ownerWindow, - expect.objectContaining({ - buttons: ["No", "Yes"], - message: "Delete worktree?", - }), - ); - }); - - it("opens an app-level dialog when there is no focused window", async () => { - showMessageBoxMock.mockResolvedValue({ response: 0 }); - - const result = await showDesktopConfirmDialog("Delete worktree?", null); - - expect(result).toBe(false); - expect(showMessageBoxMock).toHaveBeenCalledWith( - expect.objectContaining({ - buttons: ["No", "Yes"], - message: "Delete worktree?", - }), - ); - }); -}); diff --git a/apps/desktop/src/confirmDialog.ts b/apps/desktop/src/confirmDialog.ts deleted file mode 100644 index c941d090652..00000000000 --- a/apps/desktop/src/confirmDialog.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type BrowserWindow, dialog } from "electron"; - -const CONFIRM_BUTTON_INDEX = 1; - -export async function showDesktopConfirmDialog( - message: string, - ownerWindow: BrowserWindow | null, -): Promise { - const normalizedMessage = message.trim(); - if (normalizedMessage.length === 0) { - return false; - } - - const options = { - type: "question" as const, - buttons: ["No", "Yes"], - defaultId: 0, - cancelId: 0, - noLink: true, - message: normalizedMessage, - }; - const result = ownerWindow - ? await dialog.showMessageBox(ownerWindow, options) - : await dialog.showMessageBox(options); - return result.response === CONFIRM_BUTTON_INDEX; -} diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts deleted file mode 100644 index cc58dc810ef..00000000000 --- a/apps/desktop/src/desktopSettings.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { - DEFAULT_DESKTOP_SETTINGS, - readDesktopSettings, - resolveDefaultDesktopSettings, - setDesktopServerExposurePreference, - setDesktopTailscaleServePreference, - setDesktopUpdateChannelPreference, - writeDesktopSettings, -} from "./desktopSettings.ts"; - -const tempDirectories: string[] = []; - -afterEach(() => { - for (const directory of tempDirectories.splice(0)) { - fs.rmSync(directory, { recursive: true, force: true }); - } -}); - -function makeSettingsPath() { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-desktop-settings-test-")); - tempDirectories.push(directory); - return path.join(directory, "desktop-settings.json"); -} - -describe("desktopSettings", () => { - it("returns defaults when no settings file exists", () => { - expect(readDesktopSettings(makeSettingsPath(), "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS); - }); - - it("defaults packaged nightly builds to the nightly update channel", () => { - expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({ - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, - }); - }); - - it("persists and reloads the configured server exposure mode", () => { - const settingsPath = makeSettingsPath(); - - writeDesktopSettings(settingsPath, { - serverExposureMode: "network-accessible", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, - }); - - expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ - serverExposureMode: "network-accessible", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, - }); - }); - - it("preserves the requested network-accessible preference across temporary fallback", () => { - expect( - setDesktopServerExposurePreference( - { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }, - "network-accessible", - ), - ).toEqual({ - serverExposureMode: "network-accessible", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }); - }); - - it("persists the requested Tailscale Serve preference", () => { - expect( - setDesktopTailscaleServePreference( - { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }, - { enabled: true, port: 8443 }, - ), - ).toEqual({ - serverExposureMode: "local-only", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }); - }); - - it("preserves the configured Tailscale Serve port when no new port is requested", () => { - expect( - setDesktopTailscaleServePreference( - { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }, - { enabled: true }, - ), - ).toEqual({ - serverExposureMode: "local-only", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }); - }); - - it("persists the requested nightly update channel", () => { - expect( - setDesktopUpdateChannelPreference( - { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }, - "nightly", - ), - ).toEqual({ - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: true, - }); - }); - - it("falls back to defaults when the settings file is malformed", () => { - const settingsPath = makeSettingsPath(); - fs.writeFileSync(settingsPath, "{not-json", "utf8"); - - expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS); - }); - - it("falls back to the nightly channel for legacy nightly settings without an update track", () => { - const settingsPath = makeSettingsPath(); - fs.writeFileSync(settingsPath, JSON.stringify({ serverExposureMode: "local-only" }), "utf8"); - - expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, - }); - }); - - it("migrates legacy implicit stable settings to nightly when running a nightly build", () => { - const settingsPath = makeSettingsPath(); - fs.writeFileSync( - settingsPath, - JSON.stringify({ - serverExposureMode: "local-only", - updateChannel: "latest", - }), - "utf8", - ); - - expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, - }); - }); - - it("preserves an explicit stable choice on nightly builds", () => { - const settingsPath = makeSettingsPath(); - fs.writeFileSync( - settingsPath, - JSON.stringify({ - serverExposureMode: "local-only", - updateChannel: "latest", - updateChannelConfiguredByUser: true, - }), - "utf8", - ); - - expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, - }); - }); - - it("falls back to the default Tailscale Serve port when the persisted port is invalid", () => { - const settingsPath = makeSettingsPath(); - fs.writeFileSync( - settingsPath, - JSON.stringify({ - tailscaleServeEnabled: true, - tailscaleServePort: 0, - }), - "utf8", - ); - - expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ - serverExposureMode: "local-only", - tailscaleServeEnabled: true, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }); - }); -}); diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts deleted file mode 100644 index 5a61faef803..00000000000 --- a/apps/desktop/src/desktopSettings.ts +++ /dev/null @@ -1,125 +0,0 @@ -import * as FS from "node:fs"; -import * as Path from "node:path"; -import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts"; - -import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; - -export interface DesktopSettings { - readonly serverExposureMode: DesktopServerExposureMode; - readonly tailscaleServeEnabled: boolean; - readonly tailscaleServePort: number; - readonly updateChannel: DesktopUpdateChannel; - readonly updateChannelConfiguredByUser: 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, -}; - -export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { - return { - ...DEFAULT_DESKTOP_SETTINGS, - updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), - }; -} - -export function setDesktopServerExposurePreference( - settings: DesktopSettings, - requestedMode: DesktopServerExposureMode, -): DesktopSettings { - return settings.serverExposureMode === requestedMode - ? settings - : { - ...settings, - serverExposureMode: requestedMode, - }; -} - -export function setDesktopTailscaleServePreference( - settings: DesktopSettings, - input: { readonly enabled: boolean; readonly port?: number }, -): DesktopSettings { - const port = - input.port === undefined - ? settings.tailscaleServePort - : normalizeTailscaleServePort(input.port); - return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port - ? settings - : { - ...settings, - tailscaleServeEnabled: input.enabled, - tailscaleServePort: port, - }; -} - -export function normalizeTailscaleServePort(value: unknown): number { - return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535 - ? value - : DEFAULT_TAILSCALE_SERVE_PORT; -} - -export function setDesktopUpdateChannelPreference( - settings: DesktopSettings, - requestedChannel: DesktopUpdateChannel, -): DesktopSettings { - return { - ...settings, - updateChannel: requestedChannel, - updateChannelConfiguredByUser: true, - }; -} - -export function readDesktopSettings(settingsPath: string, appVersion: string): DesktopSettings { - const defaultSettings = resolveDefaultDesktopSettings(appVersion); - - try { - if (!FS.existsSync(settingsPath)) { - return defaultSettings; - } - - const raw = FS.readFileSync(settingsPath, "utf8"); - const parsed = JSON.parse(raw) as { - readonly serverExposureMode?: unknown; - readonly tailscaleServeEnabled?: unknown; - readonly tailscaleServePort?: unknown; - readonly updateChannel?: unknown; - readonly updateChannelConfiguredByUser?: unknown; - }; - const parsedUpdateChannel = - parsed.updateChannel === "nightly" || parsed.updateChannel === "latest" - ? parsed.updateChannel - : null; - const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; - const updateChannelConfiguredByUser = - parsed.updateChannelConfiguredByUser === true || - (isLegacySettings && parsedUpdateChannel === "nightly"); - - return { - serverExposureMode: - parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", - tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, - tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), - updateChannel: - updateChannelConfiguredByUser && parsedUpdateChannel !== null - ? parsedUpdateChannel - : defaultSettings.updateChannel, - updateChannelConfiguredByUser, - }; - } catch { - return defaultSettings; - } -} - -export function writeDesktopSettings(settingsPath: string, settings: DesktopSettings): void { - const directory = Path.dirname(settingsPath); - const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; - FS.mkdirSync(directory, { recursive: true }); - FS.writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); - FS.renameSync(tempPath, settingsPath); -} diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts new file mode 100644 index 00000000000..c51157a2364 --- /dev/null +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -0,0 +1,109 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vitest"; + +const { + appendSwitchMock, + exitMock, + getAppPathMock, + getVersionMock, + onMock, + quitMock, + relaunchMock, + removeListenerMock, + setAboutPanelOptionsMock, + setAppUserModelIdMock, + setDesktopNameMock, + setDockIconMock, + setNameMock, + setPathMock, + whenReadyMock, +} = vi.hoisted(() => ({ + appendSwitchMock: vi.fn(), + exitMock: vi.fn(), + getAppPathMock: vi.fn(() => "/app"), + getVersionMock: vi.fn(() => "1.2.3"), + onMock: vi.fn(), + quitMock: vi.fn(), + relaunchMock: vi.fn(), + removeListenerMock: vi.fn(), + setAboutPanelOptionsMock: vi.fn(), + setAppUserModelIdMock: vi.fn(), + setDesktopNameMock: vi.fn(), + setDockIconMock: vi.fn(), + setNameMock: vi.fn(), + setPathMock: vi.fn(), + whenReadyMock: vi.fn(() => Promise.resolve()), +})); + +vi.mock("electron", () => ({ + app: { + commandLine: { + appendSwitch: appendSwitchMock, + }, + dock: { + setIcon: setDockIconMock, + }, + getAppPath: getAppPathMock, + getVersion: getVersionMock, + isPackaged: true, + name: "T3 Code", + on: onMock, + quit: quitMock, + relaunch: relaunchMock, + removeListener: removeListenerMock, + runningUnderARM64Translation: false, + setAboutPanelOptions: setAboutPanelOptionsMock, + setAppUserModelId: setAppUserModelIdMock, + setDesktopName: setDesktopNameMock, + setName: setNameMock, + setPath: setPathMock, + whenReady: whenReadyMock, + exit: exitMock, + }, +})); + +import * as ElectronApp from "./ElectronApp.ts"; + +describe("ElectronApp", () => { + beforeEach(() => { + appendSwitchMock.mockClear(); + exitMock.mockClear(); + onMock.mockClear(); + quitMock.mockClear(); + relaunchMock.mockClear(); + removeListenerMock.mockClear(); + setPathMock.mockClear(); + }); + + it.effect("reads app metadata through the service", () => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const metadata = yield* electronApp.metadata; + + assert.deepEqual(metadata, { + appVersion: "1.2.3", + appPath: "/app", + isPackaged: true, + resourcesPath: process.resourcesPath, + runningUnderArm64Translation: false, + }); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + + it.effect("scopes app event listeners", () => + Effect.gen(function* () { + const listener = vi.fn(); + + yield* Effect.scoped( + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.on("activate", listener); + }), + ); + + assert.deepEqual(onMock.mock.calls, [["activate", listener]]); + assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]); + }).pipe(Effect.provide(ElectronApp.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts new file mode 100644 index 00000000000..2e330c2d275 --- /dev/null +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -0,0 +1,118 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; + +import * as Electron from "electron"; + +export interface ElectronAppMetadata { + readonly appVersion: string; + readonly appPath: string; + readonly isPackaged: boolean; + readonly resourcesPath: string; + readonly runningUnderArm64Translation: boolean; +} + +export interface ElectronAppShape { + readonly metadata: Effect.Effect; + readonly name: Effect.Effect; + readonly whenReady: Effect.Effect; + readonly quit: Effect.Effect; + readonly exit: (code: number) => Effect.Effect; + readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect; + readonly setPath: ( + name: Parameters[0], + path: string, + ) => Effect.Effect; + readonly setName: (name: string) => Effect.Effect; + readonly setAboutPanelOptions: ( + options: Electron.AboutPanelOptionsOptions, + ) => Effect.Effect; + readonly setAppUserModelId: (id: string) => Effect.Effect; + readonly setDesktopName: (desktopName: string) => Effect.Effect; + readonly setDockIcon: (iconPath: string) => Effect.Effect; + readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; + readonly on: >( + eventName: string, + listener: (...args: Args) => void, + ) => Effect.Effect; +} + +export class ElectronApp extends Context.Service()( + "t3/desktop/electron/App", +) {} + +const addScopedAppListener = >( + eventName: string, + listener: (...args: Args) => void, +): Effect.Effect => + Effect.acquireRelease( + Effect.sync(() => { + Electron.app.on(eventName as any, listener as any); + }), + () => + Effect.sync(() => { + Electron.app.removeListener(eventName as any, listener as any); + }), + ).pipe(Effect.asVoid); + +const make = ElectronApp.of({ + metadata: Effect.sync(() => ({ + appVersion: Electron.app.getVersion(), + appPath: Electron.app.getAppPath(), + isPackaged: Electron.app.isPackaged, + resourcesPath: process.resourcesPath, + runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true, + })), + name: Effect.sync(() => Electron.app.name), + whenReady: Effect.promise(() => Electron.app.whenReady()).pipe(Effect.asVoid), + quit: Effect.sync(() => { + Electron.app.quit(); + }), + exit: (code) => + Effect.sync(() => { + Electron.app.exit(code); + }), + relaunch: (options) => + Effect.sync(() => { + Electron.app.relaunch(options); + }), + setPath: (name, path) => + Effect.sync(() => { + Electron.app.setPath(name, path); + }), + setName: (name) => + Effect.sync(() => { + Electron.app.setName(name); + }), + setAboutPanelOptions: (options) => + Effect.sync(() => { + Electron.app.setAboutPanelOptions(options); + }), + setAppUserModelId: (id) => + Effect.sync(() => { + Electron.app.setAppUserModelId(id); + }), + setDesktopName: (desktopName) => + Effect.sync(() => { + const linuxApp = Electron.app as Electron.App & { + setDesktopName?: (desktopName: string) => void; + }; + linuxApp.setDesktopName?.(desktopName); + }), + setDockIcon: (iconPath) => + Effect.sync(() => { + Electron.app.dock?.setIcon(iconPath); + }), + appendCommandLineSwitch: (switchName, value) => + Effect.sync(() => { + if (value === undefined) { + Electron.app.commandLine.appendSwitch(switchName); + return; + } + Electron.app.commandLine.appendSwitch(switchName, value); + }), + on: addScopedAppListener, +}); + +export const layer = Layer.succeed(ElectronApp, make); diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts new file mode 100644 index 00000000000..61b40bcfc4c --- /dev/null +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -0,0 +1,93 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import type { BrowserWindow } from "electron"; +import { beforeEach, vi } from "vitest"; + +import * as ElectronDialog from "./ElectronDialog.ts"; + +const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock } = vi.hoisted(() => ({ + showMessageBoxMock: vi.fn(), + showOpenDialogMock: vi.fn(), + showErrorBoxMock: vi.fn(), +})); + +vi.mock("electron", () => ({ + dialog: { + showMessageBox: showMessageBoxMock, + showOpenDialog: showOpenDialogMock, + showErrorBox: showErrorBoxMock, + }, +})); + +describe("ElectronDialog", () => { + beforeEach(() => { + showMessageBoxMock.mockReset(); + showOpenDialogMock.mockReset(); + showErrorBoxMock.mockReset(); + }); + + it.effect("returns false without opening a confirm dialog for empty messages", () => + Effect.gen(function* () { + const dialog = yield* ElectronDialog.ElectronDialog; + + const result = yield* dialog.confirm({ + message: " ", + owner: Option.none(), + }); + + assert.isFalse(result); + assert.equal(showMessageBoxMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("opens a confirm dialog for the owner window", () => + Effect.gen(function* () { + const owner = { id: 1 } as BrowserWindow; + showMessageBoxMock.mockResolvedValue({ response: 1 }); + const dialog = yield* ElectronDialog.ElectronDialog; + + const result = yield* dialog.confirm({ + message: "Delete worktree?", + owner: Option.some(owner), + }); + + assert.isTrue(result); + assert.deepEqual(showMessageBoxMock.mock.calls[0], [ + owner, + { + type: "question", + buttons: ["No", "Yes"], + defaultId: 0, + cancelId: 0, + noLink: true, + message: "Delete worktree?", + }, + ]); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("opens an app-level confirm dialog when there is no owner window", () => + Effect.gen(function* () { + showMessageBoxMock.mockResolvedValue({ response: 0 }); + const dialog = yield* ElectronDialog.ElectronDialog; + + const result = yield* dialog.confirm({ + message: "Delete worktree?", + owner: Option.none(), + }); + + assert.isFalse(result); + assert.deepEqual(showMessageBoxMock.mock.calls[0], [ + { + type: "question", + buttons: ["No", "Yes"], + defaultId: 0, + cancelId: 0, + noLink: true, + message: "Delete worktree?", + }, + ]); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts new file mode 100644 index 00000000000..5a4fdfd7ac4 --- /dev/null +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -0,0 +1,84 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as Electron from "electron"; + +const CONFIRM_BUTTON_INDEX = 1; + +export interface ElectronDialogPickFolderInput { + readonly owner: Option.Option; + readonly defaultPath: Option.Option; +} + +export interface ElectronDialogConfirmInput { + readonly owner: Option.Option; + readonly message: string; +} + +export interface ElectronDialogShape { + readonly pickFolder: ( + input: ElectronDialogPickFolderInput, + ) => Effect.Effect>; + readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect; + readonly showMessageBox: ( + options: Electron.MessageBoxOptions, + ) => Effect.Effect; + readonly showErrorBox: (title: string, content: string) => Effect.Effect; +} + +export class ElectronDialog extends Context.Service()( + "t3/desktop/electron/Dialog", +) {} + +const make = ElectronDialog.of({ + pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { + const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { + onNone: () => ({ + properties: ["openDirectory", "createDirectory"], + }), + onSome: (defaultPath) => ({ + properties: ["openDirectory", "createDirectory"], + defaultPath, + }), + }); + const result = yield* Option.match(input.owner, { + onNone: () => Effect.promise(() => Electron.dialog.showOpenDialog(openDialogOptions)), + onSome: (owner) => + Effect.promise(() => Electron.dialog.showOpenDialog(owner, openDialogOptions)), + }); + + if (result.canceled) { + return Option.none(); + } + return Option.fromNullishOr(result.filePaths[0]); + }), + confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { + const normalizedMessage = input.message.trim(); + if (normalizedMessage.length === 0) { + return false; + } + + const options = { + type: "question" as const, + buttons: ["No", "Yes"], + defaultId: 0, + cancelId: 0, + noLink: true, + message: normalizedMessage, + }; + const result = yield* Option.match(input.owner, { + onNone: () => Effect.promise(() => Electron.dialog.showMessageBox(options)), + onSome: (owner) => Effect.promise(() => Electron.dialog.showMessageBox(owner, options)), + }); + return result.response === CONFIRM_BUTTON_INDEX; + }), + showMessageBox: (options) => Effect.promise(() => Electron.dialog.showMessageBox(options)), + showErrorBox: (title, content) => + Effect.sync(() => { + Electron.dialog.showErrorBox(title, content); + }), +}); + +export const layer = Layer.succeed(ElectronDialog, make); diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts new file mode 100644 index 00000000000..0e66c5a6f3f --- /dev/null +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -0,0 +1,119 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import type * as Electron from "electron"; +import { beforeEach, vi } from "vitest"; + +const { buildFromTemplateMock, createFromNamedImageMock, setApplicationMenuMock } = vi.hoisted( + () => ({ + buildFromTemplateMock: vi.fn(), + createFromNamedImageMock: vi.fn(), + setApplicationMenuMock: vi.fn(), + }), +); + +vi.mock("electron", () => ({ + Menu: { + buildFromTemplate: buildFromTemplateMock, + setApplicationMenu: setApplicationMenuMock, + }, + nativeImage: { + createFromNamedImage: createFromNamedImageMock, + }, +})); + +import * as ElectronMenu from "./ElectronMenu.ts"; + +describe("ElectronMenu", () => { + beforeEach(() => { + buildFromTemplateMock.mockReset(); + createFromNamedImageMock.mockReset(); + setApplicationMenuMock.mockReset(); + }); + + it.effect("returns none without building a menu when there are no valid items", () => + Effect.gen(function* () { + const electronMenu = yield* ElectronMenu.ElectronMenu; + const selectedItemId = yield* electronMenu.showContextMenu({ + window: {} as Electron.BrowserWindow, + items: [], + position: Option.none(), + }); + + assert.isTrue(Option.isNone(selectedItemId)); + assert.equal(buildFromTemplateMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronMenu.layer)), + ); + + it.effect("resolves with the clicked leaf item id", () => + Effect.gen(function* () { + buildFromTemplateMock.mockImplementation( + (template: Electron.MenuItemConstructorOptions[]) => ({ + popup: () => { + const firstItem = template[0]; + assert.isDefined(firstItem); + const click = firstItem.click; + if (!click) { + throw new Error("Expected menu item to have a click handler."); + } + click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + }, + }), + ); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const selectedItemId = yield* electronMenu.showContextMenu({ + window: {} as Electron.BrowserWindow, + items: [{ id: "copy", label: "Copy" }], + position: Option.none(), + }); + + assert.equal(Option.getOrNull(selectedItemId), "copy"); + }).pipe(Effect.provide(ElectronMenu.layer)), + ); + + it.effect("resolves with none when the menu closes without a click", () => + Effect.gen(function* () { + buildFromTemplateMock.mockImplementation(() => ({ + popup: (options: Electron.PopupOptions) => { + options.callback?.(); + }, + })); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const selectedItemId = yield* electronMenu.showContextMenu({ + window: {} as Electron.BrowserWindow, + items: [{ id: "copy", label: "Copy" }], + position: Option.some({ x: 10.8, y: 20.2 }), + }); + + assert.isTrue(Option.isNone(selectedItemId)); + assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], { + label: "Copy", + enabled: true, + click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, + }); + }).pipe(Effect.provide(ElectronMenu.layer)), + ); + + it.effect("defers popupTemplate side effects until the returned Effect runs", () => + Effect.gen(function* () { + const popupMock = vi.fn(); + buildFromTemplateMock.mockImplementation(() => ({ popup: popupMock })); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const popup = electronMenu.popupTemplate({ + window: {} as Electron.BrowserWindow, + template: [{ label: "Copy" }], + }); + + assert.equal(buildFromTemplateMock.mock.calls.length, 0); + assert.equal(popupMock.mock.calls.length, 0); + + yield* popup; + + assert.equal(buildFromTemplateMock.mock.calls.length, 1); + assert.equal(popupMock.mock.calls.length, 1); + }).pipe(Effect.provide(ElectronMenu.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts new file mode 100644 index 00000000000..7164fdb54c1 --- /dev/null +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -0,0 +1,181 @@ +import type { ContextMenuItem } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as Electron from "electron"; + +export interface ElectronMenuPosition { + readonly x: number; + readonly y: number; +} + +export interface ElectronMenuContextInput { + readonly window: Electron.BrowserWindow; + readonly items: readonly ContextMenuItem[]; + readonly position: Option.Option; +} + +export interface ElectronMenuTemplateInput { + readonly window: Electron.BrowserWindow; + readonly template: readonly Electron.MenuItemConstructorOptions[]; +} + +export interface ElectronMenuShape { + readonly setApplicationMenu: ( + template: readonly Electron.MenuItemConstructorOptions[], + ) => Effect.Effect; + readonly showContextMenu: ( + input: ElectronMenuContextInput, + ) => Effect.Effect>; + readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect; +} + +export class ElectronMenu extends Context.Service()( + "t3/desktop/electron/Menu", +) {} + +function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { + const normalizedItems: ContextMenuItem[] = []; + + for (const sourceItem of source) { + if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { + continue; + } + + const normalizedItem: ContextMenuItem = { + id: sourceItem.id, + label: sourceItem.label, + destructive: sourceItem.destructive === true, + disabled: sourceItem.disabled === true, + }; + + if (sourceItem.children) { + const normalizedChildren = normalizeContextMenuItems(sourceItem.children); + if (normalizedChildren.length === 0) { + continue; + } + normalizedItem.children = normalizedChildren; + } + + normalizedItems.push(normalizedItem); + } + + return normalizedItems; +} + +const normalizePosition = ( + position: Option.Option, +): Option.Option => + Option.filter( + position, + ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, + ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); + +export const layer = Layer.sync(ElectronMenu, () => { + let destructiveMenuIconCache: Option.Option | undefined; + + const getDestructiveMenuIcon = (): Option.Option => { + if (process.platform !== "darwin") { + return Option.none(); + } + if (destructiveMenuIconCache !== undefined) { + return destructiveMenuIconCache; + } + + try { + const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ + width: 12, + height: 12, + }); + destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); + } catch { + destructiveMenuIconCache = Option.none(); + } + + return destructiveMenuIconCache; + }; + + const buildTemplate = ( + entries: readonly ContextMenuItem[], + complete: (selectedItemId: Option.Option) => void, + ): Electron.MenuItemConstructorOptions[] => { + const template: Electron.MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; + + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; + } + + const itemOption: Electron.MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children, complete); + } else { + itemOption.click = () => complete(Option.some(item.id)); + } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (Option.isSome(destructiveIcon)) { + itemOption.icon = destructiveIcon.value; + } + } + + template.push(itemOption); + } + + return template; + }; + + return ElectronMenu.of({ + setApplicationMenu: (template) => + Effect.sync(() => { + Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); + }), + popupTemplate: (input) => + Effect.sync(() => { + if (input.template.length === 0) { + return; + } + Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); + }), + showContextMenu: (input) => + Effect.callback>((resume) => { + const normalizedItems = normalizeContextMenuItems(input.items); + if (normalizedItems.length === 0) { + resume(Effect.succeed(Option.none())); + return; + } + + let completed = false; + const complete = (selectedItemId: Option.Option) => { + if (completed) { + return; + } + completed = true; + resume(Effect.succeed(selectedItemId)); + }; + + const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); + const popupPosition = normalizePosition(input.position); + const popupOptions = Option.match(popupPosition, { + onNone: (): Electron.PopupOptions => ({ + window: input.window, + callback: () => complete(Option.none()), + }), + onSome: (position): Electron.PopupOptions => ({ + window: input.window, + x: position.x, + y: position.y, + callback: () => complete(Option.none()), + }), + }); + menu.popup(popupOptions); + }), + }); +}); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts new file mode 100644 index 00000000000..955813d6d35 --- /dev/null +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -0,0 +1,105 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import type * as Electron from "electron"; +import { beforeEach, vi } from "vitest"; + +const { registerFileProtocolMock, registerSchemesAsPrivilegedMock, unregisterProtocolMock } = + vi.hoisted(() => ({ + registerFileProtocolMock: vi.fn(), + registerSchemesAsPrivilegedMock: vi.fn(), + unregisterProtocolMock: vi.fn(), + })); + +vi.mock("electron", () => ({ + protocol: { + registerFileProtocol: registerFileProtocolMock, + registerSchemesAsPrivileged: registerSchemesAsPrivilegedMock, + unregisterProtocol: unregisterProtocolMock, + }, +})); + +import * as ElectronProtocol from "./ElectronProtocol.ts"; + +describe("ElectronProtocol", () => { + beforeEach(() => { + registerFileProtocolMock.mockReset(); + registerSchemesAsPrivilegedMock.mockReset(); + unregisterProtocolMock.mockReset(); + }); + + it("normalizes safe desktop protocol pathnames", () => { + assert.equal( + Option.getOrNull(ElectronProtocol.normalizeDesktopProtocolPathname("/settings/./general")), + "settings/general", + ); + assert.isTrue(Option.isNone(ElectronProtocol.normalizeDesktopProtocolPathname("/../secret"))); + }); + + it.effect("registers desktop scheme privileges through a layer", () => + Effect.scoped( + Layer.build(ElectronProtocol.layerSchemePrivileges).pipe( + Effect.andThen( + Effect.sync(() => { + assert.deepEqual(registerSchemesAsPrivilegedMock.mock.calls, [ + [ + [ + { + scheme: "t3", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + ], + ], + ]); + }), + ), + ), + ), + ); + + it.effect("scopes registered file protocols", () => + Effect.gen(function* () { + let capturedHandler: + | (( + request: Electron.ProtocolRequest, + callback: (response: Electron.ProtocolResponse) => void, + ) => void) + | undefined; + + registerFileProtocolMock.mockImplementation((_scheme, handler) => { + capturedHandler = handler; + return true; + }); + + const response = yield* Effect.scoped( + Effect.gen(function* () { + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + yield* electronProtocol.registerFileProtocol({ + scheme: "t3", + handler: () => Effect.succeed({ path: "/app/index.html" }), + }); + + assert.isDefined(capturedHandler); + return yield* Effect.callback((resume) => { + capturedHandler?.({ url: "t3://app/" } as Electron.ProtocolRequest, (response) => + resume(Effect.succeed(response)), + ); + }); + }), + ); + + assert.deepEqual(response, { path: "/app/index.html" }); + assert.deepEqual( + registerFileProtocolMock.mock.calls.map((call) => call[0]), + ["t3"], + ); + assert.deepEqual(unregisterProtocolMock.mock.calls, [["t3"]]); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts new file mode 100644 index 00000000000..32d23ba485d --- /dev/null +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -0,0 +1,272 @@ +import * as Cause from "effect/Cause"; +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 Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; + +import * as Electron from "electron"; + +import { DesktopEnvironment, type DesktopEnvironmentShape } from "../app/DesktopEnvironment.ts"; + +export const DESKTOP_SCHEME = "t3"; + +export class ElectronProtocolRegistrationError extends Data.TaggedError( + "ElectronProtocolRegistrationError", +)<{ + readonly scheme: string; + readonly cause: unknown; +}> { + override get message() { + return `Failed to register ${this.scheme}: file protocol.`; + } +} + +export class ElectronProtocolStaticBundleMissingError extends Data.TaggedError( + "ElectronProtocolStaticBundleMissingError", +)<{}> { + override get message() { + return "Desktop static bundle missing. Build apps/server (with bundled client) first."; + } +} + +export interface ElectronProtocolShape { + readonly registerFileProtocol: (input: { + readonly scheme: string; + readonly handler: ( + request: Electron.ProtocolRequest, + ) => Effect.Effect; + readonly onFailure?: ( + request: Electron.ProtocolRequest, + cause: Cause.Cause, + ) => Electron.ProtocolResponse; + }) => Effect.Effect; + readonly registerDesktopFileProtocol: Effect.Effect< + void, + ElectronProtocolRegistrationError | ElectronProtocolStaticBundleMissingError, + FileSystem.FileSystem | DesktopEnvironment | Scope.Scope + >; +} + +export class ElectronProtocol extends Context.Service()( + "t3/desktop/electron/Protocol", +) {} + +export function normalizeDesktopProtocolPathname(rawPath: string): Option.Option { + const segments: string[] = []; + for (const segment of rawPath.split("/")) { + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + return Option.none(); + } + segments.push(segment); + } + return Option.some(segments.join("/")); +} + +const registerDesktopSchemePrivileges = Effect.sync(() => { + Electron.protocol.registerSchemesAsPrivileged([ + { + scheme: DESKTOP_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + ]); +}).pipe(Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges")); + +export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges); + +const resolveDesktopStaticDir: Effect.Effect< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment +> = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const candidates = [ + environment.path.join(environment.appRoot, "apps/server/dist/client"), + environment.path.join(environment.appRoot, "apps/web/dist"), + ]; + for (const candidate of candidates) { + const hasIndex = yield* fileSystem + .exists(environment.path.join(candidate, "index.html")) + .pipe(Effect.orElseSucceed(() => false)); + if (hasIndex) { + return Option.some(candidate); + } + } + return Option.none(); +}); + +const resolveDesktopStaticPath = Effect.fn("desktop.electron.protocol.resolveDesktopStaticPath")( + function* ( + staticRoot: string, + requestUrl: string, + ): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const url = new URL(requestUrl); + const rawPath = decodeURIComponent(url.pathname); + const normalizedPath = normalizeDesktopProtocolPathname(rawPath); + if (Option.isNone(normalizedPath)) { + return environment.path.join(staticRoot, "index.html"); + } + + const requestedPath = normalizedPath.value.length > 0 ? normalizedPath.value : "index.html"; + const resolvedPath = environment.path.join(staticRoot, requestedPath); + + if (environment.path.extname(resolvedPath)) { + return resolvedPath; + } + + const nestedIndex = environment.path.join(resolvedPath, "index.html"); + const nestedIndexExists = yield* fileSystem + .exists(nestedIndex) + .pipe(Effect.orElseSucceed(() => false)); + if (nestedIndexExists) { + return nestedIndex; + } + + return environment.path.join(staticRoot, "index.html"); + }, +); + +function isStaticAssetRequest(requestUrl: string, environment: DesktopEnvironmentShape): boolean { + try { + const url = new URL(requestUrl); + return environment.path.extname(url.pathname).length > 0; + } catch { + return false; + } +} + +const make = Effect.gen(function* () { + const registeredProtocols = yield* Ref.make>(new Set()); + + const registerFileProtocol = Effect.fn("desktop.electron.protocol.registerFileProtocol")( + function* ({ + scheme, + handler, + onFailure, + }: { + readonly scheme: string; + readonly handler: ( + request: Electron.ProtocolRequest, + ) => Effect.Effect; + readonly onFailure?: ( + request: Electron.ProtocolRequest, + cause: Cause.Cause, + ) => Electron.ProtocolResponse; + }): Effect.fn.Return { + yield* Effect.annotateCurrentSpan({ scheme }); + const alreadyRegistered = yield* Ref.get(registeredProtocols).pipe( + Effect.map((protocols) => protocols.has(scheme)), + ); + if (alreadyRegistered) { + return; + } + + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + yield* Effect.acquireRelease( + Effect.try({ + try: () => { + const registered = Electron.protocol.registerFileProtocol( + scheme, + (request, callback) => { + const response = handler(request).pipe( + Effect.withSpan("desktop.electron.protocol.handleFileRequest"), + Effect.catchCause((cause) => + Effect.succeed(onFailure?.(request, cause) ?? ({ error: -2 } as const)), + ), + ); + + void runPromise(response).then(callback, () => callback({ error: -2 })); + }, + ); + if (!registered) { + throw new ElectronProtocolRegistrationError({ + scheme, + cause: "registerFileProtocol returned false", + }); + } + }, + catch: (cause) => + cause instanceof ElectronProtocolRegistrationError + ? cause + : new ElectronProtocolRegistrationError({ scheme, cause }), + }).pipe( + Effect.andThen( + Ref.update(registeredProtocols, (protocols) => new Set(protocols).add(scheme)), + ), + ), + () => + Effect.sync(() => { + Electron.protocol.unregisterProtocol(scheme); + }).pipe( + Effect.andThen( + Ref.update(registeredProtocols, (protocols) => { + const next = new Set(protocols); + next.delete(scheme); + return next; + }), + ), + ), + ); + }, + ); + + const registerDesktopFileProtocol = Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + if (environment.isDevelopment) return; + + const staticRoot = yield* resolveDesktopStaticDir; + if (Option.isNone(staticRoot)) { + return yield* new ElectronProtocolStaticBundleMissingError(); + } + + const staticRootResolved = environment.path.resolve(staticRoot.value); + const staticRootPrefix = `${staticRootResolved}${environment.path.sep}`; + const fallbackIndex = environment.path.join(staticRootResolved, "index.html"); + + yield* registerFileProtocol({ + scheme: DESKTOP_SCHEME, + handler: Effect.fn("desktop.electron.protocol.handleDesktopFileRequest")(function* (request) { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const candidate = yield* resolveDesktopStaticPath(staticRootResolved, request.url); + const resolvedCandidate = environment.path.resolve(candidate); + const isInRoot = + resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); + const isAssetRequest = isStaticAssetRequest(request.url, environment); + const exists = yield* fileSystem + .exists(resolvedCandidate) + .pipe(Effect.orElseSucceed(() => false)); + + if (!isInRoot || !exists) { + return isAssetRequest ? ({ error: -6 } as const) : ({ path: fallbackIndex } as const); + } + + return { path: resolvedCandidate } as const; + }), + onFailure: () => ({ path: fallbackIndex }), + }); + }).pipe(Effect.withSpan("desktop.electron.protocol.registerDesktopFileProtocol")); + + return ElectronProtocol.of({ + registerFileProtocol, + registerDesktopFileProtocol, + }); +}); + +export const layer = Layer.effect(ElectronProtocol, make); diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts new file mode 100644 index 00000000000..eebb3e2b2f8 --- /dev/null +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -0,0 +1,70 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Electron from "electron"; + +export class ElectronSafeStorageAvailabilityError extends Data.TaggedError( + "ElectronSafeStorageAvailabilityError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron safe storage failed to check encryption availability."; + } +} + +export class ElectronSafeStorageEncryptError extends Data.TaggedError( + "ElectronSafeStorageEncryptError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron safe storage failed to encrypt a string."; + } +} + +export class ElectronSafeStorageDecryptError extends Data.TaggedError( + "ElectronSafeStorageDecryptError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron safe storage failed to decrypt a string."; + } +} + +export interface ElectronSafeStorageShape { + readonly isEncryptionAvailable: Effect.Effect; + readonly encryptString: ( + value: string, + ) => Effect.Effect; + readonly decryptString: ( + value: Uint8Array, + ) => Effect.Effect; +} + +export class ElectronSafeStorage extends Context.Service< + ElectronSafeStorage, + ElectronSafeStorageShape +>()("@t3tools/desktop/ElectronSafeStorage") {} + +const make = ElectronSafeStorage.of({ + isEncryptionAvailable: Effect.try({ + try: () => Electron.safeStorage.isEncryptionAvailable(), + catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), + }), + encryptString: (value) => + Effect.try({ + try: () => Electron.safeStorage.encryptString(value), + catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), + }), + decryptString: (value) => + Effect.try({ + try: () => Electron.safeStorage.decryptString(Buffer.from(value)), + catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), + }), +}); + +export const layer = Layer.succeed(ElectronSafeStorage, make); diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts new file mode 100644 index 00000000000..42d1bb33add --- /dev/null +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -0,0 +1,59 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vitest"; + +const { openExternalMock, writeTextMock } = vi.hoisted(() => ({ + openExternalMock: vi.fn(), + writeTextMock: vi.fn(), +})); + +vi.mock("electron", () => ({ + shell: { + openExternal: openExternalMock, + }, + clipboard: { + writeText: writeTextMock, + }, +})); + +import * as ElectronShell from "./ElectronShell.ts"; + +describe("ElectronShell", () => { + beforeEach(() => { + openExternalMock.mockReset(); + writeTextMock.mockReset(); + }); + + it.effect("opens safe external URLs", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("https://example.com/path"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [["https://example.com/path"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open unsafe external URLs", () => + Effect.gen(function* () { + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("file:///etc/passwd"); + + assert.equal(result, false); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("returns false when Electron rejects openExternal", () => + Effect.gen(function* () { + openExternalMock.mockRejectedValue(new Error("open failed")); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("https://example.com/path"); + + assert.equal(result, false); + }).pipe(Effect.provide(ElectronShell.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts new file mode 100644 index 00000000000..09826a95b09 --- /dev/null +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -0,0 +1,50 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as Electron from "electron"; + +const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); + +export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { + if (typeof rawUrl !== "string") { + return Option.none(); + } + + try { + const url = new URL(rawUrl); + return SAFE_EXTERNAL_PROTOCOLS.has(url.protocol) ? Option.some(url.href) : Option.none(); + } catch { + return Option.none(); + } +} + +export interface ElectronShellShape { + readonly openExternal: (rawUrl: unknown) => Effect.Effect; + readonly copyText: (text: string) => Effect.Effect; +} + +export class ElectronShell extends Context.Service()( + "t3/desktop/electron/Shell", +) {} + +const make = ElectronShell.of({ + openExternal: (rawUrl) => + Option.match(parseSafeExternalUrl(rawUrl), { + onNone: () => Effect.succeed(false), + onSome: (externalUrl) => + Effect.promise(() => + Electron.shell.openExternal(externalUrl).then( + () => true, + () => false, + ), + ), + }), + copyText: (text) => + Effect.sync(() => { + Electron.clipboard.writeText(text); + }), +}); + +export const layer = Layer.succeed(ElectronShell, make); diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts new file mode 100644 index 00000000000..c52882852ff --- /dev/null +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -0,0 +1,52 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vitest"; + +const { onMock, removeListenerMock, themeState } = vi.hoisted(() => ({ + onMock: vi.fn(), + removeListenerMock: vi.fn(), + themeState: { + shouldUseDarkColors: true, + themeSource: "system", + }, +})); + +vi.mock("electron", () => ({ + nativeTheme: { + get shouldUseDarkColors() { + return themeState.shouldUseDarkColors; + }, + set themeSource(value: string) { + themeState.themeSource = value; + }, + on: onMock, + removeListener: removeListenerMock, + }, +})); + +import * as ElectronTheme from "./ElectronTheme.ts"; + +describe("ElectronTheme", () => { + beforeEach(() => { + onMock.mockClear(); + removeListenerMock.mockClear(); + themeState.shouldUseDarkColors = true; + themeState.themeSource = "system"; + }); + + it.effect("scopes native theme update listeners", () => + Effect.gen(function* () { + const listener = vi.fn(); + + yield* Effect.scoped( + Effect.gen(function* () { + const electronTheme = yield* ElectronTheme.ElectronTheme; + yield* electronTheme.onUpdated(listener); + }), + ); + + assert.deepEqual(onMock.mock.calls, [["updated", listener]]); + assert.deepEqual(removeListenerMock.mock.calls, [["updated", listener]]); + }).pipe(Effect.provide(ElectronTheme.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts new file mode 100644 index 00000000000..ecf1f98dade --- /dev/null +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -0,0 +1,40 @@ +import type { DesktopTheme } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; + +import * as Electron from "electron"; + +export interface ElectronThemeShape { + readonly shouldUseDarkColors: Effect.Effect; + readonly setSource: (theme: DesktopTheme) => Effect.Effect; + readonly onUpdated: (listener: () => void) => Effect.Effect; +} + +export class ElectronTheme extends Context.Service()( + "t3/desktop/electron/Theme", +) {} + +const make = ElectronTheme.of({ + shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), + setSource: (theme) => + Effect.suspend(() => { + Electron.nativeTheme.themeSource = theme; + return Effect.void; + }), + onUpdated: (listener) => + Effect.acquireRelease( + Effect.suspend(() => { + Electron.nativeTheme.on("updated", listener); + return Effect.void; + }), + () => + Effect.suspend(() => { + Electron.nativeTheme.removeListener("updated", listener); + return Effect.void; + }), + ), +}); + +export const layer = Layer.succeed(ElectronTheme, make); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts new file mode 100644 index 00000000000..eec21a9ae56 --- /dev/null +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -0,0 +1,79 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vitest"; + +const { autoUpdaterMock } = vi.hoisted(() => ({ + autoUpdaterMock: { + allowDowngrade: false, + allowPrerelease: false, + autoDownload: true, + autoInstallOnAppQuit: true, + channel: "latest", + disableDifferentialDownload: false, + checkForUpdates: vi.fn(() => Promise.resolve(null)), + downloadUpdate: vi.fn(() => Promise.resolve([])), + on: vi.fn(), + quitAndInstall: vi.fn(), + removeListener: vi.fn(), + setFeedURL: vi.fn(), + }, +})); + +vi.mock("electron-updater", () => ({ + autoUpdater: autoUpdaterMock, +})); + +import * as ElectronUpdater from "./ElectronUpdater.ts"; + +describe("ElectronUpdater", () => { + beforeEach(() => { + autoUpdaterMock.allowDowngrade = false; + autoUpdaterMock.allowPrerelease = false; + autoUpdaterMock.autoDownload = true; + autoUpdaterMock.autoInstallOnAppQuit = true; + autoUpdaterMock.channel = "latest"; + autoUpdaterMock.disableDifferentialDownload = false; + autoUpdaterMock.checkForUpdates.mockClear(); + autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null)); + autoUpdaterMock.downloadUpdate.mockClear(); + autoUpdaterMock.downloadUpdate.mockImplementation(() => Promise.resolve([])); + autoUpdaterMock.on.mockClear(); + autoUpdaterMock.quitAndInstall.mockClear(); + autoUpdaterMock.removeListener.mockClear(); + autoUpdaterMock.setFeedURL.mockClear(); + }); + + it.effect("scopes updater event listeners", () => + Effect.gen(function* () { + const listener = vi.fn(); + + yield* Effect.scoped( + Effect.gen(function* () { + const updater = yield* ElectronUpdater.ElectronUpdater; + yield* updater.on("update-available", listener); + }), + ); + + assert.deepEqual(autoUpdaterMock.on.mock.calls, [["update-available", listener]]); + assert.deepEqual(autoUpdaterMock.removeListener.mock.calls, [["update-available", listener]]); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + + it.effect("wraps rejected update checks in the method-specific typed error", () => + Effect.gen(function* () { + const cause = new Error("network unavailable"); + autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => Promise.reject(cause)); + const updater = yield* ElectronUpdater.ElectronUpdater; + + const exit = yield* Effect.exit(updater.checkForUpdates); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); + assert.equal(error.cause, cause); + } + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts new file mode 100644 index 00000000000..ad8afbcdfc3 --- /dev/null +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -0,0 +1,139 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; + +import { autoUpdater } from "electron-updater"; + +type AutoUpdater = typeof autoUpdater; + +export type ElectronUpdaterFeedUrl = Parameters[0]; + +export class ElectronUpdaterCheckForUpdatesError extends Data.TaggedError( + "ElectronUpdaterCheckForUpdatesError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron updater failed to check for updates."; + } +} + +export class ElectronUpdaterDownloadUpdateError extends Data.TaggedError( + "ElectronUpdaterDownloadUpdateError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron updater failed to download the update."; + } +} + +export class ElectronUpdaterQuitAndInstallError extends Data.TaggedError( + "ElectronUpdaterQuitAndInstallError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron updater failed to quit and install the update."; + } +} + +export type ElectronUpdaterError = + | ElectronUpdaterCheckForUpdatesError + | ElectronUpdaterDownloadUpdateError + | ElectronUpdaterQuitAndInstallError; + +export interface ElectronUpdaterShape { + readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect; + readonly setAutoDownload: (value: boolean) => Effect.Effect; + readonly setAutoInstallOnAppQuit: (value: boolean) => Effect.Effect; + readonly setChannel: (channel: string) => Effect.Effect; + readonly setAllowPrerelease: (value: boolean) => Effect.Effect; + readonly allowDowngrade: Effect.Effect; + readonly setAllowDowngrade: (value: boolean) => Effect.Effect; + readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; + readonly checkForUpdates: Effect.Effect; + readonly downloadUpdate: Effect.Effect; + readonly quitAndInstall: (options: { + readonly isSilent: boolean; + readonly isForceRunAfter: boolean; + }) => Effect.Effect; + readonly on: >( + eventName: string, + listener: (...args: Args) => void, + ) => Effect.Effect; +} + +export class ElectronUpdater extends Context.Service()( + "t3/desktop/electron/Updater", +) {} + +export const layer = Layer.succeed(ElectronUpdater, { + setFeedURL: (options) => + Effect.suspend(() => { + autoUpdater.setFeedURL(options); + return Effect.void; + }), + setAutoDownload: (value) => + Effect.suspend(() => { + autoUpdater.autoDownload = value; + return Effect.void; + }), + setAutoInstallOnAppQuit: (value) => + Effect.suspend(() => { + autoUpdater.autoInstallOnAppQuit = value; + return Effect.void; + }), + setChannel: (channel) => + Effect.suspend(() => { + autoUpdater.channel = channel; + return Effect.void; + }), + setAllowPrerelease: (value) => + Effect.suspend(() => { + autoUpdater.allowPrerelease = value; + return Effect.void; + }), + allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade), + setAllowDowngrade: (value) => + Effect.suspend(() => { + autoUpdater.allowDowngrade = value; + return Effect.void; + }), + setDisableDifferentialDownload: (value) => + Effect.suspend(() => { + autoUpdater.disableDifferentialDownload = value; + return Effect.void; + }), + checkForUpdates: Effect.tryPromise({ + try: () => autoUpdater.checkForUpdates(), + catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ cause }), + }).pipe(Effect.asVoid), + downloadUpdate: Effect.tryPromise({ + try: () => autoUpdater.downloadUpdate(), + catch: (cause) => new ElectronUpdaterDownloadUpdateError({ cause }), + }).pipe(Effect.asVoid), + quitAndInstall: ({ isSilent, isForceRunAfter }) => + Effect.try({ + try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), + catch: (cause) => new ElectronUpdaterQuitAndInstallError({ cause }), + }), + on: (eventName, listener) => { + const eventTarget = autoUpdater as unknown as { + on: (eventName: string, listener: (...args: Array) => void) => void; + removeListener: (eventName: string, listener: (...args: Array) => void) => void; + }; + const untypedListener = listener as unknown as (...args: Array) => void; + return Effect.acquireRelease( + Effect.sync(() => { + eventTarget.on(eventName, untypedListener); + }), + () => + Effect.sync(() => { + eventTarget.removeListener(eventName, untypedListener); + }), + ).pipe(Effect.asVoid); + }, +} satisfies ElectronUpdaterShape); diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts new file mode 100644 index 00000000000..bc8a4cdd282 --- /dev/null +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -0,0 +1,51 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type * as Electron from "electron"; +import { beforeEach, vi } from "vitest"; + +const { appFocusMock, getAllWindowsMock } = vi.hoisted(() => ({ + appFocusMock: vi.fn(), + getAllWindowsMock: vi.fn(), +})); + +vi.mock("electron", () => ({ + app: { + focus: appFocusMock, + }, + BrowserWindow: { + getAllWindows: getAllWindowsMock, + }, +})); + +import * as ElectronWindow from "./ElectronWindow.ts"; + +function makeBrowserWindow(input: { readonly destroyed: boolean }) { + return { + isDestroyed: vi.fn(() => input.destroyed), + } as unknown as Electron.BrowserWindow; +} + +describe("ElectronWindow", () => { + beforeEach(() => { + appFocusMock.mockReset(); + getAllWindowsMock.mockReset(); + }); + + it.effect("skips windows destroyed before appearance sync runs", () => + Effect.gen(function* () { + const liveWindow = makeBrowserWindow({ destroyed: false }); + const destroyedWindow = makeBrowserWindow({ destroyed: true }); + getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]); + + const syncedWindows: Electron.BrowserWindow[] = []; + const electronWindow = yield* ElectronWindow.ElectronWindow; + yield* electronWindow.syncAllAppearance((window) => + Effect.sync(() => { + syncedWindows.push(window); + }), + ); + + assert.deepEqual(syncedWindows, [liveWindow]); + }).pipe(Effect.provide(ElectronWindow.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts new file mode 100644 index 00000000000..ed31fb0700e --- /dev/null +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -0,0 +1,135 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import * as Electron from "electron"; + +export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{ + readonly cause: unknown; +}> { + override get message() { + return "Failed to create Electron BrowserWindow."; + } +} + +export interface ElectronWindowShape { + readonly create: ( + options: Electron.BrowserWindowConstructorOptions, + ) => Effect.Effect; + readonly main: Effect.Effect>; + readonly currentMainOrFirst: Effect.Effect>; + readonly focusedMainOrFirst: Effect.Effect>; + readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect; + readonly clearMain: (window: Option.Option) => Effect.Effect; + readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect; + readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect; + readonly destroyAll: Effect.Effect; + readonly syncAllAppearance: ( + sync: (window: Electron.BrowserWindow) => Effect.Effect, + ) => Effect.Effect; +} + +export class ElectronWindow extends Context.Service()( + "t3/desktop/electron/Window", +) {} + +const make = Effect.gen(function* () { + const mainWindowRef = yield* Ref.make>(Option.none()); + + const liveMain = Ref.get(mainWindowRef).pipe( + Effect.map(Option.filter((value) => !value.isDestroyed())), + ); + + const currentMainOrFirst = Effect.gen(function* () { + const main = yield* liveMain; + if (Option.isSome(main)) { + return main; + } + + return Option.fromNullishOr(Electron.BrowserWindow.getAllWindows()[0] ?? null).pipe( + Option.filter((window) => !window.isDestroyed()), + ); + }); + + const focusedMainOrFirst = Effect.sync(() => + Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null).pipe( + Option.filter((window) => !window.isDestroyed()), + ), + ).pipe( + Effect.flatMap((focused) => + Option.isSome(focused) ? Effect.succeed(focused) : currentMainOrFirst, + ), + ); + + return ElectronWindow.of({ + create: (options) => + Effect.try({ + try: () => new Electron.BrowserWindow(options), + catch: (cause) => new ElectronWindowCreateError({ cause }), + }), + main: liveMain, + currentMainOrFirst, + focusedMainOrFirst, + setMain: (window) => Ref.set(mainWindowRef, Option.some(window)), + clearMain: (window) => + Ref.update(mainWindowRef, (current) => { + if (Option.isNone(current)) { + return current; + } + if (Option.isSome(window) && current.value !== window.value) { + return current; + } + return Option.none(); + }), + reveal: (window) => + Effect.sync(() => { + if (window.isDestroyed()) { + return; + } + + if (window.isMinimized()) { + window.restore(); + } + + if (!window.isVisible()) { + window.show(); + } + + if (process.platform === "darwin") { + Electron.app.focus({ steal: true }); + } + + window.focus(); + }), + sendAll: (channel, ...args) => + Effect.sync(() => { + for (const window of Electron.BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue; + } + window.webContents.send(channel, ...args); + } + }), + destroyAll: Effect.sync(() => { + for (const window of Electron.BrowserWindow.getAllWindows()) { + window.destroy(); + } + }), + syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* ( + sync: (window: Electron.BrowserWindow) => Effect.Effect, + ) { + const windows = Electron.BrowserWindow.getAllWindows(); + for (const window of windows) { + if (window.isDestroyed()) { + continue; + } + yield* sync(window); + } + }), + }); +}); + +export const layer = Layer.effect(ElectronWindow, make); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts new file mode 100644 index 00000000000..05a0f25512e --- /dev/null +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -0,0 +1,219 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +export interface DesktopIpcInvokeEvent {} + +export interface DesktopIpcSyncEvent { + returnValue: unknown; +} + +export type DesktopIpcHandleListener = ( + event: DesktopIpcInvokeEvent, + raw: unknown, +) => unknown | Promise; + +export type DesktopIpcSyncListener = (event: DesktopIpcSyncEvent) => void; + +export interface DesktopIpcMain { + removeHandler(channel: string): void; + handle(channel: string, listener: DesktopIpcHandleListener): void; + removeAllListeners(channel: string): void; + on(channel: string, listener: DesktopIpcSyncListener): void; +} + +export interface DesktopIpcMethod { + readonly channel: string; + readonly handler: (raw: unknown) => Effect.Effect; +} + +export interface DesktopSyncIpcMethod { + readonly channel: string; + readonly handler: () => Effect.Effect; +} + +export interface DesktopIpcShape { + readonly handle: ( + input: DesktopIpcMethod, + ) => Effect.Effect; + readonly handleSync: ( + input: DesktopSyncIpcMethod, + ) => Effect.Effect; +} + +export class DesktopIpc extends Context.Service()("t3/desktop/Ipc") {} + +export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => + DesktopIpc.of({ + handle: Effect.fn("desktop.ipc.registerInvoke")(function* ({ + channel, + handler, + }: DesktopIpcMethod) { + yield* Effect.annotateCurrentSpan({ channel }); + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + yield* Effect.acquireRelease( + Effect.sync(() => { + ipcMain.removeHandler(channel); + ipcMain.handle(channel, (_event, raw) => + runPromise( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ channel }); + return yield* handler(raw); + }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")), + ), + ); + }), + () => Effect.sync(() => ipcMain.removeHandler(channel)), + ); + }), + + handleSync: Effect.fn("desktop.ipc.registerSync")(function* ({ + channel, + handler, + }: DesktopSyncIpcMethod) { + yield* Effect.annotateCurrentSpan({ channel }); + const context = yield* Effect.context(); + const runSync = Effect.runSyncWith(context); + + yield* Effect.acquireRelease( + Effect.sync(() => { + ipcMain.removeAllListeners(channel); + ipcMain.on(channel, (event) => { + event.returnValue = runSync( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ channel }); + return yield* handler(); + }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invokeSync")), + ); + }); + }), + () => Effect.sync(() => ipcMain.removeAllListeners(channel)), + ); + }), + }); + +/** + * Convenience helpers for creating IPC methods + */ + +export interface DesktopIpcMethodRegistration< + Payload, + EncodedPayload, + Result, + EncodedResult, + E, + R, + PayloadDecodingServices = never, + PayloadEncodingServices = never, + ResultDecodingServices = never, + ResultEncodingServices = never, +> { + readonly channel: string; + readonly payload: Schema.Codec< + Payload, + EncodedPayload, + PayloadDecodingServices, + PayloadEncodingServices + >; + readonly result: Schema.Codec< + Result, + EncodedResult, + ResultDecodingServices, + ResultEncodingServices + >; + readonly handler: (input: Payload) => Effect.Effect; +} + +export const makeIpcMethod = < + Payload, + EncodedPayload, + Result, + EncodedResult, + E, + R, + PayloadDecodingServices = never, + PayloadEncodingServices = never, + ResultDecodingServices = never, + ResultEncodingServices = never, +>( + method: DesktopIpcMethodRegistration< + Payload, + EncodedPayload, + Result, + EncodedResult, + E, + R, + PayloadDecodingServices, + PayloadEncodingServices, + ResultDecodingServices, + ResultEncodingServices + >, +): DesktopIpcMethod< + E | Schema.SchemaError, + R | PayloadDecodingServices | ResultEncodingServices +> => { + const decode = Schema.decodeUnknownEffect(method.payload); + const encode = Schema.encodeUnknownEffect(method.result); + + return { + channel: method.channel, + handler: (raw) => + decode(raw).pipe( + Effect.flatMap(method.handler), + Effect.flatMap(encode), + Effect.withSpan("desktop.ipc.method", { attributes: { channel: method.channel } }), + ), + }; +}; + +export interface DesktopSyncIpcMethodRegistration< + Result, + EncodedResult, + E, + R, + ResultDecodingServices = never, + ResultEncodingServices = never, +> { + readonly channel: string; + readonly result: Schema.Codec< + Result, + EncodedResult, + ResultDecodingServices, + ResultEncodingServices + >; + readonly handler: () => Effect.Effect; +} + +export const makeSyncIpcMethod = < + Result, + EncodedResult, + E, + R, + ResultDecodingServices = never, + ResultEncodingServices = never, +>( + method: DesktopSyncIpcMethodRegistration< + Result, + EncodedResult, + E, + R, + ResultDecodingServices, + ResultEncodingServices + >, +): DesktopSyncIpcMethod => { + const encode = Schema.encodeUnknownEffect(method.result); + + return { + channel: method.channel, + handler: () => + method + .handler() + .pipe( + Effect.flatMap(encode), + Effect.withSpan("desktop.ipc.method", { attributes: { channel: method.channel } }), + ), + }; +}; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts new file mode 100644 index 00000000000..8717c877951 --- /dev/null +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -0,0 +1,84 @@ +import * as Effect from "effect/Effect"; + +import * as DesktopIpc from "./DesktopIpc.ts"; +import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; +import { + getSavedEnvironmentRegistry, + getSavedEnvironmentSecret, + removeSavedEnvironmentSecret, + setSavedEnvironmentRegistry, + setSavedEnvironmentSecret, +} from "./methods/savedEnvironments.ts"; +import { + getAdvertisedEndpoints, + getServerExposureState, + setServerExposureMode, + setTailscaleServeEnabled, +} from "./methods/serverExposure.ts"; +import { + bootstrapSshBearerSession, + disconnectSshEnvironment, + discoverSshHosts, + ensureSshEnvironment, + fetchSshEnvironmentDescriptor, + fetchSshSessionState, + issueSshWebSocketToken, + resolveSshPasswordPrompt, +} from "./methods/sshEnvironment.ts"; +import { + checkForUpdate, + downloadUpdate, + getUpdateState, + installUpdate, + setUpdateChannel, +} from "./methods/updates.ts"; +import { + confirm, + getAppBranding, + getLocalEnvironmentBootstrap, + openExternal, + pickFolder, + setTheme, + showContextMenu, +} from "./methods/window.ts"; + +export const installDesktopIpcHandlers = Effect.gen(function* () { + const ipc = yield* DesktopIpc.DesktopIpc; + + yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getLocalEnvironmentBootstrap); + + yield* ipc.handle(getClientSettings); + yield* ipc.handle(setClientSettings); + yield* ipc.handle(getSavedEnvironmentRegistry); + yield* ipc.handle(setSavedEnvironmentRegistry); + yield* ipc.handle(getSavedEnvironmentSecret); + yield* ipc.handle(setSavedEnvironmentSecret); + yield* ipc.handle(removeSavedEnvironmentSecret); + + yield* ipc.handle(discoverSshHosts); + yield* ipc.handle(ensureSshEnvironment); + yield* ipc.handle(disconnectSshEnvironment); + yield* ipc.handle(fetchSshEnvironmentDescriptor); + yield* ipc.handle(bootstrapSshBearerSession); + yield* ipc.handle(fetchSshSessionState); + yield* ipc.handle(issueSshWebSocketToken); + yield* ipc.handle(resolveSshPasswordPrompt); + + yield* ipc.handle(getServerExposureState); + yield* ipc.handle(setServerExposureMode); + yield* ipc.handle(setTailscaleServeEnabled); + yield* ipc.handle(getAdvertisedEndpoints); + + yield* ipc.handle(pickFolder); + yield* ipc.handle(confirm); + yield* ipc.handle(setTheme); + yield* ipc.handle(showContextMenu); + yield* ipc.handle(openExternal); + + yield* ipc.handle(getUpdateState); + yield* ipc.handle(setUpdateChannel); + yield* ipc.handle(downloadUpdate); + yield* ipc.handle(installUpdate); + yield* ipc.handle(checkForUpdate); +}).pipe(Effect.withSpan("desktop.ipc.installHandlers")); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts new file mode 100644 index 00000000000..2715b20cb36 --- /dev/null +++ b/apps/desktop/src/ipc/channels.ts @@ -0,0 +1,35 @@ +export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +export const CONFIRM_CHANNEL = "desktop:confirm"; +export const SET_THEME_CHANNEL = "desktop:set-theme"; +export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; +export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const UPDATE_STATE_CHANNEL = "desktop:update-state"; +export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; +export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; +export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; +export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; +export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; +export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; +export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; +export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; +export const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; +export const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; +export const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; +export const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; +export const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; +export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; +export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; +export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; +export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; +export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; +export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; +export const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; +export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; +export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; +export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; +export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; +export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; +export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; diff --git a/apps/desktop/src/ipc/methods/clientSettings.ts b/apps/desktop/src/ipc/methods/clientSettings.ts new file mode 100644 index 00000000000..52b173266cd --- /dev/null +++ b/apps/desktop/src/ipc/methods/clientSettings.ts @@ -0,0 +1,28 @@ +import { ClientSettingsSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getClientSettings = makeIpcMethod({ + channel: IpcChannels.GET_CLIENT_SETTINGS_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(ClientSettingsSchema), + handler: Effect.fn("desktop.ipc.clientSettings.get")(function* () { + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + return Option.getOrNull(yield* clientSettings.get); + }), +}); + +export const setClientSettings = makeIpcMethod({ + channel: IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, + payload: ClientSettingsSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.clientSettings.set")(function* (settings) { + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + yield* clientSettings.set(settings); + }), +}); diff --git a/apps/desktop/src/ipc/methods/savedEnvironments.ts b/apps/desktop/src/ipc/methods/savedEnvironments.ts new file mode 100644 index 00000000000..bc5e4a9aeb2 --- /dev/null +++ b/apps/desktop/src/ipc/methods/savedEnvironments.ts @@ -0,0 +1,76 @@ +import { EnvironmentId, PersistedSavedEnvironmentRecordSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopSavedEnvironments from "../../settings/DesktopSavedEnvironments.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +const SavedEnvironmentRegistryPayload = Schema.Array(PersistedSavedEnvironmentRecordSchema); +const NonBlankString = Schema.String.check( + Schema.makeFilter((value) => + value.trim().length > 0 ? undefined : "Expected a non-empty string", + ), +); + +const SetSavedEnvironmentSecretInput = Schema.Struct({ + environmentId: EnvironmentId, + secret: NonBlankString, +}); + +export const getSavedEnvironmentRegistry = makeIpcMethod({ + channel: IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, + payload: Schema.Void, + result: SavedEnvironmentRegistryPayload, + handler: Effect.fn("desktop.ipc.savedEnvironments.getRegistry")(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + return yield* savedEnvironments.getRegistry; + }), +}); + +export const setSavedEnvironmentRegistry = makeIpcMethod({ + channel: IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, + payload: SavedEnvironmentRegistryPayload, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.savedEnvironments.setRegistry")(function* (records) { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry(records); + }), +}); + +export const getSavedEnvironmentSecret = makeIpcMethod({ + channel: IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + payload: EnvironmentId, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.savedEnvironments.getSecret")(function* (environmentId) { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + return Option.getOrNull(yield* savedEnvironments.getSecret(environmentId)); + }), +}); + +export const setSavedEnvironmentSecret = makeIpcMethod({ + channel: IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + payload: SetSavedEnvironmentSecretInput, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.savedEnvironments.setSecret")(function* ({ + environmentId, + secret, + }) { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + return yield* savedEnvironments.setSecret({ + environmentId, + secret, + }); + }), +}); + +export const removeSavedEnvironmentSecret = makeIpcMethod({ + channel: IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, + payload: EnvironmentId, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.savedEnvironments.removeSecret")(function* (environmentId) { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.removeSecret(environmentId); + }), +}); diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts new file mode 100644 index 00000000000..2bbb08c3f75 --- /dev/null +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -0,0 +1,69 @@ +import { + AdvertisedEndpoint, + DesktopServerExposureModeSchema, + DesktopServerExposureStateSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopServerExposure from "../../serverExposure/DesktopServerExposure.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +const SetTailscaleServeEnabledInput = Schema.Struct({ + enabled: Schema.Boolean, + port: Schema.optionalKey(Schema.Number), +}); + +export const getServerExposureState = makeIpcMethod({ + channel: IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopServerExposureStateSchema, + handler: Effect.fn("desktop.ipc.serverExposure.getState")(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + return yield* serverExposure.getState; + }), +}); + +export const setServerExposureMode = makeIpcMethod({ + channel: IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, + payload: DesktopServerExposureModeSchema, + result: DesktopServerExposureStateSchema, + handler: Effect.fn("desktop.ipc.serverExposure.setMode")(function* (mode) { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const change = yield* serverExposure.setMode(mode); + if (change.requiresRelaunch) { + yield* lifecycle.relaunch(`serverExposureMode=${mode}`); + } + return change.state; + }), +}); + +export const setTailscaleServeEnabled = makeIpcMethod({ + channel: IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, + payload: SetTailscaleServeEnabledInput, + result: DesktopServerExposureStateSchema, + handler: Effect.fn("desktop.ipc.serverExposure.setTailscaleServeEnabled")(function* (input) { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const change = yield* serverExposure.setTailscaleServeEnabled(input); + if (change.requiresRelaunch) { + yield* lifecycle.relaunch( + change.state.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled", + ); + } + return change.state; + }), +}); + +export const getAdvertisedEndpoints = makeIpcMethod({ + channel: IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(AdvertisedEndpoint), + handler: Effect.fn("desktop.ipc.serverExposure.getAdvertisedEndpoints")(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + return yield* serverExposure.getAdvertisedEndpoints; + }), +}); diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts new file mode 100644 index 00000000000..efea3dd132d --- /dev/null +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -0,0 +1,127 @@ +import { + DesktopDiscoveredSshHostSchema, + DesktopSshBearerBootstrapInputSchema, + DesktopSshBearerRequestInputSchema, + DesktopSshEnvironmentEnsureInputSchema, + DesktopSshEnvironmentEnsureResultSchema, + DesktopSshEnvironmentTargetSchema, + DesktopSshHttpBaseUrlInputSchema, + DesktopSshPasswordPromptCancelledType, + DesktopSshPasswordPromptResolutionInputSchema, + ExecutionEnvironmentDescriptor, + AuthBearerBootstrapResult, + AuthSessionState, + AuthWebSocketTokenResult, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; +import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; +import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; +import * as DesktopSshRemoteApi from "../../ssh/DesktopSshRemoteApi.ts"; + +export const discoverSshHosts = makeIpcMethod({ + channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(DesktopDiscoveredSshHostSchema), + handler: Effect.fn("desktop.ipc.sshEnvironment.discoverHosts")(function* () { + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + return yield* sshEnvironment.discoverHosts(); + }), +}); + +export const ensureSshEnvironment = makeIpcMethod({ + channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, + payload: DesktopSshEnvironmentEnsureInputSchema, + result: DesktopSshEnvironmentEnsureResultSchema, + handler: Effect.fn("desktop.ipc.sshEnvironment.ensureEnvironment")(function* ({ + target, + options, + }) { + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + return yield* sshEnvironment.ensureEnvironment(target, options).pipe( + Effect.catch((error) => + DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error) + ? Effect.succeed({ + type: DesktopSshPasswordPromptCancelledType, + message: error.message, + }) + : Effect.fail(error), + ), + ); + }), +}); + +export const disconnectSshEnvironment = makeIpcMethod({ + channel: IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, + payload: DesktopSshEnvironmentTargetSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.sshEnvironment.disconnectEnvironment")(function* (target) { + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + yield* sshEnvironment.disconnectEnvironment(target); + }), +}); + +export const fetchSshEnvironmentDescriptor = makeIpcMethod({ + channel: IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, + payload: DesktopSshHttpBaseUrlInputSchema, + result: ExecutionEnvironmentDescriptor, + handler: Effect.fn("desktop.ipc.sshEnvironment.fetchDescriptor")(function* ({ httpBaseUrl }) { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.fetchEnvironmentDescriptor({ httpBaseUrl }); + }), +}); + +export const bootstrapSshBearerSession = makeIpcMethod({ + channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, + payload: DesktopSshBearerBootstrapInputSchema, + result: AuthBearerBootstrapResult, + handler: Effect.fn("desktop.ipc.sshEnvironment.bootstrapBearerSession")(function* ({ + httpBaseUrl, + credential, + }) { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.bootstrapBearerSession({ httpBaseUrl, credential }); + }), +}); + +export const fetchSshSessionState = makeIpcMethod({ + channel: IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, + payload: DesktopSshBearerRequestInputSchema, + result: AuthSessionState, + handler: Effect.fn("desktop.ipc.sshEnvironment.fetchSessionState")(function* ({ + httpBaseUrl, + bearerToken, + }) { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.fetchSessionState({ httpBaseUrl, bearerToken }); + }), +}); + +export const issueSshWebSocketToken = makeIpcMethod({ + channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, + payload: DesktopSshBearerRequestInputSchema, + result: AuthWebSocketTokenResult, + handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketToken")(function* ({ + httpBaseUrl, + bearerToken, + }) { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.issueWebSocketToken({ httpBaseUrl, bearerToken }); + }), +}); + +export const resolveSshPasswordPrompt = makeIpcMethod({ + channel: IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, + payload: DesktopSshPasswordPromptResolutionInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.sshEnvironment.resolvePasswordPrompt")(function* ({ + requestId, + password, + }) { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + yield* prompts.resolve({ requestId, password }); + }), +}); diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts new file mode 100644 index 00000000000..45ea8502121 --- /dev/null +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -0,0 +1,62 @@ +import { + DesktopUpdateActionResultSchema, + DesktopUpdateChannelSchema, + DesktopUpdateCheckResultSchema, + DesktopUpdateStateSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopUpdates from "../../updates/DesktopUpdates.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getUpdateState = makeIpcMethod({ + channel: IpcChannels.UPDATE_GET_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopUpdateStateSchema, + handler: Effect.fn("desktop.ipc.updates.getState")(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.getState; + }), +}); + +export const setUpdateChannel = makeIpcMethod({ + channel: IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, + payload: DesktopUpdateChannelSchema, + result: DesktopUpdateStateSchema, + handler: Effect.fn("desktop.ipc.updates.setChannel")(function* (channel) { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.setChannel(channel); + }), +}); + +export const downloadUpdate = makeIpcMethod({ + channel: IpcChannels.UPDATE_DOWNLOAD_CHANNEL, + payload: Schema.Void, + result: DesktopUpdateActionResultSchema, + handler: Effect.fn("desktop.ipc.updates.download")(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.download; + }), +}); + +export const installUpdate = makeIpcMethod({ + channel: IpcChannels.UPDATE_INSTALL_CHANNEL, + payload: Schema.Void, + result: DesktopUpdateActionResultSchema, + handler: Effect.fn("desktop.ipc.updates.install")(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.install; + }), +}); + +export const checkForUpdate = makeIpcMethod({ + channel: IpcChannels.UPDATE_CHECK_CHANNEL, + payload: Schema.Void, + result: DesktopUpdateCheckResultSchema, + handler: Effect.fn("desktop.ipc.updates.check")(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.check("web-ui"); + }), +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts new file mode 100644 index 00000000000..1cb4d7265a1 --- /dev/null +++ b/apps/desktop/src/ipc/methods/window.ts @@ -0,0 +1,135 @@ +import { + ContextMenuItemSchema, + DesktopAppBrandingSchema, + DesktopEnvironmentBootstrapSchema, + DesktopThemeSchema, + PickFolderOptionsSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronMenu from "../../electron/ElectronMenu.ts"; +import * as ElectronShell from "../../electron/ElectronShell.ts"; +import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; + +const ContextMenuPosition = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, +}); + +const ContextMenuInput = Schema.Struct({ + items: Schema.Array(ContextMenuItemSchema), + position: Schema.optionalKey(ContextMenuPosition), +}); + +function toWebSocketBaseUrl(httpBaseUrl: URL): string { + const url = new URL(httpBaseUrl.href); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.href; +} + +export const getAppBranding = makeSyncIpcMethod({ + channel: IpcChannels.GET_APP_BRANDING_CHANNEL, + result: Schema.NullOr(DesktopAppBrandingSchema), + handler: Effect.fn("desktop.ipc.window.getAppBranding")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.branding; + }), +}); + +export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, + result: Schema.NullOr(DesktopEnvironmentBootstrapSchema), + handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstrap")(function* () { + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const config = yield* backendManager.currentConfig; + return Option.match(config, { + onNone: () => null, + onSome: ({ bootstrap, httpBaseUrl }) => ({ + label: "Local environment", + httpBaseUrl: httpBaseUrl.href, + wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), + ...(bootstrap.desktopBootstrapToken + ? { bootstrapToken: bootstrap.desktopBootstrapToken } + : {}), + }), + }); + }), +}); + +export const pickFolder = makeIpcMethod({ + channel: IpcChannels.PICK_FOLDER_CHANNEL, + payload: Schema.UndefinedOr(PickFolderOptionsSchema), + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.window.pickFolder")(function* (options) { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const selectedPath = yield* dialog.pickFolder({ + owner: yield* electronWindow.focusedMainOrFirst, + defaultPath: environment.resolvePickFolderDefaultPath(options), + }); + return Option.getOrNull(selectedPath); + }), +}); + +export const confirm = makeIpcMethod({ + channel: IpcChannels.CONFIRM_CHANNEL, + payload: Schema.String, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.confirm")(function* (message) { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + return yield* electronWindow.focusedMainOrFirst.pipe( + Effect.flatMap((owner) => dialog.confirm({ owner, message })), + ); + }), +}); + +export const setTheme = makeIpcMethod({ + channel: IpcChannels.SET_THEME_CHANNEL, + payload: DesktopThemeSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.setTheme")(function* (theme) { + const electronTheme = yield* ElectronTheme.ElectronTheme; + yield* electronTheme.setSource(theme); + }), +}); + +export const showContextMenu = makeIpcMethod({ + channel: IpcChannels.CONTEXT_MENU_CHANNEL, + payload: ContextMenuInput, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.window.showContextMenu")(function* (input) { + const electronMenu = yield* ElectronMenu.ElectronMenu; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.focusedMainOrFirst; + if (Option.isNone(window)) { + return null; + } + + const selectedItemId = yield* electronMenu.showContextMenu({ + window: window.value, + items: input.items, + position: Option.fromNullishOr(input.position), + }); + return Option.getOrNull(selectedItemId); + }), +}); + +export const openExternal = makeIpcMethod({ + channel: IpcChannels.OPEN_EXTERNAL_CHANNEL, + payload: Schema.String, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.openExternal")(function* (url) { + const shell = yield* ElectronShell.ElectronShell; + return yield* shell.openExternal(url); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9c097fc9bd1..576c40ddd88 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,2298 +1,149 @@ -import * as ChildProcess from "node:child_process"; -import * as Crypto from "node:crypto"; -import * as FS from "node:fs"; -import * as OS from "node:os"; -import * as Path from "node:path"; - -import { - app, - BrowserWindow, - type BrowserWindowConstructorOptions, - clipboard, - dialog, - ipcMain, - Menu, - nativeImage, - nativeTheme, - protocol, - safeStorage, - shell, -} from "electron"; -import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron"; -import type { - ClientSettings, - DesktopTheme, - DesktopAppBranding, - DesktopServerExposureMode, - DesktopServerExposureState, - DesktopUpdateChannel, - PersistedSavedEnvironmentRecord, - DesktopUpdateActionResult, - DesktopUpdateCheckResult, - DesktopUpdateState, -} from "@t3tools/contracts"; -import { autoUpdater } from "electron-updater"; - -import type { ContextMenuItem } from "@t3tools/contracts"; -import { RotatingFileSink } from "@t3tools/shared/logging"; -import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as Electron from "electron"; + +import * as NetService from "@t3tools/shared/Net"; +import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; -import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts"; -import { - type DesktopSettings, - DEFAULT_DESKTOP_SETTINGS, - readDesktopSettings, - setDesktopServerExposurePreference, - setDesktopTailscaleServePreference, - setDesktopUpdateChannelPreference, - writeDesktopSettings, -} from "./desktopSettings.ts"; -import { - readClientSettings, - readSavedEnvironmentRegistry, - readSavedEnvironmentSecret, - removeSavedEnvironmentSecret, - writeClientSettings, - writeSavedEnvironmentRegistry, - writeSavedEnvironmentSecret, -} from "./clientPersistence.ts"; -import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts"; -import { showDesktopConfirmDialog } from "./confirmDialog.ts"; -import { - resolveDesktopCoreAdvertisedEndpoints, - resolveDesktopServerExposure, -} from "./serverExposure.ts"; -import { DesktopSshEnvironmentBridge, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts"; -import { syncShellEnvironment } from "./syncShellEnvironment.ts"; -import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; -import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; -import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; -import { ServerListeningDetector } from "./serverListeningDetector.ts"; -import { - createInitialDesktopUpdateState, - reduceDesktopUpdateStateOnCheckFailure, - reduceDesktopUpdateStateOnCheckStart, - reduceDesktopUpdateStateOnDownloadComplete, - reduceDesktopUpdateStateOnDownloadFailure, - reduceDesktopUpdateStateOnDownloadProgress, - reduceDesktopUpdateStateOnDownloadStart, - reduceDesktopUpdateStateOnInstallFailure, - reduceDesktopUpdateStateOnNoUpdate, - reduceDesktopUpdateStateOnUpdateAvailable, -} from "./updateMachine.ts"; -import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; -import { resolveDesktopAppBranding } from "./appBranding.ts"; -import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; -import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; - -syncShellEnvironment(); - -const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; -const CONFIRM_CHANNEL = "desktop:confirm"; -const SET_THEME_CHANNEL = "desktop:set-theme"; -const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; -const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; -const MENU_ACTION_CHANNEL = "desktop:menu-action"; -const UPDATE_STATE_CHANNEL = "desktop:update-state"; -const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; -const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; -const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; -const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; -const UPDATE_CHECK_CHANNEL = "desktop:update-check"; -const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; -const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; -const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; -const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; -const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; -const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; -const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; -const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; -const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; -const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; -const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; -const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; -const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; -const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); -const STATE_DIR = Path.join(BASE_DIR, "userdata"); -const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); -const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json"); -const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json"); -const DESKTOP_SCHEME = "t3"; -const ROOT_DIR = Path.resolve(__dirname, "../../.."); -const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); -// Dev-only SSH launcher override. Set this to an absolute path on the SSH host -// for a built server entry, for example: -// "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs" -const DEV_REMOTE_T3_SERVER_ENTRY_PATH = - process.env.T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH?.trim() ?? ""; -const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({ - isDevelopment, - appVersion: app.getVersion(), -}); -const APP_DISPLAY_NAME = desktopAppBranding.displayName; -const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; -const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop"; -const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code"; -const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code"; -const LEGACY_USER_DATA_DIR_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; -const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; -const COMMIT_HASH_DISPLAY_LENGTH = 12; -const LOG_DIR = Path.join(STATE_DIR, "logs"); -const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; -const LOG_FILE_MAX_FILES = 10; -const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); -const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json"); -const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; -const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; - -function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined { - if (typeof rawOptions !== "object" || rawOptions === null) { - return undefined; - } - - const { initialPath } = rawOptions as { initialPath?: unknown }; - if (typeof initialPath !== "string") { - return undefined; - } - - const trimmedPath = initialPath.trim(); - if (trimmedPath.length === 0) { - return undefined; - } - - if (trimmedPath === "~") { - return OS.homedir(); - } - - if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) { - return Path.join(OS.homedir(), trimmedPath.slice(2)); - } - - return Path.resolve(trimmedPath); -} -const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; -const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; -const TITLEBAR_HEIGHT = 40; -const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux -const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; -const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; - -function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { - const normalizedItems: ContextMenuItem[] = []; - - for (const sourceItem of source) { - if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { - continue; - } - - const normalizedItem: ContextMenuItem = { - id: sourceItem.id, - label: sourceItem.label, - destructive: sourceItem.destructive === true, - disabled: sourceItem.disabled === true, - }; - - if (sourceItem.children) { - const normalizedChildren = normalizeContextMenuItems(sourceItem.children); - if (normalizedChildren.length === 0) { - continue; - } - normalizedItem.children = normalizedChildren; - } - - normalizedItems.push(normalizedItem); - } - - return normalizedItems; -} - -type WindowTitleBarOptions = Pick< - BrowserWindowConstructorOptions, - "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" ->; - -type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; -type LinuxDesktopNamedApp = Electron.App & { - setDesktopName?: (desktopName: string) => void; -}; - -let mainWindow: BrowserWindow | null = null; -let backendProcess: ChildProcess.ChildProcess | null = null; -let backendPort = 0; -let backendBindHost = DESKTOP_LOOPBACK_HOST; -let backendBootstrapToken = ""; -let backendHttpUrl = ""; -let backendWsUrl = ""; -let backendEndpointUrl: string | null = null; -let backendAdvertisedHost: string | null = null; -let backendReadinessAbortController: AbortController | null = null; -let backendInitialWindowOpenInFlight: Promise | null = null; -let backendListeningDetector: ServerListeningDetector | null = null; -let restartAttempt = 0; -let restartTimer: ReturnType | null = null; -let isQuitting = false; -let desktopProtocolRegistered = false; -let aboutCommitHashCache: string | null | undefined; -let desktopLogSink: RotatingFileSink | null = null; -let backendLogSink: RotatingFileSink | null = null; -let restoreStdIoCapture: (() => void) | null = null; -let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); -let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion()); -let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; - -let destructiveMenuIconCache: Electron.NativeImage | null | undefined; -const expectedBackendExitChildren = new WeakSet(); -const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ - platform: process.platform, - processArch: process.arch, - runningUnderArm64Translation: app.runningUnderARM64Translation === true, -}); -const initialUpdateState = (): DesktopUpdateState => - createInitialDesktopUpdateState( - app.getVersion(), - desktopRuntimeInfo, - desktopSettings.updateChannel, - ); - -function logTimestamp(): string { - return new Date().toISOString(); -} - -function logScope(scope: string): string { - return `${scope} run=${APP_RUN_ID}`; -} - -function sanitizeLogValue(value: string): string { - return value.replace(/\s+/g, " ").trim(); -} - -function readPersistedBackendObservabilitySettings(): { - readonly otlpTracesUrl: string | undefined; - readonly otlpMetricsUrl: string | undefined; -} { - try { - if (!FS.existsSync(SERVER_SETTINGS_PATH)) { - return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; - } - return parsePersistedServerObservabilitySettings(FS.readFileSync(SERVER_SETTINGS_PATH, "utf8")); - } catch (error) { - console.warn("[desktop] failed to read persisted backend observability settings", error); - return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; - } -} - -function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined { - if (!rawPort) { - return undefined; - } - - const parsedPort = Number.parseInt(rawPort, 10); - if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) { - return undefined; - } - - return parsedPort; -} - -function resolveDesktopDevServerUrl(): string { - const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); - if (!devServerUrl) { - throw new Error("VITE_DEV_SERVER_URL is required in desktop development."); - } - - return devServerUrl; -} - -function backendChildEnv(): NodeJS.ProcessEnv { - const env = { ...process.env }; - delete env.T3CODE_PORT; - delete env.T3CODE_MODE; - delete env.T3CODE_NO_BROWSER; - delete env.T3CODE_HOST; - delete env.T3CODE_DESKTOP_WS_URL; - delete env.T3CODE_DESKTOP_LAN_ACCESS; - delete env.T3CODE_DESKTOP_LAN_HOST; - delete env.T3CODE_DESKTOP_HTTPS_ENDPOINTS; - delete env.T3CODE_TAILSCALE_SERVE; - delete env.T3CODE_TAILSCALE_SERVE_PORT; - return env; -} - -function getDesktopServerExposureState(): DesktopServerExposureState { - return { - mode: desktopServerExposureMode, - endpointUrl: backendEndpointUrl, - advertisedHost: backendAdvertisedHost, - tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, - tailscaleServePort: desktopSettings.tailscaleServePort, - }; -} - -async function getDesktopAdvertisedEndpoints() { - const exposure = resolveDesktopServerExposure({ - mode: desktopServerExposureMode, - port: backendPort, - networkInterfaces: OS.networkInterfaces(), - ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}), - }); - const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ - port: backendPort, - exposure, - customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(), - }); - const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({ - port: backendPort, - serveEnabled: desktopSettings.tailscaleServeEnabled, - servePort: desktopSettings.tailscaleServePort, - networkInterfaces: OS.networkInterfaces(), - }); - return [...coreEndpoints, ...tailscaleEndpoints]; -} - -function getDesktopSecretStorage() { - return { - isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), - encryptString: (value: string) => safeStorage.encryptString(value), - decryptString: (value: Buffer) => safeStorage.decryptString(value), - } as const; -} -function resolveAdvertisedHostOverride(): string | undefined { - const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); - return override && override.length > 0 ? override : undefined; -} - -function resolveCustomHttpsEndpointUrls(): readonly string[] { - return (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "") - .split(",") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); -} - -async function applyDesktopServerExposureMode( - mode: DesktopServerExposureMode, - options?: { - readonly persist?: boolean; - readonly rejectIfUnavailable?: boolean; - }, -): Promise { - const advertisedHostOverride = resolveAdvertisedHostOverride(); - const requestedMode = mode; - let exposure = resolveDesktopServerExposure({ - mode, - port: backendPort, - networkInterfaces: OS.networkInterfaces(), - ...(advertisedHostOverride ? { advertisedHostOverride } : {}), - }); - - if (requestedMode === "network-accessible" && exposure.endpointUrl === null) { - if (options?.rejectIfUnavailable) { - throw new Error("No reachable network address is available for this desktop right now."); - } - exposure = resolveDesktopServerExposure({ - mode: "local-only", - port: backendPort, - networkInterfaces: OS.networkInterfaces(), - ...(advertisedHostOverride ? { advertisedHostOverride } : {}), - }); - } - - desktopServerExposureMode = exposure.mode; - desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode); - backendBindHost = exposure.bindHost; - backendHttpUrl = exposure.localHttpUrl; - backendWsUrl = exposure.localWsUrl; - backendEndpointUrl = exposure.endpointUrl; - backendAdvertisedHost = exposure.advertisedHost; - - if (options?.persist) { - writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); - } - - return getDesktopServerExposureState(); -} - -async function applyDesktopTailscaleServeEnabled( - nextSettings: DesktopSettings, -): Promise { - desktopSettings = nextSettings; - writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); - relaunchDesktopApp( - desktopSettings.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled", - ); - return getDesktopServerExposureState(); -} - -function relaunchDesktopApp(reason: string): void { - writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`); - setImmediate(() => { - isQuitting = true; - clearUpdatePollTimer(); - cancelBackendReadinessWait(); - void stopBackendAndWaitForExit() - .catch((error) => { - writeDesktopLogHeader( - `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, - ); - }) - .then(() => desktopSshEnvironmentBridge.dispose().catch(() => undefined)) - .finally(() => { - restoreStdIoCapture?.(); - if (isDevelopment) { - app.exit(75); - return; - } - app.relaunch({ - execPath: process.execPath, - args: process.argv.slice(1), - }); - app.exit(0); - }); - }); -} - -function writeDesktopLogHeader(message: string): void { - if (!desktopLogSink) return; - desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); -} - -function writeBackendSessionBoundary(phase: "START" | "END", details: string): void { - if (!backendLogSink) return; - const normalizedDetails = sanitizeLogValue(details); - backendLogSink.write( - `[${logTimestamp()}] ---- APP SESSION ${phase} run=${APP_RUN_ID} ${normalizedDetails} ----\n`, - ); -} - -function formatErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -function getSafeExternalUrl(rawUrl: unknown): string | null { - if (typeof rawUrl !== "string" || rawUrl.length === 0) { - return null; - } - - let parsedUrl: URL; - try { - parsedUrl = new URL(rawUrl); - } catch { - return null; - } - - if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { - return null; - } - - return parsedUrl.toString(); -} - -function getSafeTheme(rawTheme: unknown): DesktopTheme | null { - if (rawTheme === "light" || rawTheme === "dark" || rawTheme === "system") { - return rawTheme; - } - - return null; -} - -async function waitForBackendHttpReady( - baseUrl: string, - options?: Parameters[1], -): Promise { - cancelBackendReadinessWait(); - const controller = new AbortController(); - backendReadinessAbortController = controller; - - try { - await waitForHttpReady(baseUrl, { - ...options, - signal: controller.signal, - }); - } finally { - if (backendReadinessAbortController === controller) { - backendReadinessAbortController = null; - } - } -} - -function cancelBackendReadinessWait(): void { - backendReadinessAbortController?.abort(); - backendReadinessAbortController = null; -} - -async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> { - return await waitForBackendStartupReady({ - listeningPromise: backendListeningDetector?.promise ?? null, - waitForHttpReady: () => - waitForBackendHttpReady(baseUrl, { - timeoutMs: 60_000, - }), - cancelHttpWait: cancelBackendReadinessWait, - }); -} - -function ensureInitialBackendWindowOpen(): void { - const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; - if (isDevelopment || existingWindow !== null || backendInitialWindowOpenInFlight !== null) { - return; - } - - const nextOpen = waitForBackendWindowReady(backendHttpUrl) - .then((source) => { - writeDesktopLogHeader(`bootstrap backend ready source=${source}`); - if (mainWindow ?? BrowserWindow.getAllWindows()[0]) { - return; - } - mainWindow = createWindow(); - writeDesktopLogHeader("bootstrap main window created"); - }) - .catch((error) => { - if (isBackendReadinessAborted(error)) { - return; - } - writeDesktopLogHeader( - `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, - ); - console.warn("[desktop] backend readiness check timed out during packaged bootstrap", error); - }) - .finally(() => { - if (backendInitialWindowOpenInFlight === nextOpen) { - backendInitialWindowOpenInFlight = null; - } - }); - - backendInitialWindowOpenInFlight = nextOpen; -} - -function writeDesktopStreamChunk( - streamName: "stdout" | "stderr", - chunk: unknown, - encoding: BufferEncoding | undefined, -): void { - if (!desktopLogSink) return; - const buffer = Buffer.isBuffer(chunk) - ? chunk - : Buffer.from(String(chunk), typeof chunk === "string" ? encoding : undefined); - desktopLogSink.write(`[${logTimestamp()}] [${logScope(streamName)}] `); - desktopLogSink.write(buffer); - if (buffer.length === 0 || buffer[buffer.length - 1] !== 0x0a) { - desktopLogSink.write("\n"); - } -} - -function installStdIoCapture(): void { - if (!app.isPackaged || desktopLogSink === null || restoreStdIoCapture !== null) { - return; - } - - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - - const patchWrite = - (streamName: "stdout" | "stderr", originalWrite: typeof process.stdout.write) => - ( - chunk: string | Uint8Array, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void, - ): boolean => { - const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined; - writeDesktopStreamChunk(streamName, chunk, encoding); - if (typeof encodingOrCallback === "function") { - return originalWrite(chunk, encodingOrCallback); - } - if (callback !== undefined) { - return originalWrite(chunk, encoding, callback); - } - if (encoding !== undefined) { - return originalWrite(chunk, encoding); - } - return originalWrite(chunk); - }; - - process.stdout.write = patchWrite("stdout", originalStdoutWrite); - process.stderr.write = patchWrite("stderr", originalStderrWrite); - - restoreStdIoCapture = () => { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - restoreStdIoCapture = null; - }; -} - -function initializePackagedLogging(): void { - if (!app.isPackaged) return; - try { - desktopLogSink = new RotatingFileSink({ - filePath: Path.join(LOG_DIR, "desktop-main.log"), - maxBytes: LOG_FILE_MAX_BYTES, - maxFiles: LOG_FILE_MAX_FILES, - }); - backendLogSink = new RotatingFileSink({ - filePath: Path.join(LOG_DIR, "server-child.log"), - maxBytes: LOG_FILE_MAX_BYTES, - maxFiles: LOG_FILE_MAX_FILES, - }); - installStdIoCapture(); - writeDesktopLogHeader(`runtime log capture enabled logDir=${LOG_DIR}`); - } catch (error) { - // Logging setup should never block app startup. - console.error("[desktop] failed to initialize packaged logging", error); - } -} - -function captureBackendOutput(child: ChildProcess.ChildProcess): void { - const attachStream = (stream: NodeJS.ReadableStream | null | undefined): void => { - stream?.on("data", (chunk: unknown) => { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8"); - backendLogSink?.write(buffer); - backendListeningDetector?.push(buffer); - }); - }; - - attachStream(child.stdout); - attachStream(child.stderr); -} - -initializePackagedLogging(); - -if (process.platform === "linux") { - app.commandLine.appendSwitch("class", LINUX_WM_CLASS); -} - -function getDestructiveMenuIcon(): Electron.NativeImage | undefined { - if (process.platform !== "darwin") return undefined; - if (destructiveMenuIconCache !== undefined) { - return destructiveMenuIconCache ?? undefined; - } - try { - const icon = nativeImage.createFromNamedImage("trash").resize({ - width: 14, - height: 14, - }); - if (icon.isEmpty()) { - destructiveMenuIconCache = null; - return undefined; - } - icon.setTemplateImage(true); - destructiveMenuIconCache = icon; - return icon; - } catch { - destructiveMenuIconCache = null; - return undefined; - } -} -let updatePollTimer: ReturnType | null = null; -let updateStartupTimer: ReturnType | null = null; -let updateCheckInFlight = false; -let updateDownloadInFlight = false; -let updateInstallInFlight = false; -let updaterConfigured = false; -let updateState: DesktopUpdateState = initialUpdateState(); - -const desktopSshEnvironmentBridge = new DesktopSshEnvironmentBridge({ - getMainWindow: () => mainWindow, - resolveCliRunner: (): RemoteT3RunnerOptions => { - if (isDevelopment && DEV_REMOTE_T3_SERVER_ENTRY_PATH.length > 0) { - return { nodeScriptPath: DEV_REMOTE_T3_SERVER_ENTRY_PATH }; - } - return { - packageSpec: resolveRemoteT3CliPackageSpec({ - appVersion: app.getVersion(), - updateChannel: desktopSettings.updateChannel, - isDevelopment, - }), - }; - }, -}); - -function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { - if (updateInstallInFlight) return "install"; - if (updateDownloadInFlight) return "download"; - if (updateCheckInFlight) return "check"; - return updateState.errorContext; -} - -protocol.registerSchemesAsPrivileged([ - { - scheme: DESKTOP_SCHEME, - privileges: { - standard: true, - secure: true, - supportFetchAPI: true, - corsEnabled: true, - }, - }, -]); - -function resolveAppRoot(): string { - if (!app.isPackaged) { - return ROOT_DIR; - } - return app.getAppPath(); -} - -/** Read the baked-in app-update.yml config (if applicable). */ -function readAppUpdateYml(): Record | null { - try { - // electron-updater reads from process.resourcesPath in packaged builds, - // or dev-app-update.yml via app.getAppPath() in dev. - const ymlPath = app.isPackaged - ? Path.join(process.resourcesPath, "app-update.yml") - : Path.join(app.getAppPath(), "dev-app-update.yml"); - const raw = FS.readFileSync(ymlPath, "utf-8"); - // The YAML is simple key-value pairs — avoid pulling in a YAML parser by - // doing a line-based parse (fields: provider, owner, repo, releaseType, …). - const entries: Record = {}; - for (const line of raw.split("\n")) { - const match = line.match(/^(\w+):\s*(.+)$/); - if (match?.[1] && match[2]) entries[match[1]] = match[2].trim(); - } - return entries.provider ? entries : null; - } catch { - return null; - } -} - -function normalizeCommitHash(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - if (!COMMIT_HASH_PATTERN.test(trimmed)) { - return null; - } - return trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase(); -} - -function resolveEmbeddedCommitHash(): string | null { - const packageJsonPath = Path.join(resolveAppRoot(), "package.json"); - if (!FS.existsSync(packageJsonPath)) { - return null; - } - - try { - const raw = FS.readFileSync(packageJsonPath, "utf8"); - const parsed = JSON.parse(raw) as { t3codeCommitHash?: unknown }; - return normalizeCommitHash(parsed.t3codeCommitHash); - } catch { - return null; - } -} - -function resolveAboutCommitHash(): string | null { - if (aboutCommitHashCache !== undefined) { - return aboutCommitHashCache; - } - - const envCommitHash = normalizeCommitHash(process.env.T3CODE_COMMIT_HASH); - if (envCommitHash) { - aboutCommitHashCache = envCommitHash; - return aboutCommitHashCache; - } - - // Only packaged builds are required to expose commit metadata. - if (!app.isPackaged) { - aboutCommitHashCache = null; - return aboutCommitHashCache; - } - - aboutCommitHashCache = resolveEmbeddedCommitHash(); - - return aboutCommitHashCache; -} - -function resolveBackendEntry(): string { - return Path.join(resolveAppRoot(), "apps/server/dist/bin.mjs"); -} - -function resolveBackendCwd(): string { - if (!app.isPackaged) { - return resolveAppRoot(); - } - return OS.homedir(); -} - -function resolveDesktopStaticDir(): string | null { - const appRoot = resolveAppRoot(); - const candidates = [ - Path.join(appRoot, "apps/server/dist/client"), - Path.join(appRoot, "apps/web/dist"), - ]; - - for (const candidate of candidates) { - if (FS.existsSync(Path.join(candidate, "index.html"))) { - return candidate; - } - } - - return null; -} - -function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string { - const url = new URL(requestUrl); - const rawPath = decodeURIComponent(url.pathname); - const normalizedPath = Path.posix.normalize(rawPath).replace(/^\/+/, ""); - if (normalizedPath.includes("..")) { - return Path.join(staticRoot, "index.html"); - } - - const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html"; - const resolvedPath = Path.join(staticRoot, requestedPath); - - if (Path.extname(resolvedPath)) { - return resolvedPath; - } - - const nestedIndex = Path.join(resolvedPath, "index.html"); - if (FS.existsSync(nestedIndex)) { - return nestedIndex; - } - - return Path.join(staticRoot, "index.html"); -} - -function isStaticAssetRequest(requestUrl: string): boolean { - try { - const url = new URL(requestUrl); - return Path.extname(url.pathname).length > 0; - } catch { - return false; - } -} - -function handleFatalStartupError(stage: string, error: unknown): void { - const message = formatErrorMessage(error); - const detail = - error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; - writeDesktopLogHeader(`fatal startup error stage=${stage} message=${message}`); - console.error(`[desktop] fatal startup error (${stage})`, error); - if (!isQuitting) { - isQuitting = true; - dialog.showErrorBox("T3 Code failed to start", `Stage: ${stage}\n${message}${detail}`); - } - stopBackend(); - restoreStdIoCapture?.(); - app.quit(); -} - -function registerDesktopProtocol(): void { - if (isDevelopment || desktopProtocolRegistered) return; - - const staticRoot = resolveDesktopStaticDir(); - if (!staticRoot) { - throw new Error( - "Desktop static bundle missing. Build apps/server (with bundled client) first.", - ); - } - - const staticRootResolved = Path.resolve(staticRoot); - const staticRootPrefix = `${staticRootResolved}${Path.sep}`; - const fallbackIndex = Path.join(staticRootResolved, "index.html"); - - protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => { - try { - const candidate = resolveDesktopStaticPath(staticRootResolved, request.url); - const resolvedCandidate = Path.resolve(candidate); - const isInRoot = - resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); - const isAssetRequest = isStaticAssetRequest(request.url); - - if (!isInRoot || !FS.existsSync(resolvedCandidate)) { - if (isAssetRequest) { - callback({ error: -6 }); - return; - } - callback({ path: fallbackIndex }); - return; - } - - callback({ path: resolvedCandidate }); - } catch { - callback({ path: fallbackIndex }); - } - }); - - desktopProtocolRegistered = true; -} - -function dispatchMenuAction(action: string): void { - const existingWindow = - BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; - const targetWindow = existingWindow ?? createWindow(); - if (!existingWindow) { - mainWindow = targetWindow; - } - - const send = () => { - if (targetWindow.isDestroyed()) return; - targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); - revealWindow(targetWindow); - }; - - if (targetWindow.webContents.isLoadingMainFrame()) { - targetWindow.webContents.once("did-finish-load", send); - return; - } - - send(); -} - -function handleCheckForUpdatesMenuClick(): void { - const hasUpdateFeedConfig = - readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); - const disabledReason = getAutoUpdateDisabledReason({ - isDevelopment, - isPackaged: app.isPackaged, - platform: process.platform, - appImage: process.env.APPIMAGE, - disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", - hasUpdateFeedConfig, - }); - if (disabledReason) { - console.info("[desktop-updater] Manual update check requested, but updates are disabled."); - void dialog.showMessageBox({ - type: "info", - title: "Updates unavailable", - message: "Automatic updates are not available right now.", - detail: disabledReason, - buttons: ["OK"], - }); - return; - } - - if (!BrowserWindow.getAllWindows().length) { - mainWindow = createWindow(); - } - void checkForUpdatesFromMenu(); -} - -async function checkForUpdatesFromMenu(): Promise { - await checkForUpdates("menu"); - - if (updateState.status === "up-to-date") { - void dialog.showMessageBox({ - type: "info", - title: "You're up to date!", - message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, - buttons: ["OK"], - }); - } else if (updateState.status === "error") { - void dialog.showMessageBox({ - type: "warning", - title: "Update check failed", - message: "Could not check for updates.", - detail: updateState.message ?? "An unknown error occurred. Please try again later.", - buttons: ["OK"], - }); - } -} - -function configureApplicationMenu(): void { - const template: MenuItemConstructorOptions[] = []; - - if (process.platform === "darwin") { - template.push({ - label: app.name, - submenu: [ - { role: "about" }, - { - label: "Check for Updates...", - click: () => handleCheckForUpdatesMenuClick(), - }, - { type: "separator" }, - { - label: "Settings...", - accelerator: "CmdOrCtrl+,", - click: () => dispatchMenuAction("open-settings"), - }, - { type: "separator" }, - { role: "services" }, - { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, - { type: "separator" }, - { role: "quit" }, - ], - }); - } - - template.push( - { - label: "File", - submenu: [ - ...(process.platform === "darwin" - ? [] - : [ - { - label: "Settings...", - accelerator: "CmdOrCtrl+,", - click: () => dispatchMenuAction("open-settings"), - }, - { type: "separator" as const }, - ]), - { role: process.platform === "darwin" ? "close" : "quit" }, - ], - }, - { role: "editMenu" }, - { - label: "View", - submenu: [ - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, - { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, - { role: "zoomOut" }, - { type: "separator" }, - { role: "togglefullscreen" }, - ], - }, - { role: "windowMenu" }, - { - role: "help", - submenu: [ - { - label: "Check for Updates...", - click: () => handleCheckForUpdatesMenuClick(), - }, - ], - }, - ); - - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); -} - -function resolveResourcePath(fileName: string): string | null { - const candidates = [ - Path.join(__dirname, "../resources", fileName), - Path.join(__dirname, "../prod-resources", fileName), - Path.join(process.resourcesPath, "resources", fileName), - Path.join(process.resourcesPath, fileName), - ]; - - for (const candidate of candidates) { - if (FS.existsSync(candidate)) { - return candidate; - } - } - - return null; -} - -function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { - if (isDevelopment && process.platform === "darwin" && ext === "png") { - const developmentDockIconPath = Path.join( - ROOT_DIR, - "assets", - "dev", - "blueprint-macos-1024.png", +import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts"; +import * as DesktopIpc from "./ipc/DesktopIpc.ts"; +import * as ElectronApp from "./electron/ElectronApp.ts"; +import * as ElectronDialog from "./electron/ElectronDialog.ts"; +import * as ElectronMenu from "./electron/ElectronMenu.ts"; +import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; +import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts"; +import * as ElectronShell from "./electron/ElectronShell.ts"; +import * as ElectronTheme from "./electron/ElectronTheme.ts"; +import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; +import * as ElectronWindow from "./electron/ElectronWindow.ts"; +import * as DesktopApp from "./app/DesktopApp.ts"; +import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; +import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; +import * as DesktopAssets from "./app/DesktopAssets.ts"; +import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; +import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; +import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; +import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; +import * as DesktopObservability from "./app/DesktopObservability.ts"; +import * as DesktopServerExposure from "./serverExposure/DesktopServerExposure.ts"; +import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; +import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; +import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; +import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; +import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; +import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; +import * as DesktopSshRemoteApi from "./ssh/DesktopSshRemoteApi.ts"; +import * as DesktopState from "./app/DesktopState.ts"; +import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; +import * as DesktopWindow from "./window/DesktopWindow.ts"; + +const desktopEnvironmentLayer = Layer.unwrap( + Effect.gen(function* () { + const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( + Effect.flatMap((app) => app.metadata), ); - if (FS.existsSync(developmentDockIconPath)) { - return developmentDockIconPath; - } - } - - return resolveResourcePath(`icon.${ext}`); -} - -/** - * Resolve the Electron userData directory path. - * - * Electron derives the default userData path from `productName` in - * package.json, which currently produces directories with spaces and - * parentheses (e.g. `~/.config/T3 Code (Alpha)` on Linux). This is - * unfriendly for shell usage and violates Linux naming conventions. - * - * We override it to a clean lowercase name (`t3code`). If the legacy - * directory already exists we keep using it so existing users don't - * lose their Chromium profile data (localStorage, cookies, sessions). - */ -function resolveUserDataPath(): string { - const appDataBase = - process.platform === "win32" - ? process.env.APPDATA || Path.join(OS.homedir(), "AppData", "Roaming") - : process.platform === "darwin" - ? Path.join(OS.homedir(), "Library", "Application Support") - : process.env.XDG_CONFIG_HOME || Path.join(OS.homedir(), ".config"); - - const legacyPath = Path.join(appDataBase, LEGACY_USER_DATA_DIR_NAME); - if (FS.existsSync(legacyPath)) { - return legacyPath; - } - - return Path.join(appDataBase, USER_DATA_DIR_NAME); -} - -function configureAppIdentity(): void { - app.setName(APP_DISPLAY_NAME); - const commitHash = resolveAboutCommitHash(); - app.setAboutPanelOptions({ - applicationName: APP_DISPLAY_NAME, - applicationVersion: app.getVersion(), - version: commitHash ?? "unknown", - }); - - if (process.platform === "win32") { - app.setAppUserModelId(APP_USER_MODEL_ID); - } - - if (process.platform === "linux") { - (app as LinuxDesktopNamedApp).setDesktopName?.(LINUX_DESKTOP_ENTRY_NAME); - } - - if (process.platform === "darwin" && app.dock) { - const iconPath = resolveIconPath("png"); - if (iconPath) { - app.dock.setIcon(iconPath); - } - } -} - -function clearUpdatePollTimer(): void { - if (updateStartupTimer) { - clearTimeout(updateStartupTimer); - updateStartupTimer = null; - } - if (updatePollTimer) { - clearInterval(updatePollTimer); - updatePollTimer = null; - } -} - -function revealWindow(window: BrowserWindow): void { - if (window.isDestroyed()) { - return; - } - - if (window.isMinimized()) { - window.restore(); - } - - if (!window.isVisible()) { - window.show(); - } - - if (process.platform === "darwin") { - app.focus({ steal: true }); - } - - window.focus(); -} - -function emitUpdateState(): void { - for (const window of BrowserWindow.getAllWindows()) { - if (window.isDestroyed()) continue; - window.webContents.send(UPDATE_STATE_CHANNEL, updateState); - } -} - -function setUpdateState(patch: Partial): void { - updateState = { ...updateState, ...patch }; - emitUpdateState(); -} - -function createBaseUpdateState( - channel: DesktopUpdateChannel, - enabled: boolean, -): DesktopUpdateState { - return { - ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo, channel), - enabled, - status: enabled ? "idle" : "disabled", - }; -} - -function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): void { - autoUpdater.channel = channel; - autoUpdater.allowPrerelease = channel === "nightly"; - autoUpdater.allowDowngrade = channel === "nightly"; - console.info( - `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}, allowDowngrade=${channel === "nightly"}).`, - ); -} - -function shouldEnableAutoUpdates(): boolean { - const hasUpdateFeedConfig = - readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); - return ( - getAutoUpdateDisabledReason({ - isDevelopment, - isPackaged: app.isPackaged, + return DesktopEnvironment.layer({ + dirname: __dirname, + homeDirectory: NodeOS.homedir(), platform: process.platform, - appImage: process.env.APPIMAGE, - disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", - hasUpdateFeedConfig, - }) === null - ); -} - -async function checkForUpdates(reason: string): Promise { - if (isQuitting || !updaterConfigured || updateCheckInFlight) return false; - if (updateState.status === "downloading" || updateState.status === "downloaded") { - console.info( - `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`, - ); - return false; - } - updateCheckInFlight = true; - setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString())); - console.info(`[desktop-updater] Checking for updates (${reason})...`); - - try { - await autoUpdater.checkForUpdates(); - return true; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - setUpdateState( - reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()), - ); - console.error(`[desktop-updater] Failed to check for updates: ${message}`); - return true; - } finally { - updateCheckInFlight = false; - } -} - -async function downloadAvailableUpdate(): Promise<{ - accepted: boolean; - completed: boolean; -}> { - if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") { - return { accepted: false, completed: false }; - } - updateDownloadInFlight = true; - setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState)); - autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); - console.info("[desktop-updater] Downloading update..."); - - try { - await autoUpdater.downloadUpdate(); - return { accepted: true, completed: true }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - setUpdateState(reduceDesktopUpdateStateOnDownloadFailure(updateState, message)); - console.error(`[desktop-updater] Failed to download update: ${message}`); - return { accepted: true, completed: false }; - } finally { - updateDownloadInFlight = false; - } -} - -async function installDownloadedUpdate(): Promise<{ - accepted: boolean; - completed: boolean; -}> { - if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") { - return { accepted: false, completed: false }; - } - - isQuitting = true; - updateInstallInFlight = true; - clearUpdatePollTimer(); - try { - await stopBackendAndWaitForExit(); - // Destroy all windows before launching the NSIS installer to avoid the installer finding live windows it needs to close. - for (const win of BrowserWindow.getAllWindows()) { - win.destroy(); - } - // `quitAndInstall()` only starts the handoff to the updater. The actual - // install may still fail asynchronously, so keep the action incomplete - // until we either quit or receive an updater error. - autoUpdater.quitAndInstall(true, true); - return { accepted: true, completed: false }; - } catch (error: unknown) { - const message = formatErrorMessage(error); - updateInstallInFlight = false; - isQuitting = false; - setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); - console.error(`[desktop-updater] Failed to install update: ${message}`); - return { accepted: true, completed: false }; - } -} - -function configureAutoUpdater(): void { - const githubToken = - process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || ""; - if (githubToken) { - // When a token is provided, re-configure the feed with `private: true` so - // electron-updater uses the GitHub API (api.github.com) instead of the - // public Atom feed (github.com/…/releases.atom) which rejects Bearer auth. - const appUpdateYml = readAppUpdateYml(); - if (appUpdateYml?.provider === "github") { - autoUpdater.setFeedURL({ - ...appUpdateYml, - provider: "github" as const, - private: true, - token: githubToken, - }); - } - } - - if (process.env.T3CODE_DESKTOP_MOCK_UPDATES) { - autoUpdater.setFeedURL({ - provider: "generic", - url: `http://localhost:${process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000}`, + processArch: process.arch, + ...metadata, }); - } - - const enabled = shouldEnableAutoUpdates(); - setUpdateState(createBaseUpdateState(desktopSettings.updateChannel, enabled)); - if (!enabled) { - return; - } - updaterConfigured = true; - - autoUpdater.autoDownload = false; - autoUpdater.autoInstallOnAppQuit = false; - applyAutoUpdaterChannel(desktopSettings.updateChannel); - autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); - let lastLoggedDownloadMilestone = -1; - - if (isArm64HostRunningIntelBuild(desktopRuntimeInfo)) { - console.info( - "[desktop-updater] Apple Silicon host detected while running Intel build; updates will switch to arm64 packages.", - ); - } - - autoUpdater.on("checking-for-update", () => { - console.info("[desktop-updater] Looking for updates..."); - }); - autoUpdater.on("update-available", (info) => { - if (!doesVersionMatchDesktopUpdateChannel(info.version, updateState.channel)) { - console.info( - `[desktop-updater] Ignoring ${info.version} because it does not match the selected '${updateState.channel}' channel.`, - ); - setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); - lastLoggedDownloadMilestone = -1; - return; - } - - setUpdateState( - reduceDesktopUpdateStateOnUpdateAvailable( - updateState, - info.version, - new Date().toISOString(), - ), - ); - lastLoggedDownloadMilestone = -1; - console.info(`[desktop-updater] Update available: ${info.version}`); - }); - autoUpdater.on("update-not-available", () => { - setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); - lastLoggedDownloadMilestone = -1; - console.info("[desktop-updater] No updates available."); - }); - autoUpdater.on("error", (error) => { - const message = formatErrorMessage(error); - if (updateInstallInFlight) { - updateInstallInFlight = false; - isQuitting = false; - setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); - console.error(`[desktop-updater] Updater error: ${message}`); - return; - } - if (!updateCheckInFlight && !updateDownloadInFlight) { - setUpdateState({ - status: "error", - message, - checkedAt: new Date().toISOString(), - downloadPercent: null, - errorContext: resolveUpdaterErrorContext(), - canRetry: updateState.availableVersion !== null || updateState.downloadedVersion !== null, - }); - } - console.error(`[desktop-updater] Updater error: ${message}`); - }); - autoUpdater.on("download-progress", (progress) => { - const percent = Math.floor(progress.percent); - if ( - shouldBroadcastDownloadProgress(updateState, progress.percent) || - updateState.message !== null - ) { - setUpdateState(reduceDesktopUpdateStateOnDownloadProgress(updateState, progress.percent)); - } - const milestone = percent - (percent % 10); - if (milestone > lastLoggedDownloadMilestone) { - lastLoggedDownloadMilestone = milestone; - console.info(`[desktop-updater] Download progress: ${percent}%`); - } - }); - autoUpdater.on("update-downloaded", (info) => { - setUpdateState(reduceDesktopUpdateStateOnDownloadComplete(updateState, info.version)); - console.info(`[desktop-updater] Update downloaded: ${info.version}`); - }); - - clearUpdatePollTimer(); + }), +); - updateStartupTimer = setTimeout(() => { - updateStartupTimer = null; - void checkForUpdates("startup"); - }, AUTO_UPDATE_STARTUP_DELAY_MS); - updateStartupTimer.unref(); - - updatePollTimer = setInterval(() => { - void checkForUpdates("poll"); - }, AUTO_UPDATE_POLL_INTERVAL_MS); - updatePollTimer.unref(); -} -function scheduleBackendRestart(reason: string): void { - if (isQuitting || restartTimer) return; - - const delayMs = Math.min(500 * 2 ** restartAttempt, 10_000); - restartAttempt += 1; - console.error(`[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`); - - restartTimer = setTimeout(() => { - restartTimer = null; - startBackend(); - }, delayMs); -} - -function startBackend(): void { - if (isQuitting || backendProcess) return; - - backendObservabilitySettings = readPersistedBackendObservabilitySettings(); - const backendEntry = resolveBackendEntry(); - if (!FS.existsSync(backendEntry)) { - scheduleBackendRestart(`missing server entry at ${backendEntry}`); - return; - } - - const captureBackendLogs = !isDevelopment; - const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], { - cwd: resolveBackendCwd(), - // In Electron main, process.execPath points to the Electron binary. - // Run the child in Node mode so this backend process does not become a GUI app instance. - env: { - ...backendChildEnv(), - ELECTRON_RUN_AS_NODE: "1", - }, - stdio: captureBackendLogs - ? ["ignore", "pipe", "pipe", "pipe"] - : ["ignore", "inherit", "inherit", "pipe"], - }); - const bootstrapStream = child.stdio[3]; - if (bootstrapStream && "write" in bootstrapStream) { - bootstrapStream.write( - `${JSON.stringify({ - mode: "desktop", - noBrowser: true, - port: backendPort, - t3Home: BASE_DIR, - host: backendBindHost, - desktopBootstrapToken: backendBootstrapToken, - tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, - tailscaleServePort: desktopSettings.tailscaleServePort, - ...(backendObservabilitySettings.otlpTracesUrl - ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } - : {}), - ...(backendObservabilitySettings.otlpMetricsUrl - ? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl } - : {}), - })}\n`, - ); - bootstrapStream.end(); - } else { - child.kill("SIGTERM"); - scheduleBackendRestart("missing desktop bootstrap pipe"); - return; +const resolveDesktopSshCliRunner = ( + environment: DesktopEnvironment.DesktopEnvironmentShape, + settings: DesktopSettingsValue, +): RemoteT3RunnerOptions => { + const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); + if (environment.isDevelopment && devRemoteEntryPath !== undefined) { + return { nodeScriptPath: devRemoteEntryPath }; } - const listeningDetector = new ServerListeningDetector(); - backendListeningDetector = listeningDetector; - backendProcess = child; - let backendSessionClosed = false; - const closeBackendSession = (details: string) => { - if (backendSessionClosed) return; - backendSessionClosed = true; - writeBackendSessionBoundary("END", details); - }; - writeBackendSessionBoundary( - "START", - `pid=${child.pid ?? "unknown"} port=${backendPort} cwd=${resolveBackendCwd()}`, - ); - captureBackendOutput(child); - - child.once("spawn", () => { - restartAttempt = 0; - }); - - child.on("error", (error) => { - if (backendListeningDetector === listeningDetector) { - listeningDetector.fail(error); - backendListeningDetector = null; - } - const wasExpected = expectedBackendExitChildren.has(child); - if (backendProcess === child) { - backendProcess = null; - } - closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`); - if (wasExpected) { - return; - } - scheduleBackendRestart(error.message); - }); - - child.on("exit", (code, signal) => { - if (backendListeningDetector === listeningDetector) { - listeningDetector.fail( - new Error( - `backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`, - ), - ); - backendListeningDetector = null; - } - const wasExpected = expectedBackendExitChildren.has(child); - if (backendProcess === child) { - backendProcess = null; - } - closeBackendSession( - `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`, - ); - if (isQuitting || wasExpected) return; - const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; - scheduleBackendRestart(reason); - }); - - ensureInitialBackendWindowOpen(); -} - -function stopBackend(): void { - cancelBackendReadinessWait(); - backendListeningDetector = null; - if (restartTimer) { - clearTimeout(restartTimer); - restartTimer = null; - } - - const child = backendProcess; - backendProcess = null; - if (!child) return; - - if (child.exitCode === null && child.signalCode === null) { - expectedBackendExitChildren.add(child); - child.kill("SIGTERM"); - setTimeout(() => { - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGKILL"); - } - }, 2_000).unref(); - } -} - -async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { - cancelBackendReadinessWait(); - if (restartTimer) { - clearTimeout(restartTimer); - restartTimer = null; - } - - const child = backendProcess; - backendProcess = null; - if (!child) return; - const backendChild = child; - if (backendChild.exitCode !== null || backendChild.signalCode !== null) return; - expectedBackendExitChildren.add(backendChild); - - await new Promise((resolve) => { - let settled = false; - let forceKillTimer: ReturnType | null = null; - let exitTimeoutTimer: ReturnType | null = null; - - function settle(): void { - if (settled) return; - settled = true; - backendChild.off("exit", onExit); - if (forceKillTimer) { - clearTimeout(forceKillTimer); - } - if (exitTimeoutTimer) { - clearTimeout(exitTimeoutTimer); - } - resolve(); - } - - function onExit(): void { - settle(); - } - - backendChild.once("exit", onExit); - backendChild.kill("SIGTERM"); - - forceKillTimer = setTimeout(() => { - if (backendChild.exitCode === null && backendChild.signalCode === null) { - backendChild.kill("SIGKILL"); - } - }, 2_000); - forceKillTimer.unref(); - - exitTimeoutTimer = setTimeout(() => { - settle(); - }, timeoutMs); - exitTimeoutTimer.unref(); - }); -} - -function registerIpcHandlers(): void { - ipcMain.removeAllListeners(GET_APP_BRANDING_CHANNEL); - ipcMain.on(GET_APP_BRANDING_CHANNEL, (event) => { - event.returnValue = desktopAppBranding; - }); - - ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); - ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { - event.returnValue = { - label: "Local environment", - httpBaseUrl: backendHttpUrl || null, - wsBaseUrl: backendWsUrl || null, - bootstrapToken: backendBootstrapToken || undefined, - } as const; - }); - - ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL); - ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => readClientSettings(CLIENT_SETTINGS_PATH)); - - ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL); - ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => { - if (typeof rawSettings !== "object" || rawSettings === null) { - throw new Error("Invalid client settings payload."); - } - - writeClientSettings(CLIENT_SETTINGS_PATH, rawSettings as ClientSettings); - }); - - ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); - ipcMain.handle(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async () => - readSavedEnvironmentRegistry(SAVED_ENVIRONMENT_REGISTRY_PATH), - ); - - ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); - ipcMain.handle(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async (_event, rawRecords: unknown) => { - if (!Array.isArray(rawRecords)) { - throw new Error("Invalid saved environment registry payload."); - } - - writeSavedEnvironmentRegistry( - SAVED_ENVIRONMENT_REGISTRY_PATH, - rawRecords as readonly PersistedSavedEnvironmentRecord[], - ); - }); - - ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL); - ipcMain.handle( - GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - async (_event, rawEnvironmentId: unknown) => { - if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { - return null; - } - - return readSavedEnvironmentSecret({ - registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, - environmentId: rawEnvironmentId, - secretStorage: getDesktopSecretStorage(), - }); - }, - ); - - ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL); - ipcMain.handle( - SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - async (_event, rawEnvironmentId: unknown, rawSecret: unknown) => { - if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { - throw new Error("Invalid saved environment id."); - } - if (typeof rawSecret !== "string" || rawSecret.trim().length === 0) { - throw new Error("Invalid saved environment secret."); - } - - return writeSavedEnvironmentSecret({ - registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, - environmentId: rawEnvironmentId, - secret: rawSecret, - secretStorage: getDesktopSecretStorage(), - }); - }, - ); - - ipcMain.removeHandler(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL); - ipcMain.handle( - REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, - async (_event, rawEnvironmentId: unknown) => { - if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { - return; - } - - removeSavedEnvironmentSecret({ - registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, - environmentId: rawEnvironmentId, - }); - }, - ); - - desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain); - - ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); - ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); - - ipcMain.removeHandler(SET_SERVER_EXPOSURE_MODE_CHANNEL); - ipcMain.handle(SET_SERVER_EXPOSURE_MODE_CHANNEL, async (_event, rawMode: unknown) => { - if (rawMode !== "local-only" && rawMode !== "network-accessible") { - throw new Error("Invalid desktop server exposure input."); - } - - const nextMode = rawMode as DesktopServerExposureMode; - if (nextMode === desktopServerExposureMode) { - return getDesktopServerExposureState(); - } - - const nextState = await applyDesktopServerExposureMode(nextMode, { - persist: true, - rejectIfUnavailable: true, - }); - relaunchDesktopApp(`serverExposureMode=${nextMode}`); - return nextState; - }); - - ipcMain.removeHandler(SET_TAILSCALE_SERVE_ENABLED_CHANNEL); - ipcMain.handle(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, async (_event, rawInput: unknown) => { - if (typeof rawInput !== "object" || rawInput === null) { - throw new Error("Invalid Tailscale Serve input."); - } - const input = rawInput as { - readonly enabled?: unknown; - readonly port?: unknown; - }; - if (typeof input.enabled !== "boolean") { - throw new Error("Invalid Tailscale Serve input."); - } - const nextSettings = setDesktopTailscaleServePreference(desktopSettings, { - enabled: input.enabled, - ...(typeof input.port === "number" ? { port: input.port } : {}), - }); - if (nextSettings === desktopSettings) { - return getDesktopServerExposureState(); - } - return applyDesktopTailscaleServeEnabled(nextSettings); - }); - - ipcMain.removeHandler(GET_ADVERTISED_ENDPOINTS_CHANNEL); - ipcMain.handle(GET_ADVERTISED_ENDPOINTS_CHANNEL, async () => getDesktopAdvertisedEndpoints()); - - ipcMain.removeHandler(PICK_FOLDER_CHANNEL); - ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { - const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; - const defaultPath = resolvePickFolderDefaultPath(rawOptions); - const openDialogOptions: OpenDialogOptions = { - properties: ["openDirectory", "createDirectory"], - ...(defaultPath ? { defaultPath } : {}), - }; - const result = owner - ? await dialog.showOpenDialog(owner, openDialogOptions) - : await dialog.showOpenDialog(openDialogOptions); - if (result.canceled) return null; - return result.filePaths[0] ?? null; - }); - - ipcMain.removeHandler(CONFIRM_CHANNEL); - ipcMain.handle(CONFIRM_CHANNEL, async (_event, message: unknown) => { - if (typeof message !== "string") { - return false; - } - - const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; - return showDesktopConfirmDialog(message, owner); - }); - - ipcMain.removeHandler(SET_THEME_CHANNEL); - ipcMain.handle(SET_THEME_CHANNEL, async (_event, rawTheme: unknown) => { - const theme = getSafeTheme(rawTheme); - if (!theme) { - return; - } - - nativeTheme.themeSource = theme; - }); - - ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); - ipcMain.handle( - CONTEXT_MENU_CHANNEL, - async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { - const normalizedItems = normalizeContextMenuItems(items); - if (normalizedItems.length === 0) { - return null; - } - - const popupPosition = - position && - Number.isFinite(position.x) && - Number.isFinite(position.y) && - position.x >= 0 && - position.y >= 0 - ? { - x: Math.floor(position.x), - y: Math.floor(position.y), - } - : null; - - const window = BrowserWindow.getFocusedWindow() ?? mainWindow; - if (!window) return null; - - return new Promise((resolve) => { - const buildTemplate = ( - entries: readonly ContextMenuItem[], - ): MenuItemConstructorOptions[] => { - const template: MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - for (const item of entries) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } - const itemOption: MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - }; - if (item.children && item.children.length > 0) { - itemOption.submenu = buildTemplate(item.children); - } else { - itemOption.click = () => resolve(item.id); - } - if (item.destructive && (!item.children || item.children.length === 0)) { - const destructiveIcon = getDestructiveMenuIcon(); - if (destructiveIcon) { - itemOption.icon = destructiveIcon; - } - } - template.push(itemOption); - } - return template; - }; - - const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems)); - menu.popup({ - window, - ...popupPosition, - callback: () => resolve(null), - }); - }); - }, - ); - - ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); - ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { - const externalUrl = getSafeExternalUrl(rawUrl); - if (!externalUrl) { - return false; - } - - try { - await shell.openExternal(externalUrl); - return true; - } catch { - return false; - } - }); - - ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL); - ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState); - - ipcMain.removeHandler(UPDATE_SET_CHANNEL_CHANNEL); - ipcMain.handle(UPDATE_SET_CHANNEL_CHANNEL, async (_event, rawChannel: unknown) => { - if (rawChannel !== "latest" && rawChannel !== "nightly") { - throw new Error("Invalid desktop update channel input."); - } - if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) { - throw new Error("Cannot change update tracks while an update action is in progress."); - } - - const nextChannel = rawChannel as DesktopUpdateChannel; - - desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel); - writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); - - if (nextChannel === updateState.channel) { - return updateState; - } - - const enabled = shouldEnableAutoUpdates(); - setUpdateState(createBaseUpdateState(nextChannel, enabled)); - - if (!enabled || !updaterConfigured) { - return updateState; - } - - applyAutoUpdaterChannel(nextChannel); - const allowDowngrade = autoUpdater.allowDowngrade; - // An explicit channel switch should allow the immediate nightly->stable rollback path. - autoUpdater.allowDowngrade = true; - try { - await checkForUpdates("channel-change"); - } finally { - autoUpdater.allowDowngrade = allowDowngrade; - } - return updateState; - }); - - ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL); - ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => { - const result = await downloadAvailableUpdate(); - return { - accepted: result.accepted, - completed: result.completed, - state: updateState, - } satisfies DesktopUpdateActionResult; - }); - - ipcMain.removeHandler(UPDATE_INSTALL_CHANNEL); - ipcMain.handle(UPDATE_INSTALL_CHANNEL, async () => { - if (isQuitting) { - return { - accepted: false, - completed: false, - state: updateState, - } satisfies DesktopUpdateActionResult; - } - const result = await installDownloadedUpdate(); - return { - accepted: result.accepted, - completed: result.completed, - state: updateState, - } satisfies DesktopUpdateActionResult; - }); - - ipcMain.removeHandler(UPDATE_CHECK_CHANNEL); - ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => { - if (!updaterConfigured) { - return { - checked: false, - state: updateState, - } satisfies DesktopUpdateCheckResult; - } - const checked = await checkForUpdates("web-ui"); - return { - checked, - state: updateState, - } satisfies DesktopUpdateCheckResult; - }); -} - -function getIconOption(): { icon: string } | Record { - if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle - const ext = process.platform === "win32" ? "ico" : "png"; - const iconPath = resolveIconPath(ext); - return iconPath ? { icon: iconPath } : {}; -} - -function getInitialWindowBackgroundColor(): string { - return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; -} - -function getWindowTitleBarOptions(): WindowTitleBarOptions { - if (process.platform === "darwin") { - return { - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 18 }, - }; - } - return { - titleBarStyle: "hidden", - titleBarOverlay: { - color: TITLEBAR_COLOR, - height: TITLEBAR_HEIGHT, - symbolColor: nativeTheme.shouldUseDarkColors - ? TITLEBAR_DARK_SYMBOL_COLOR - : TITLEBAR_LIGHT_SYMBOL_COLOR, - }, + packageSpec: resolveRemoteT3CliPackageSpec({ + appVersion: environment.appVersion, + updateChannel: settings.updateChannel, + isDevelopment: environment.isDevelopment, + }), }; -} - -function syncWindowAppearance(window: BrowserWindow): void { - if (window.isDestroyed()) { - return; - } - - window.setBackgroundColor(getInitialWindowBackgroundColor()); - const { titleBarOverlay } = getWindowTitleBarOptions(); - if (typeof titleBarOverlay === "object") { - window.setTitleBarOverlay(titleBarOverlay); - } -} - -function syncAllWindowAppearance(): void { - for (const window of BrowserWindow.getAllWindows()) { - syncWindowAppearance(window); - } -} - -nativeTheme.on("updated", syncAllWindowAppearance); - -function createWindow(): BrowserWindow { - const window = new BrowserWindow({ - width: 1100, - height: 780, - minWidth: 840, - minHeight: 620, - show: false, - autoHideMenuBar: true, - backgroundColor: getInitialWindowBackgroundColor(), - ...getIconOption(), - title: APP_DISPLAY_NAME, - ...getWindowTitleBarOptions(), - webPreferences: { - preload: Path.join(__dirname, "preload.cjs"), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); - - window.webContents.on("context-menu", (event, params) => { - event.preventDefault(); - - const menuTemplate: MenuItemConstructorOptions[] = []; - - if (params.misspelledWord) { - for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { - menuTemplate.push({ - label: suggestion, - click: () => window.webContents.replaceMisspelling(suggestion), - }); - } - if (params.dictionarySuggestions.length === 0) { - menuTemplate.push({ label: "No suggestions", enabled: false }); - } - menuTemplate.push({ type: "separator" }); - } - - const externalUrl = getSafeExternalUrl(params.linkURL); - if (externalUrl) { - menuTemplate.push( - { - label: "Copy Link", - click: () => clipboard.writeText(params.linkURL), - }, - { type: "separator" }, - ); - } - - if (params.mediaType === "image") { - menuTemplate.push({ - label: "Copy Image", - click: () => window.webContents.copyImageAt(params.x, params.y), - }); - menuTemplate.push({ type: "separator" }); - } - - menuTemplate.push( - { role: "cut", enabled: params.editFlags.canCut }, - { role: "copy", enabled: params.editFlags.canCopy }, - { role: "paste", enabled: params.editFlags.canPaste }, - { role: "selectAll", enabled: params.editFlags.canSelectAll }, - ); - - Menu.buildFromTemplate(menuTemplate).popup({ window }); - }); - - window.webContents.setWindowOpenHandler(({ url }) => { - const externalUrl = getSafeExternalUrl(url); - if (externalUrl) { - void shell.openExternal(externalUrl); - } - return { action: "deny" }; - }); - - window.on("page-title-updated", (event) => { - event.preventDefault(); - window.setTitle(APP_DISPLAY_NAME); - }); - window.webContents.on("did-finish-load", () => { - window.setTitle(APP_DISPLAY_NAME); - emitUpdateState(); - }); - - // On Linux/Wayland with `show: false`, Electron's `ready-to-show` only - // fires after `show()` is called, deadlocking the standard "wait for - // ready, then show" pattern. Add `did-finish-load` as a Linux-only - // fallback so the window still surfaces once the renderer has loaded - // the page. Other platforms keep the no-flash `ready-to-show` path, - // since `did-finish-load` typically fires before the first paint there. - const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; - if (process.platform === "linux") { - revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); - } - bindFirstRevealTrigger(revealSubscribers, () => revealWindow(window)); - - if (isDevelopment) { - void window.loadURL(resolveDesktopDevServerUrl()); - window.webContents.openDevTools({ mode: "detach" }); - } else { - void window.loadURL(backendHttpUrl); - } - - window.on("closed", () => { - desktopSshEnvironmentBridge.cancelPendingPasswordPrompts( - "SSH authentication was cancelled because the app window closed.", - ); - if (mainWindow === window) { - mainWindow = null; - } - }); - - return window; -} - -// Override Electron's userData path before the `ready` event so that -// Chromium session data uses a filesystem-friendly directory name. -// Must be called synchronously at the top level — before `app.whenReady()`. -app.setPath("userData", resolveUserDataPath()); - -configureAppIdentity(); - -async function bootstrap(): Promise { - writeDesktopLogHeader("bootstrap start"); - const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT); - if (isDevelopment && configuredBackendPort === undefined) { - throw new Error("T3CODE_PORT is required in desktop development."); - } - - backendPort = - configuredBackendPort ?? - (await resolveDesktopBackendPort({ - host: DESKTOP_LOOPBACK_HOST, - startPort: DEFAULT_DESKTOP_BACKEND_PORT, - requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS, - })); - writeDesktopLogHeader( - configuredBackendPort === undefined - ? `selected backend port via sequential scan startPort=${DEFAULT_DESKTOP_BACKEND_PORT} port=${backendPort}` - : `using configured backend port port=${backendPort}`, - ); - backendBootstrapToken = Crypto.randomBytes(24).toString("hex"); - if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) { - writeDesktopLogHeader( - `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`, - ); - } - const serverExposureState = await applyDesktopServerExposureMode( - desktopSettings.serverExposureMode, - { - persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode, - }, - ); - writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`); - if (serverExposureState.endpointUrl) { - writeDesktopLogHeader( - `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`, - ); - } else if (desktopSettings.serverExposureMode === "network-accessible") { - writeDesktopLogHeader( - "bootstrap fell back to local-only because no advertised network host was available", - ); - } - - registerIpcHandlers(); - writeDesktopLogHeader("bootstrap ipc handlers registered"); - startBackend(); - writeDesktopLogHeader("bootstrap backend start requested"); - - if (isDevelopment) { - mainWindow = createWindow(); - writeDesktopLogHeader("bootstrap main window created"); - void waitForBackendWindowReady(backendHttpUrl) - .then((source) => { - writeDesktopLogHeader(`bootstrap backend ready source=${source}`); - }) - .catch((error) => { - if (isBackendReadinessAborted(error)) { - return; - } - writeDesktopLogHeader( - `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, - ); - console.warn("[desktop] backend readiness check timed out during dev bootstrap", error); - }); - return; - } - - ensureInitialBackendWindowOpen(); -} - -app.on("before-quit", () => { - isQuitting = true; - updateInstallInFlight = false; - writeDesktopLogHeader("before-quit received"); - clearUpdatePollTimer(); - cancelBackendReadinessWait(); - stopBackend(); - void desktopSshEnvironmentBridge.dispose().catch(() => undefined); - restoreStdIoCapture?.(); -}); - -app - .whenReady() - .then(() => { - writeDesktopLogHeader("app ready"); - configureAppIdentity(); - configureApplicationMenu(); - registerDesktopProtocol(); - configureAutoUpdater(); - void bootstrap().catch((error) => { - if (isBackendReadinessAborted(error) && isQuitting) { - return; - } - handleFatalStartupError("bootstrap", error); - }); +}; - app.on("activate", () => { - const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; - if (existingWindow) { - revealWindow(existingWindow); - return; - } - if (isDevelopment) { - mainWindow = createWindow(); - return; - } - ensureInitialBackendWindowOpen(); +const desktopSshEnvironmentLayer = Layer.unwrap( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + return DesktopSshEnvironment.layer({ + resolveCliRunner: settings.get.pipe( + Effect.map((currentSettings) => resolveDesktopSshCliRunner(environment, currentSettings)), + ), }); - }) - .catch((error) => { - handleFatalStartupError("whenReady", error); - }); - -app.on("window-all-closed", () => { - if (process.platform !== "darwin" && !isQuitting) { - app.quit(); - } -}); - -if (process.platform !== "win32") { - process.on("SIGINT", () => { - if (isQuitting) return; - isQuitting = true; - writeDesktopLogHeader("SIGINT received"); - clearUpdatePollTimer(); - cancelBackendReadinessWait(); - stopBackend(); - void desktopSshEnvironmentBridge.dispose().catch(() => undefined); - restoreStdIoCapture?.(); - app.quit(); - }); - - process.on("SIGTERM", () => { - if (isQuitting) return; - isQuitting = true; - writeDesktopLogHeader("SIGTERM received"); - clearUpdatePollTimer(); - stopBackend(); - void desktopSshEnvironmentBridge.dispose().catch(() => undefined); - restoreStdIoCapture?.(); - app.quit(); - }); -} + }), +); + +const electronLayer = Layer.mergeAll( + ElectronApp.layer, + ElectronDialog.layer, + ElectronMenu.layer, + ElectronProtocol.layer, + DesktopSecretStorage.layer, + ElectronShell.layer, + ElectronTheme.layer, + ElectronUpdater.layer, + ElectronWindow.layer, + Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(Electron.ipcMain)), +); + +const desktopFoundationLayer = Layer.mergeAll( + DesktopState.layer, + DesktopLifecycle.layerShutdown, + DesktopAppSettings.layer, + DesktopClientSettings.layer, + DesktopSavedEnvironments.layer, + DesktopAssets.layer, + DesktopObservability.layer, +).pipe(Layer.provideMerge(desktopEnvironmentLayer)); + +const desktopSshLayer = Layer.mergeAll(desktopSshEnvironmentLayer, DesktopSshRemoteApi.layer).pipe( + Layer.provideMerge(DesktopSshPasswordPrompts.layer()), +); + +const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( + Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer), + Layer.provideMerge(desktopFoundationLayer), +); + +const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopServerExposureLayer)); + +const desktopBackendLayer = DesktopBackendManager.layer.pipe( + Layer.provideMerge(DesktopAppIdentity.layer), + Layer.provideMerge(DesktopBackendConfiguration.layer), + Layer.provideMerge(desktopWindowLayer), +); + +const desktopApplicationLayer = Layer.mergeAll( + DesktopLifecycle.layer, + DesktopApplicationMenu.layer, + DesktopShellEnvironment.layer, + desktopSshLayer, +).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); + +const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( + Layer.flatMap(() => + desktopApplicationLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(electronLayer), + ), + ), +); + +DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b3b553fe214..173be8fb54a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,48 +1,14 @@ -import { contextBridge, ipcRenderer } from "electron"; import type { DesktopBridge } from "@t3tools/contracts"; +import { contextBridge, ipcRenderer } from "electron"; -const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; -const CONFIRM_CHANNEL = "desktop:confirm"; -const SET_THEME_CHANNEL = "desktop:set-theme"; -const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; -const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; -const MENU_ACTION_CHANNEL = "desktop:menu-action"; -const UPDATE_STATE_CHANNEL = "desktop:update-state"; -const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; -const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; -const UPDATE_CHECK_CHANNEL = "desktop:update-check"; -const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; -const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; -const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; -const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; -const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; -const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; -const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; -const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; -const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; -const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; -const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; -const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; -const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; -const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; -const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; -const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; -const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; -const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; -const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; -const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; -const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; -const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; -const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; -const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; -const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; +import * as IpcChannels from "./ipc/channels.ts"; function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && result !== null && "type" in result && - result.type === SSH_PASSWORD_PROMPT_CANCELLED_RESULT + result.type === IpcChannels.SSH_PASSWORD_PROMPT_CANCELLED_RESULT ) { const message = "message" in result && typeof result.message === "string" @@ -55,93 +21,107 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) { contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { - const result = ipcRenderer.sendSync(GET_APP_BRANDING_CHANNEL); + const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL); if (typeof result !== "object" || result === null) { return null; } return result as ReturnType; }, getLocalEnvironmentBootstrap: () => { - const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); + const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); if (typeof result !== "object" || result === null) { return null; } return result as ReturnType; }, - getClientSettings: () => ipcRenderer.invoke(GET_CLIENT_SETTINGS_CHANNEL), - setClientSettings: (settings) => ipcRenderer.invoke(SET_CLIENT_SETTINGS_CHANNEL, settings), - getSavedEnvironmentRegistry: () => ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), + getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), + setClientSettings: (settings) => + ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), + getSavedEnvironmentRegistry: () => + ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), setSavedEnvironmentRegistry: (records) => - ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records), + ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records), getSavedEnvironmentSecret: (environmentId) => - ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), setSavedEnvironmentSecret: (environmentId, secret) => - ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret), + ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }), removeSavedEnvironmentSecret: (environmentId) => - ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), - discoverSshHosts: () => ipcRenderer.invoke(DISCOVER_SSH_HOSTS_CHANNEL), + ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), ensureSshEnvironment: async (target, options) => unwrapEnsureSshEnvironmentResult( - await ipcRenderer.invoke(ENSURE_SSH_ENVIRONMENT_CHANNEL, target, options), + await ipcRenderer.invoke(IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, { + target, + ...(options === undefined ? {} : { options }), + }), ), disconnectSshEnvironment: (target) => - ipcRenderer.invoke(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target), + ipcRenderer.invoke(IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target), fetchSshEnvironmentDescriptor: (httpBaseUrl) => - ipcRenderer.invoke(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, httpBaseUrl), + ipcRenderer.invoke(IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, { httpBaseUrl }), bootstrapSshBearerSession: (httpBaseUrl, credential) => - ipcRenderer.invoke(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, httpBaseUrl, credential), + ipcRenderer.invoke(IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, { + httpBaseUrl, + credential, + }), fetchSshSessionState: (httpBaseUrl, bearerToken) => - ipcRenderer.invoke(FETCH_SSH_SESSION_STATE_CHANNEL, httpBaseUrl, bearerToken), + ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }), issueSshWebSocketToken: (httpBaseUrl, bearerToken) => - ipcRenderer.invoke(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, httpBaseUrl, bearerToken), + ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }), onSshPasswordPrompt: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { if (typeof request !== "object" || request === null) return; listener(request as Parameters[0]); }; - ipcRenderer.on(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); + ipcRenderer.on(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); return () => { - ipcRenderer.removeListener(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); + ipcRenderer.removeListener(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); }; }, resolveSshPasswordPrompt: (requestId, password) => - ipcRenderer.invoke(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, requestId, password), - getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), - setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), + ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }), + getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL), + setServerExposureMode: (mode) => + ipcRenderer.invoke(IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), setTailscaleServeEnabled: (input) => - ipcRenderer.invoke(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), - getAdvertisedEndpoints: () => ipcRenderer.invoke(GET_ADVERTISED_ENDPOINTS_CHANNEL), - pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), - confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), - setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), - showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), - openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), + ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), + getAdvertisedEndpoints: () => ipcRenderer.invoke(IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL), + pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), + confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), + setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), + showContextMenu: (items, position) => + ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { + items, + ...(position === undefined ? {} : { position }), + }), + openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; listener(action); }; - ipcRenderer.on(MENU_ACTION_CHANNEL, wrappedListener); + ipcRenderer.on(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); return () => { - ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener); + ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, - getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), - setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel), - checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), - downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL), - installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL), + getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), + setUpdateChannel: (channel) => + ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), + checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), + downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), + installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), onUpdateState: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { if (typeof state !== "object" || state === null) return; listener(state as Parameters[0]); }; - ipcRenderer.on(UPDATE_STATE_CHANNEL, wrappedListener); + ipcRenderer.on(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); return () => { - ipcRenderer.removeListener(UPDATE_STATE_CHANNEL, wrappedListener); + ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); }; }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/rotatingFileSink.test.ts b/apps/desktop/src/rotatingFileSink.test.ts deleted file mode 100644 index 53dd98ade8c..00000000000 --- a/apps/desktop/src/rotatingFileSink.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { RotatingFileSink } from "@t3tools/shared/logging"; -import { afterEach, describe, expect, it } from "vitest"; - -const tempRoots: string[] = []; - -function makeTempDir(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-rotating-log-")); - tempRoots.push(dir); - return dir; -} - -afterEach(() => { - for (const dir of tempRoots.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("RotatingFileSink", () => { - it("rotates when writes exceed max bytes", () => { - const dir = makeTempDir(); - const logPath = path.join(dir, "desktop-main.log"); - const sink = new RotatingFileSink({ - filePath: logPath, - maxBytes: 10, - maxFiles: 3, - }); - - sink.write("12345"); - sink.write("67890"); - sink.write("abc"); - - expect(fs.readFileSync(path.join(dir, "desktop-main.log"), "utf8")).toBe("abc"); - expect(fs.readFileSync(path.join(dir, "desktop-main.log.1"), "utf8")).toBe("1234567890"); - }); - - it("retains only maxFiles backups", () => { - const dir = makeTempDir(); - const logPath = path.join(dir, "server-child.log"); - const sink = new RotatingFileSink({ - filePath: logPath, - maxBytes: 4, - maxFiles: 2, - }); - - sink.write("aaaa"); - sink.write("bbbb"); - sink.write("cccc"); - sink.write("dddd"); - - expect(fs.existsSync(path.join(dir, "server-child.log.1"))).toBe(true); - expect(fs.existsSync(path.join(dir, "server-child.log.2"))).toBe(true); - expect(fs.existsSync(path.join(dir, "server-child.log.3"))).toBe(false); - }); - - it("prunes stale backups above maxFiles on startup", () => { - const dir = makeTempDir(); - const logPath = path.join(dir, "desktop-main.log"); - fs.writeFileSync(path.join(dir, "desktop-main.log.1"), "first"); - fs.writeFileSync(path.join(dir, "desktop-main.log.4"), "stale"); - - const sink = new RotatingFileSink({ - filePath: logPath, - maxBytes: 16, - maxFiles: 2, - }); - sink.write("hello"); - - expect(fs.existsSync(path.join(dir, "desktop-main.log.4"))).toBe(false); - }); -}); diff --git a/apps/desktop/src/runtimeArch.test.ts b/apps/desktop/src/runtimeArch.test.ts deleted file mode 100644 index a3173598949..00000000000 --- a/apps/desktop/src/runtimeArch.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; - -describe("resolveDesktopRuntimeInfo", () => { - it("detects Rosetta-translated Intel builds on Apple Silicon", () => { - const runtimeInfo = resolveDesktopRuntimeInfo({ - platform: "darwin", - processArch: "x64", - runningUnderArm64Translation: true, - }); - - expect(runtimeInfo).toEqual({ - hostArch: "arm64", - appArch: "x64", - runningUnderArm64Translation: true, - }); - expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(true); - }); - - it("detects native Apple Silicon builds", () => { - const runtimeInfo = resolveDesktopRuntimeInfo({ - platform: "darwin", - processArch: "arm64", - runningUnderArm64Translation: false, - }); - - expect(runtimeInfo).toEqual({ - hostArch: "arm64", - appArch: "arm64", - runningUnderArm64Translation: false, - }); - expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(false); - }); - - it("passes through non-mac builds without translation", () => { - const runtimeInfo = resolveDesktopRuntimeInfo({ - platform: "linux", - processArch: "x64", - runningUnderArm64Translation: true, - }); - - expect(runtimeInfo).toEqual({ - hostArch: "x64", - appArch: "x64", - runningUnderArm64Translation: false, - }); - }); -}); diff --git a/apps/desktop/src/runtimeArch.ts b/apps/desktop/src/runtimeArch.ts deleted file mode 100644 index 127abf51ab8..00000000000 --- a/apps/desktop/src/runtimeArch.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { DesktopRuntimeArch, DesktopRuntimeInfo } from "@t3tools/contracts"; - -interface ResolveDesktopRuntimeInfoInput { - readonly platform: NodeJS.Platform; - readonly processArch: string; - readonly runningUnderArm64Translation: boolean; -} - -function normalizeDesktopArch(arch: string): DesktopRuntimeArch { - if (arch === "arm64") return "arm64"; - if (arch === "x64") return "x64"; - return "other"; -} - -export function resolveDesktopRuntimeInfo( - input: ResolveDesktopRuntimeInfoInput, -): DesktopRuntimeInfo { - const appArch = normalizeDesktopArch(input.processArch); - - if (input.platform !== "darwin") { - return { - hostArch: appArch, - appArch, - runningUnderArm64Translation: false, - }; - } - - const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch; - - return { - hostArch, - appArch, - runningUnderArm64Translation: input.runningUnderArm64Translation, - }; -} - -export function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean { - return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; -} diff --git a/apps/desktop/src/serverExposure.test.ts b/apps/desktop/src/serverExposure.test.ts deleted file mode 100644 index 4e284ef42bd..00000000000 --- a/apps/desktop/src/serverExposure.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - resolveDesktopCoreAdvertisedEndpoints, - resolveDesktopServerExposure, - resolveLanAdvertisedHost, -} from "./serverExposure.ts"; - -describe("resolveLanAdvertisedHost", () => { - it("prefers an explicit host override", () => { - expect( - resolveLanAdvertisedHost( - { - en0: [ - { - address: "192.168.1.44", - family: "IPv4", - internal: false, - netmask: "255.255.255.0", - cidr: "192.168.1.44/24", - mac: "00:00:00:00:00:00", - }, - ], - }, - "10.0.0.9", - ), - ).toBe("10.0.0.9"); - }); - - it("returns the first usable non-internal IPv4 address", () => { - expect( - resolveLanAdvertisedHost( - { - lo0: [ - { - address: "127.0.0.1", - family: "IPv4", - internal: true, - netmask: "255.0.0.0", - cidr: "127.0.0.1/8", - mac: "00:00:00:00:00:00", - }, - ], - en0: [ - { - address: "192.168.1.44", - family: "IPv4", - internal: false, - netmask: "255.255.255.0", - cidr: "192.168.1.44/24", - mac: "00:00:00:00:00:00", - }, - ], - }, - undefined, - ), - ).toBe("192.168.1.44"); - }); - - it("returns null when no usable network address is available", () => { - expect( - resolveLanAdvertisedHost( - { - lo0: [ - { - address: "127.0.0.1", - family: "IPv4", - internal: true, - netmask: "255.0.0.0", - cidr: "127.0.0.1/8", - mac: "00:00:00:00:00:00", - }, - ], - }, - undefined, - ), - ).toBeNull(); - }); -}); - -describe("resolveDesktopCoreAdvertisedEndpoints", () => { - it("advertises loopback and LAN endpoints without provider-specific assumptions", () => { - const exposure = resolveDesktopServerExposure({ - mode: "network-accessible", - port: 3773, - networkInterfaces: { - en0: [ - { - address: "192.168.1.44", - family: "IPv4", - internal: false, - netmask: "255.255.255.0", - cidr: "192.168.1.44/24", - mac: "00:00:00:00:00:00", - }, - ], - }, - }); - - expect( - resolveDesktopCoreAdvertisedEndpoints({ - port: 3773, - exposure, - customHttpsEndpointUrls: [ - "https://desktop.example.ts.net", - "http://desktop.example.test:3773", - "not-a-url", - ], - }), - ).toEqual([ - { - id: "desktop-loopback:3773", - label: "This machine", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, - }, - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - reachability: "loopback", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - description: "Loopback endpoint for this desktop app.", - }, - { - id: "desktop-lan:http://192.168.1.44:3773", - label: "Local network", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, - }, - httpBaseUrl: "http://192.168.1.44:3773/", - wsBaseUrl: "ws://192.168.1.44:3773/", - reachability: "lan", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - isDefault: true, - description: "Reachable from devices on the same network.", - }, - { - id: "manual:https://desktop.example.ts.net", - label: "Custom HTTPS", - provider: { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "https://desktop.example.ts.net/", - wsBaseUrl: "wss://desktop.example.ts.net/", - reachability: "public", - compatibility: { - hostedHttpsApp: "compatible", - desktopApp: "compatible", - }, - source: "user", - status: "unknown", - description: "User-configured HTTPS endpoint for this desktop backend.", - }, - { - id: "manual:http://desktop.example.test:3773", - label: "Custom endpoint", - provider: { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "http://desktop.example.test:3773/", - wsBaseUrl: "ws://desktop.example.test:3773/", - reachability: "public", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "user", - status: "unknown", - description: "User-configured endpoint for this desktop backend.", - }, - ]); - }); -}); - -describe("resolveDesktopServerExposure", () => { - it("keeps the desktop server loopback-only when local-only mode is selected", () => { - expect( - resolveDesktopServerExposure({ - mode: "local-only", - port: 3773, - networkInterfaces: {}, - }), - ).toEqual({ - mode: "local-only", - bindHost: "127.0.0.1", - localHttpUrl: "http://127.0.0.1:3773", - localWsUrl: "ws://127.0.0.1:3773", - endpointUrl: null, - advertisedHost: null, - }); - }); - - it("binds to all interfaces in network-accessible mode", () => { - expect( - resolveDesktopServerExposure({ - mode: "network-accessible", - port: 3773, - networkInterfaces: { - en0: [ - { - address: "192.168.1.44", - family: "IPv4", - internal: false, - netmask: "255.255.255.0", - cidr: "192.168.1.44/24", - mac: "00:00:00:00:00:00", - }, - ], - }, - }), - ).toEqual({ - mode: "network-accessible", - bindHost: "0.0.0.0", - localHttpUrl: "http://127.0.0.1:3773", - localWsUrl: "ws://127.0.0.1:3773", - endpointUrl: "http://192.168.1.44:3773", - advertisedHost: "192.168.1.44", - }); - }); - - it("stays network-accessible even when no LAN address is currently detectable", () => { - expect( - resolveDesktopServerExposure({ - mode: "network-accessible", - port: 3773, - networkInterfaces: {}, - }), - ).toEqual({ - mode: "network-accessible", - bindHost: "0.0.0.0", - localHttpUrl: "http://127.0.0.1:3773", - localWsUrl: "ws://127.0.0.1:3773", - endpointUrl: null, - advertisedHost: null, - }); - }); -}); diff --git a/apps/desktop/src/serverExposure.ts b/apps/desktop/src/serverExposure.ts deleted file mode 100644 index b73b850ad13..00000000000 --- a/apps/desktop/src/serverExposure.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { NetworkInterfaceInfo } from "node:os"; -import { - createAdvertisedEndpoint, - type CreateAdvertisedEndpointInput, -} from "@t3tools/client-runtime"; -import type { - AdvertisedEndpoint, - AdvertisedEndpointProvider, - DesktopServerExposureMode, -} from "@t3tools/contracts"; - -const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; -const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; - -export interface DesktopServerExposure { - readonly mode: DesktopServerExposureMode; - readonly bindHost: string; - readonly localHttpUrl: string; - readonly localWsUrl: string; - readonly endpointUrl: string | null; - readonly advertisedHost: string | null; -} - -export interface DesktopAdvertisedEndpointInput { - readonly port: number; - readonly exposure: DesktopServerExposure; - readonly customHttpsEndpointUrls?: readonly string[]; -} - -const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, -}; - -const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, -}; - -const normalizeOptionalHost = (value: string | undefined): string | undefined => { - const normalized = value?.trim(); - return normalized && normalized.length > 0 ? normalized : undefined; -}; - -const isUsableLanIpv4Address = (address: string): boolean => - !address.startsWith("127.") && !address.startsWith("169.254."); - -function isHttpsEndpointUrl(value: string): boolean { - try { - return new URL(value).protocol === "https:"; - } catch { - return false; - } -} - -export function resolveLanAdvertisedHost( - networkInterfaces: NodeJS.Dict, - explicitHost: string | undefined, -): string | null { - const normalizedExplicitHost = normalizeOptionalHost(explicitHost); - if (normalizedExplicitHost) { - return normalizedExplicitHost; - } - - for (const interfaceAddresses of Object.values(networkInterfaces)) { - if (!interfaceAddresses) continue; - - for (const address of interfaceAddresses) { - if (address.internal) continue; - if (address.family !== "IPv4") continue; - if (!isUsableLanIpv4Address(address.address)) continue; - return address.address; - } - } - - return null; -} - -export function resolveDesktopServerExposure(input: { - readonly mode: DesktopServerExposureMode; - readonly port: number; - readonly networkInterfaces: NodeJS.Dict; - readonly advertisedHostOverride?: string; -}): DesktopServerExposure { - const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; - const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; - - if (input.mode === "local-only") { - return { - mode: input.mode, - bindHost: DESKTOP_LOOPBACK_HOST, - localHttpUrl, - localWsUrl, - endpointUrl: null, - advertisedHost: null, - }; - } - - const advertisedHost = resolveLanAdvertisedHost( - input.networkInterfaces, - input.advertisedHostOverride, - ); - - return { - mode: input.mode, - bindHost: DESKTOP_LAN_BIND_HOST, - localHttpUrl, - localWsUrl, - endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null, - advertisedHost, - }; -} - -function createDesktopEndpoint( - input: Omit, -): AdvertisedEndpoint { - return createAdvertisedEndpoint({ - ...input, - provider: DESKTOP_CORE_ENDPOINT_PROVIDER, - source: "desktop-core", - }); -} - -function createManualEndpoint( - input: Omit, -): AdvertisedEndpoint { - return createAdvertisedEndpoint({ - ...input, - provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER, - source: "user", - }); -} - -export function resolveDesktopCoreAdvertisedEndpoints( - input: DesktopAdvertisedEndpointInput, -): readonly AdvertisedEndpoint[] { - const endpoints: AdvertisedEndpoint[] = [ - createDesktopEndpoint({ - id: `desktop-loopback:${input.port}`, - label: "This machine", - httpBaseUrl: input.exposure.localHttpUrl, - reachability: "loopback", - status: "available", - description: "Loopback endpoint for this desktop app.", - }), - ]; - - if (input.exposure.endpointUrl) { - endpoints.push( - createDesktopEndpoint({ - id: `desktop-lan:${input.exposure.endpointUrl}`, - label: "Local network", - httpBaseUrl: input.exposure.endpointUrl, - reachability: "lan", - status: "available", - isDefault: true, - description: "Reachable from devices on the same network.", - }), - ); - } - - for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) { - try { - const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl); - endpoints.push( - createManualEndpoint({ - id: `manual:${customEndpointUrl}`, - label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint", - httpBaseUrl: customEndpointUrl, - reachability: "public", - ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}), - status: "unknown", - description: isHttpsEndpoint - ? "User-configured HTTPS endpoint for this desktop backend." - : "User-configured endpoint for this desktop backend.", - }), - ); - } catch { - // Ignore malformed user-configured endpoints without dropping valid endpoints. - } - } - - return endpoints; -} diff --git a/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts b/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts new file mode 100644 index 00000000000..0f3e9eaeb45 --- /dev/null +++ b/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts @@ -0,0 +1,365 @@ +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + DesktopEnvironment, + layer as makeDesktopEnvironmentLayer, +} from "../app/DesktopEnvironment.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; + +const encoder = new TextEncoder(); + +const emptyNetworkInterfaces: DesktopNetworkInterfaces = {}; +const lanNetworkInterfaces: DesktopNetworkInterfaces = { + en0: [ + { + address: "192.168.1.20", + family: "IPv4", + internal: false, + }, + ], +}; + +const tailnetNetworkInterfaces: DesktopNetworkInterfaces = { + tailscale0: [ + { + address: "100.90.1.2", + family: "IPv4", + internal: false, + }, + ], +}; + +function mockSpawnerLayer(statusJson = "{}") { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(statusJson)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ), + ); +} + +function makeEnvironmentLayer(baseDir: string, env: Record = {}) { + return makeDesktopEnvironmentLayer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir, ...env })), + ), + ); +} + +function makeLayer(input: { + readonly baseDir: string; + readonly networkInterfaces?: DesktopNetworkInterfaces; + readonly env?: Record; +}) { + const env = { T3CODE_HOME: input.baseDir, ...input.env }; + const environmentLayer = makeEnvironmentLayer(input.baseDir, env); + const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, { + read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces), + }); + + return DesktopServerExposure.layer.pipe( + Layer.provideMerge(DesktopAppSettings.layer), + Layer.provideMerge(NodeFileSystem.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(mockSpawnerLayer()), + Layer.provideMerge(networkLayer), + Layer.provideMerge(DesktopConfig.layerTest(env)), + Layer.provideMerge(environmentLayer), + ); +} + +const withHarness = ( + networkInterfaces: DesktopNetworkInterfaces, + effect: Effect.Effect< + A, + E, + | R + | DesktopEnvironment + | FileSystem.FileSystem + | DesktopServerExposure.DesktopServerExposure + | DesktopAppSettings.DesktopAppSettings + >, + env: Record = {}, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-server-exposure-test-", + }); + return yield* effect.pipe(Effect.provide(makeLayer({ baseDir, networkInterfaces, env }))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("DesktopServerExposure", () => { + it.effect("falls back to local-only without losing the requested network preference", () => + withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.setServerExposureMode("network-accessible"); + + const state = yield* serverExposure.configureFromSettings({ port: 4173 }); + assert.equal(state.mode, "local-only"); + assert.equal(state.endpointUrl, null); + assert.equal((yield* settings.get).serverExposureMode, "network-accessible"); + + const backendConfig = yield* serverExposure.backendConfig; + assert.equal(backendConfig.bindHost, "127.0.0.1"); + assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); + }), + ), + ); + + it.effect("returns a typed error when network access is explicitly unavailable", () => + withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); + assert.ok(error._tag === "DesktopServerExposureNoNetworkAddressError"); + assert.equal(error.port, 4173); + }), + ), + ); + + it.effect("persists network-accessible mode and updates backend binding state", () => + withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.load; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const change = yield* serverExposure.setMode("network-accessible"); + assert.equal(change.requiresRelaunch, true); + assert.deepEqual(change.state, { + mode: "network-accessible", + endpointUrl: "http://192.168.1.20:4173", + advertisedHost: "192.168.1.20", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }); + + const backendConfig = yield* serverExposure.backendConfig; + assert.equal(backendConfig.bindHost, "0.0.0.0"); + assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); + + const persisted = yield* settings.get; + assert.equal(persisted.serverExposureMode, "network-accessible"); + }), + ), + ); + + it.effect("persists tailscale serve preferences atomically and reports no-op updates", () => + withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.load; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const changed = yield* serverExposure.setTailscaleServeEnabled({ + enabled: true, + port: 8443, + }); + assert.equal(changed.requiresRelaunch, true); + assert.equal(changed.state.tailscaleServeEnabled, true); + assert.equal(changed.state.tailscaleServePort, 8443); + + const unchanged = yield* serverExposure.setTailscaleServeEnabled({ + enabled: true, + port: 8443, + }); + assert.equal(unchanged.requiresRelaunch, false); + + const persisted = yield* settings.get; + assert.equal(persisted.tailscaleServeEnabled, true); + assert.equal(persisted.tailscaleServePort, 8443); + }), + ), + ); + + it.effect("resolves advertised endpoints from the scoped runtime state", () => + withHarness( + { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.setMode("network-accessible"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/", "http://100.90.1.2:4173/"], + ); + }), + ), + ); + + it.effect("uses ConfigProvider desktop exposure overrides", () => + withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + const change = yield* serverExposure.setMode("network-accessible"); + + assert.equal(change.state.advertisedHost, "10.0.0.7"); + assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"], + ); + }), + { + T3CODE_DESKTOP_LAN_HOST: "10.0.0.7", + T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test", + }, + ), + ); + + it.effect("advertises loopback, LAN, and configured manual endpoints from runtime state", () => + withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 3773 }); + yield* serverExposure.setMode("network-accessible"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual(endpoints, [ + { + id: "desktop-loopback:3773", + label: "This machine", + provider: { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, + }, + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + reachability: "loopback", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + description: "Loopback endpoint for this desktop app.", + }, + { + id: "desktop-lan:http://192.168.1.20:3773", + label: "Local network", + provider: { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, + }, + httpBaseUrl: "http://192.168.1.20:3773/", + wsBaseUrl: "ws://192.168.1.20:3773/", + reachability: "lan", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + isDefault: true, + description: "Reachable from devices on the same network.", + }, + { + id: "manual:https://desktop.example.ts.net", + label: "Custom HTTPS", + provider: { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, + }, + httpBaseUrl: "https://desktop.example.ts.net/", + wsBaseUrl: "wss://desktop.example.ts.net/", + reachability: "public", + compatibility: { + hostedHttpsApp: "compatible", + desktopApp: "compatible", + }, + source: "user", + status: "unknown", + description: "User-configured HTTPS endpoint for this desktop backend.", + }, + { + id: "manual:http://desktop.example.test:3773", + label: "Custom endpoint", + provider: { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, + }, + httpBaseUrl: "http://desktop.example.test:3773/", + wsBaseUrl: "ws://desktop.example.test:3773/", + reachability: "public", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "user", + status: "unknown", + description: "User-configured endpoint for this desktop backend.", + }, + ]); + }), + { + T3CODE_DESKTOP_HTTPS_ENDPOINTS: + "https://desktop.example.ts.net,http://desktop.example.test:3773,not-a-url", + }, + ), + ); +}); diff --git a/apps/desktop/src/serverExposure/DesktopServerExposure.ts b/apps/desktop/src/serverExposure/DesktopServerExposure.ts new file mode 100644 index 00000000000..b83355368a6 --- /dev/null +++ b/apps/desktop/src/serverExposure/DesktopServerExposure.ts @@ -0,0 +1,548 @@ +import * as NodeOS from "node:os"; + +import { + createAdvertisedEndpoint, + type CreateAdvertisedEndpointInput, +} from "@t3tools/client-runtime"; +import type { + AdvertisedEndpoint, + AdvertisedEndpointProvider, + DesktopServerExposureMode, + DesktopServerExposureState, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; +import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts"; + +export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; +const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; + +export interface DesktopNetworkInterfaceInfo { + readonly address: string; + readonly family: string | number; + readonly internal: boolean; + readonly netmask?: string; + readonly mac?: string; + readonly cidr?: string | null; + readonly scopeid?: number; +} + +export type DesktopNetworkInterfaces = Readonly< + Record +>; + +interface ResolvedDesktopServerExposure { + readonly mode: DesktopServerExposureMode; + readonly bindHost: string; + readonly localHttpUrl: string; + readonly localWsUrl: string; + readonly endpointUrl: string | null; + readonly advertisedHost: string | null; +} + +interface DesktopAdvertisedEndpointInput { + readonly port: number; + readonly exposure: ResolvedDesktopServerExposure; + readonly customHttpsEndpointUrls?: readonly string[]; +} + +const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, +}; + +const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, +}; + +const normalizeOptionalHost = (value: string | undefined): string | undefined => { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? normalized : undefined; +}; + +const isUsableLanIpv4Address = (address: string): boolean => + !address.startsWith("127.") && !address.startsWith("169.254."); + +const isHttpsEndpointUrl = (value: string): boolean => { + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +}; + +const resolveLanAdvertisedHost = ( + networkInterfaces: DesktopNetworkInterfaces, + explicitHost: string | undefined, +): string | null => { + const normalizedExplicitHost = normalizeOptionalHost(explicitHost); + if (normalizedExplicitHost) { + return normalizedExplicitHost; + } + + for (const interfaceAddresses of Object.values(networkInterfaces)) { + if (!interfaceAddresses) continue; + + for (const address of interfaceAddresses) { + if (address.internal) continue; + if (address.family !== "IPv4") continue; + if (!isUsableLanIpv4Address(address.address)) continue; + return address.address; + } + } + + return null; +}; + +const resolveDesktopServerExposure = (input: { + readonly mode: DesktopServerExposureMode; + readonly port: number; + readonly networkInterfaces: DesktopNetworkInterfaces; + readonly advertisedHostOverride?: string; +}): ResolvedDesktopServerExposure => { + const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; + const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; + + if (input.mode === "local-only") { + return { + mode: input.mode, + bindHost: DESKTOP_LOOPBACK_HOST, + localHttpUrl, + localWsUrl, + endpointUrl: null, + advertisedHost: null, + }; + } + + const advertisedHost = resolveLanAdvertisedHost( + input.networkInterfaces, + input.advertisedHostOverride, + ); + + return { + mode: input.mode, + bindHost: DESKTOP_LAN_BIND_HOST, + localHttpUrl, + localWsUrl, + endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null, + advertisedHost, + }; +}; + +const createDesktopEndpoint = ( + input: Omit, +): AdvertisedEndpoint => + createAdvertisedEndpoint({ + ...input, + provider: DESKTOP_CORE_ENDPOINT_PROVIDER, + source: "desktop-core", + }); + +const createManualEndpoint = ( + input: Omit, +): AdvertisedEndpoint => + createAdvertisedEndpoint({ + ...input, + provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER, + source: "user", + }); + +const resolveDesktopCoreAdvertisedEndpoints = ( + input: DesktopAdvertisedEndpointInput, +): readonly AdvertisedEndpoint[] => { + const endpoints: AdvertisedEndpoint[] = [ + createDesktopEndpoint({ + id: `desktop-loopback:${input.port}`, + label: "This machine", + httpBaseUrl: input.exposure.localHttpUrl, + reachability: "loopback", + status: "available", + description: "Loopback endpoint for this desktop app.", + }), + ]; + + if (input.exposure.endpointUrl) { + endpoints.push( + createDesktopEndpoint({ + id: `desktop-lan:${input.exposure.endpointUrl}`, + label: "Local network", + httpBaseUrl: input.exposure.endpointUrl, + reachability: "lan", + status: "available", + isDefault: true, + description: "Reachable from devices on the same network.", + }), + ); + } + + for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) { + try { + const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl); + endpoints.push( + createManualEndpoint({ + id: `manual:${customEndpointUrl}`, + label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint", + httpBaseUrl: customEndpointUrl, + reachability: "public", + ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}), + status: "unknown", + description: isHttpsEndpoint + ? "User-configured HTTPS endpoint for this desktop backend." + : "User-configured endpoint for this desktop backend.", + }), + ); + } catch { + // Ignore malformed user-configured endpoints without dropping valid endpoints. + } + } + + return endpoints; +}; + +type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve"; + +export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError( + "DesktopServerExposureNoNetworkAddressError", +)<{ + readonly port: number; +}> { + override get message() { + return `No reachable network address is available for desktop network access on port ${this.port}.`; + } +} + +export class DesktopServerExposurePersistenceError extends Data.TaggedError( + "DesktopServerExposurePersistenceError", +)<{ + readonly operation: DesktopServerExposurePersistenceOperation; + readonly cause: DesktopAppSettingsService.DesktopSettingsWriteError; +}> { + override get message() { + return `Failed to persist desktop ${this.operation} settings.`; + } +} + +export type DesktopServerExposureSetModeError = + | DesktopServerExposureNoNetworkAddressError + | DesktopServerExposurePersistenceError; + +export type DesktopServerExposureError = DesktopServerExposureSetModeError; + +export interface DesktopServerExposureBackendConfig { + readonly port: number; + readonly bindHost: string; + readonly httpBaseUrl: URL; + readonly tailscaleServeEnabled: boolean; + readonly tailscaleServePort: number; +} + +export interface DesktopServerExposureChange { + readonly state: DesktopServerExposureState; + readonly requiresRelaunch: boolean; +} + +export interface DesktopServerExposureShape { + readonly getState: Effect.Effect; + readonly backendConfig: Effect.Effect; + readonly configureFromSettings: (input: { + readonly port: number; + }) => Effect.Effect; + readonly setMode: ( + mode: DesktopServerExposureMode, + ) => Effect.Effect; + readonly setTailscaleServeEnabled: (input: { + readonly enabled: boolean; + readonly port?: number; + }) => Effect.Effect; + readonly getAdvertisedEndpoints: Effect.Effect; +} + +export class DesktopServerExposure extends Context.Service< + DesktopServerExposure, + DesktopServerExposureShape +>()("t3/desktop/ServerExposure") {} + +export interface DesktopNetworkInterfacesServiceShape { + readonly read: Effect.Effect; +} + +export class DesktopNetworkInterfacesService extends Context.Service< + DesktopNetworkInterfacesService, + DesktopNetworkInterfacesServiceShape +>()("t3/desktop/ServerExposure/NetworkInterfaces") {} + +interface RuntimeState { + readonly requestedMode: DesktopServerExposureMode; + readonly mode: DesktopServerExposureMode; + readonly port: number; + readonly bindHost: string; + readonly localHttpUrl: string; + readonly localWsUrl: string; + readonly httpBaseUrl: URL; + readonly endpointUrl: Option.Option; + readonly advertisedHost: Option.Option; + readonly tailscaleServeEnabled: boolean; + readonly tailscaleServePort: number; +} + +interface ResolvedRuntimeState { + readonly state: RuntimeState; + readonly unavailable: boolean; +} + +const initialRuntimeState = (): RuntimeState => + runtimeStateFromResolvedExposure({ + requestedMode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, + settings: DEFAULT_DESKTOP_SETTINGS, + exposure: resolveDesktopServerExposure({ + mode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, + port: 0, + networkInterfaces: {}, + }), + port: 0, + }); + +const toContractState = (state: RuntimeState): DesktopServerExposureState => ({ + mode: state.mode, + endpointUrl: Option.getOrNull(state.endpointUrl), + advertisedHost: Option.getOrNull(state.advertisedHost), + tailscaleServeEnabled: state.tailscaleServeEnabled, + tailscaleServePort: state.tailscaleServePort, +}); + +const toBackendConfig = (state: RuntimeState): DesktopServerExposureBackendConfig => ({ + port: state.port, + bindHost: state.bindHost, + httpBaseUrl: state.httpBaseUrl, + tailscaleServeEnabled: state.tailscaleServeEnabled, + tailscaleServePort: state.tailscaleServePort, +}); + +const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure => ({ + mode: state.mode, + bindHost: state.bindHost, + localHttpUrl: state.localHttpUrl, + localWsUrl: state.localWsUrl, + endpointUrl: Option.getOrNull(state.endpointUrl), + advertisedHost: Option.getOrNull(state.advertisedHost), +}); + +function runtimeStateFromResolvedExposure(input: { + readonly requestedMode: DesktopServerExposureMode; + readonly settings: DesktopSettings; + readonly exposure: ResolvedDesktopServerExposure; + readonly port: number; +}): RuntimeState { + return { + requestedMode: input.requestedMode, + mode: input.exposure.mode, + port: input.port, + bindHost: input.exposure.bindHost, + localHttpUrl: input.exposure.localHttpUrl, + localWsUrl: input.exposure.localWsUrl, + httpBaseUrl: new URL(input.exposure.localHttpUrl), + endpointUrl: Option.fromNullishOr(input.exposure.endpointUrl), + advertisedHost: Option.fromNullishOr(input.exposure.advertisedHost), + tailscaleServeEnabled: input.settings.tailscaleServeEnabled, + tailscaleServePort: input.settings.tailscaleServePort, + }; +} + +function resolveRuntimeState(input: { + readonly requestedMode: DesktopServerExposureMode; + readonly settings: DesktopSettings; + readonly port: number; + readonly networkInterfaces: DesktopNetworkInterfaces; + readonly advertisedHostOverride: Option.Option; +}): ResolvedRuntimeState { + const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride); + const requestedExposure = resolveDesktopServerExposure({ + mode: input.requestedMode, + port: input.port, + networkInterfaces: input.networkInterfaces, + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), + }); + const unavailable = + input.requestedMode === "network-accessible" && requestedExposure.endpointUrl === null; + const exposure = unavailable + ? resolveDesktopServerExposure({ + mode: "local-only", + port: input.port, + networkInterfaces: input.networkInterfaces, + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), + }) + : requestedExposure; + + return { + state: runtimeStateFromResolvedExposure({ + requestedMode: input.requestedMode, + settings: input.settings, + exposure, + port: input.port, + }), + unavailable, + }; +} + +const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): boolean => + previous.port !== next.port || + previous.bindHost !== next.bindHost || + previous.localHttpUrl !== next.localHttpUrl; + +const make = Effect.gen(function* () { + const config = yield* DesktopConfig.DesktopConfig; + const networkInterfaces = yield* DesktopNetworkInterfacesService; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings; + const stateRef = yield* Ref.make(initialRuntimeState()); + + const readNetworkInterfaces = networkInterfaces.read; + + const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); + const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig)); + + const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")( + function* ({ port }: { readonly port: number }) { + yield* Effect.annotateCurrentSpan({ port }); + const settings = yield* desktopSettings.get; + const currentNetworkInterfaces = yield* readNetworkInterfaces; + const resolved = resolveRuntimeState({ + requestedMode: settings.serverExposureMode, + settings, + port, + networkInterfaces: currentNetworkInterfaces, + advertisedHostOverride: config.desktopLanHostOverride, + }); + yield* Ref.set(stateRef, resolved.state); + return toContractState(resolved.state); + }, + ); + + const setMode = Effect.fn("desktop.serverExposure.setMode")(function* ( + mode: DesktopServerExposureMode, + ) { + yield* Effect.annotateCurrentSpan({ mode }); + const previous = yield* Ref.get(stateRef); + const currentSettings = yield* desktopSettings.get; + const nextSettings = { + ...currentSettings, + serverExposureMode: mode, + }; + const currentNetworkInterfaces = yield* readNetworkInterfaces; + const resolved = resolveRuntimeState({ + requestedMode: mode, + settings: nextSettings, + port: previous.port, + networkInterfaces: currentNetworkInterfaces, + advertisedHostOverride: config.desktopLanHostOverride, + }); + + if (resolved.unavailable) { + return yield* new DesktopServerExposureNoNetworkAddressError({ port: previous.port }); + } + + const change = yield* desktopSettings.setServerExposureMode(mode).pipe( + Effect.mapError( + (cause) => + new DesktopServerExposurePersistenceError({ + operation: "server-exposure-mode", + cause, + }), + ), + ); + + yield* Ref.set(stateRef, resolved.state); + return { + state: toContractState(resolved.state), + requiresRelaunch: change.changed || requiresBackendRelaunch(previous, resolved.state), + }; + }); + + const setTailscaleServeEnabled = Effect.fn("desktop.serverExposure.setTailscaleServeEnabled")( + function* (input: { readonly enabled: boolean; readonly port?: number }) { + yield* Effect.annotateCurrentSpan({ + enabled: input.enabled, + ...(input.port === undefined ? {} : { port: input.port }), + }); + const result = yield* desktopSettings + .setTailscaleServe({ + enabled: input.enabled, + port: Option.fromNullishOr(input.port), + }) + .pipe( + Effect.mapError( + (cause) => + new DesktopServerExposurePersistenceError({ + operation: "tailscale-serve", + cause, + }), + ), + ); + + const nextState = yield* Ref.updateAndGet(stateRef, (current) => ({ + ...current, + tailscaleServeEnabled: result.settings.tailscaleServeEnabled, + tailscaleServePort: result.settings.tailscaleServePort, + })); + + return { + state: toContractState(nextState), + requiresRelaunch: result.changed, + }; + }, + ); + + const getAdvertisedEndpoints = Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + const currentNetworkInterfaces = yield* readNetworkInterfaces; + const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ + port: state.port, + exposure: toResolvedExposure(state), + customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, + }); + const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: state.port, + serveEnabled: state.tailscaleServeEnabled, + servePort: state.tailscaleServePort, + networkInterfaces: currentNetworkInterfaces, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + return [...coreEndpoints, ...tailscaleEndpoints]; + }).pipe(Effect.withSpan("desktop.serverExposure.getAdvertisedEndpoints")); + + return DesktopServerExposure.of({ + getState, + backendConfig, + configureFromSettings, + setMode, + setTailscaleServeEnabled, + getAdvertisedEndpoints, + }); +}); + +export const layer = Layer.effect(DesktopServerExposure, make); + +export const networkInterfacesLayer = Layer.succeed( + DesktopNetworkInterfacesService, + DesktopNetworkInterfacesService.of({ + read: Effect.sync(() => NodeOS.networkInterfaces()), + }), +); diff --git a/apps/desktop/src/serverExposure/tailscaleEndpointProvider.test.ts b/apps/desktop/src/serverExposure/tailscaleEndpointProvider.test.ts new file mode 100644 index 00000000000..612ef3bd73f --- /dev/null +++ b/apps/desktop/src/serverExposure/tailscaleEndpointProvider.test.ts @@ -0,0 +1,142 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + isTailscaleIpv4Address, + parseTailscaleMagicDnsName, + resolveTailscaleAdvertisedEndpoints, +} from "./tailscaleEndpointProvider.ts"; + +const unusedTailscaleExternalServicesLayer = Layer.mergeAll( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected Tailscale HTTPS probe")), + ), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected tailscale status process")), + ), +); + +describe("tailscale endpoint provider", () => { + it("detects Tailnet IPv4 addresses", () => { + assert.equal(isTailscaleIpv4Address("100.64.0.1"), true); + assert.equal(isTailscaleIpv4Address("100.127.255.254"), true); + assert.equal(isTailscaleIpv4Address("100.128.0.1"), false); + assert.equal(isTailscaleIpv4Address("192.168.1.44"), false); + }); + + it.effect("parses MagicDNS names from tailscale status", () => + Effect.gen(function* () { + const dnsName = yield* parseTailscaleMagicDnsName( + `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, + ); + assert.equal(dnsName, "desktop.tail.ts.net"); + assert.equal(yield* parseTailscaleMagicDnsName("{}"), null); + const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json")); + assert.isTrue(malformed._tag === "Failure"); + }), + ); + + it.effect("resolves Tailscale endpoints as add-on advertised endpoints", () => + Effect.gen(function* () { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: { + tailscale0: [ + { + address: "100.100.100.100", + family: "IPv4", + internal: false, + netmask: "255.192.0.0", + cidr: "100.100.100.100/10", + mac: "00:00:00:00:00:00", + }, + ], + }, + statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, + }); + assert.deepEqual(endpoints, [ + { + id: "tailscale-ip:http://100.100.100.100:3773", + label: "Tailscale IP", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "http://100.100.100.100:3773/", + wsBaseUrl: "ws://100.100.100.100:3773/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "available", + description: "Reachable from devices on the same Tailnet.", + }, + { + id: "tailscale-magicdns:https://desktop.tail.ts.net/", + label: "Tailscale HTTPS", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "https://desktop.tail.ts.net/", + wsBaseUrl: "wss://desktop.tail.ts.net/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "requires-configuration", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "unavailable", + description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", + }, + ]); + }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), + ); + + it.effect( + "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", + () => + Effect.gen(function* () { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: {}, + statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, + serveEnabled: true, + probe: () => Effect.succeed(true), + }); + assert.deepEqual(endpoints, [ + { + id: "tailscale-magicdns:https://desktop.tail.ts.net/", + label: "Tailscale HTTPS", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "https://desktop.tail.ts.net/", + wsBaseUrl: "wss://desktop.tail.ts.net/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "compatible", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "available", + description: "HTTPS endpoint served by Tailscale Serve.", + }, + ]); + }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), + ); +}); diff --git a/apps/desktop/src/serverExposure/tailscaleEndpointProvider.ts b/apps/desktop/src/serverExposure/tailscaleEndpointProvider.ts new file mode 100644 index 00000000000..d066f18b968 --- /dev/null +++ b/apps/desktop/src/serverExposure/tailscaleEndpointProvider.ts @@ -0,0 +1,138 @@ +import { createAdvertisedEndpoint } from "@t3tools/client-runtime"; +import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; +import { + buildTailscaleHttpsBaseUrl, + isTailscaleIpv4Address, + parseTailscaleMagicDnsName, + probeTailscaleHttpsEndpoint, + readTailscaleStatus, +} from "@t3tools/tailscale"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; + +export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; + +const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, +}; + +function resolveTailscaleIpAdvertisedEndpoints(input: { + readonly port: number; + readonly networkInterfaces: DesktopNetworkInterfaces; +}): readonly AdvertisedEndpoint[] { + const seen = new Set(); + const endpoints: AdvertisedEndpoint[] = []; + + for (const interfaceAddresses of Object.values(input.networkInterfaces)) { + if (!interfaceAddresses) continue; + + for (const address of interfaceAddresses) { + if (address.internal) continue; + if (address.family !== "IPv4") continue; + if (!isTailscaleIpv4Address(address.address)) continue; + if (seen.has(address.address)) continue; + seen.add(address.address); + + endpoints.push( + createAdvertisedEndpoint({ + provider: TAILSCALE_ENDPOINT_PROVIDER, + source: "desktop-addon", + id: `tailscale-ip:http://${address.address}:${input.port}`, + label: "Tailscale IP", + httpBaseUrl: `http://${address.address}:${input.port}`, + reachability: "private-network", + status: "available", + description: "Reachable from devices on the same Tailnet.", + }), + ); + } + } + + return endpoints; +} + +const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( + "resolveTailscaleMagicDnsAdvertisedEndpoint", +)(function* (input: { + readonly dnsName: string | null; + readonly serveEnabled: boolean; + readonly servePort?: number; + readonly probe?: (baseUrl: string) => Effect.Effect; +}): Effect.fn.Return, never, HttpClient.HttpClient> { + if (!input.dnsName) { + return Option.none(); + } + + const httpBaseUrl = buildTailscaleHttpsBaseUrl({ + magicDnsName: input.dnsName, + ...(input.servePort === undefined ? {} : { servePort: input.servePort }), + }); + const probe = + input.probe?.(httpBaseUrl) ?? + probeTailscaleHttpsEndpoint({ + baseUrl: httpBaseUrl, + }); + const isReachable = input.serveEnabled ? yield* probe : false; + + return Option.some( + createAdvertisedEndpoint({ + provider: TAILSCALE_ENDPOINT_PROVIDER, + source: "desktop-addon", + id: `tailscale-magicdns:${httpBaseUrl}`, + label: "Tailscale HTTPS", + httpBaseUrl, + reachability: "private-network", + hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration", + status: isReachable ? "available" : "unavailable", + description: isReachable + ? "HTTPS endpoint served by Tailscale Serve." + : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", + }), + ); +}); + +export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAdvertisedEndpoints")( + function* (input: { + readonly port: number; + readonly serveEnabled?: boolean; + readonly servePort?: number; + readonly networkInterfaces: DesktopNetworkInterfaces; + readonly statusJson?: string | null; + readonly probe?: (baseUrl: string) => Effect.Effect; + }): Effect.fn.Return< + readonly AdvertisedEndpoint[], + never, + ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient + > { + const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); + const dnsName = + input.statusJson === undefined + ? yield* readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.catch(() => Effect.succeed(null)), + ) + : input.statusJson + ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe( + Effect.catch(() => Effect.succeed(null)), + ) + : null; + const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({ + dnsName, + serveEnabled: input.serveEnabled === true, + ...(input.servePort === undefined ? {} : { servePort: input.servePort }), + ...(input.probe === undefined ? {} : { probe: input.probe }), + }); + + return Option.match(magicDnsEndpoint, { + onNone: () => ipEndpoints, + onSome: (endpoint) => [...ipEndpoints, endpoint], + }); + }, +); diff --git a/apps/desktop/src/serverListeningDetector.test.ts b/apps/desktop/src/serverListeningDetector.test.ts deleted file mode 100644 index fcf9f50ae96..00000000000 --- a/apps/desktop/src/serverListeningDetector.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { ServerListeningDetector } from "./serverListeningDetector.ts"; - -describe("ServerListeningDetector", () => { - it("resolves when the server logs the listening line", async () => { - const detector = new ServerListeningDetector(); - - detector.push("[01:23:30.571] INFO (#148): Listening on http://0.0.0.0:7011\n"); - - await expect(detector.promise).resolves.toBeUndefined(); - }); - - it("resolves when the listening line arrives across multiple chunks", async () => { - const detector = new ServerListeningDetector(); - - detector.push("[01:23:30.571] INFO (#148): Listen"); - detector.push("ing on http://0.0.0.0:7011\n"); - - await expect(detector.promise).resolves.toBeUndefined(); - }); - - it("rejects when the server exits before logging readiness", async () => { - const detector = new ServerListeningDetector(); - const error = new Error("server exited"); - - detector.fail(error); - - await expect(detector.promise).rejects.toBe(error); - }); -}); diff --git a/apps/desktop/src/serverListeningDetector.ts b/apps/desktop/src/serverListeningDetector.ts deleted file mode 100644 index e738aacc38d..00000000000 --- a/apps/desktop/src/serverListeningDetector.ts +++ /dev/null @@ -1,56 +0,0 @@ -const LISTENING_LOG_FRAGMENT = "Listening on http://"; -const MAX_BUFFER_CHARS = 8_192; - -export class ServerListeningDetector { - private buffer = ""; - private settled = false; - private readonly resolvePromise: () => void; - private readonly rejectPromise: (error: unknown) => void; - readonly promise: Promise; - - constructor() { - let resolvePromise: (() => void) | null = null; - let rejectPromise: ((error: unknown) => void) | null = null; - - this.promise = new Promise((resolve, reject) => { - resolvePromise = resolve; - rejectPromise = reject; - }); - - this.resolvePromise = () => { - if (this.settled) { - return; - } - this.settled = true; - resolvePromise?.(); - }; - this.rejectPromise = (error) => { - if (this.settled) { - return; - } - this.settled = true; - rejectPromise?.(error); - }; - } - - push(chunk: unknown): void { - if (this.settled) { - return; - } - - const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); - this.buffer = `${this.buffer}${text.replace(/\r/g, "")}`; - if (this.buffer.includes(LISTENING_LOG_FRAGMENT)) { - this.resolvePromise(); - return; - } - - if (this.buffer.length > MAX_BUFFER_CHARS) { - this.buffer = this.buffer.slice(-MAX_BUFFER_CHARS); - } - } - - fail(error: unknown): void { - this.rejectPromise(error); - } -} diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts new file mode 100644 index 00000000000..db6194cf8f7 --- /dev/null +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -0,0 +1,284 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +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 Schema from "effect/Schema"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { + DEFAULT_DESKTOP_SETTINGS, + resolveDefaultDesktopSettings, + type DesktopSettings as DesktopSettingsValue, +} from "./DesktopAppSettings.ts"; +import * as DesktopAppSettings from "./DesktopAppSettings.ts"; + +const DesktopSettingsPatch = Schema.Struct({ + serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), + tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), + tailscaleServePort: Schema.optionalKey(Schema.Number), + updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])), + updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), +}); + +const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); +const encodeDesktopSettingsPatch = Schema.encodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); + +function makeEnvironmentLayer(baseDir: string, appVersion = "0.0.17") { + return DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion, + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); +} + +const withSettings = ( + effect: Effect.Effect< + A, + E, + R | DesktopAppSettings.DesktopAppSettings | DesktopEnvironment.DesktopEnvironment + >, + options?: { readonly appVersion?: string }, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-settings-test-", + }); + return yield* effect.pipe( + Effect.provide( + DesktopAppSettings.layer.pipe( + Layer.provideMerge(makeEnvironmentLayer(baseDir, options?.appVersion)), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const encoded = yield* encodeDesktopSettingsPatch(patch); + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.desktopSettingsPath, `${encoded}\n`); + }); +} + +describe("DesktopSettings", () => { + it.effect("loads defaults when no settings file exists", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS); + assert.deepEqual(yield* settings.get, DEFAULT_DESKTOP_SETTINGS); + }), + ), + ); + + it("defaults packaged nightly builds to the nightly update channel", () => { + assert.deepEqual(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + } satisfies DesktopSettingsValue); + }); + + it.effect("loads persisted settings and applies semantic updates", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }); + + assert.deepEqual(yield* settings.load, { + serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "latest", + updateChannelConfiguredByUser: true, + } satisfies DesktopSettingsValue); + + const exposure = yield* settings.setServerExposureMode("local-only"); + assert.isTrue(exposure.changed); + assert.equal(exposure.settings.serverExposureMode, "local-only"); + + const tailscale = yield* settings.setTailscaleServe({ + enabled: true, + port: Option.some(9443), + }); + assert.isTrue(tailscale.changed); + assert.equal(tailscale.settings.tailscaleServePort, 9443); + + const updateChannel = yield* settings.setUpdateChannel("nightly"); + assert.isTrue(updateChannel.changed); + assert.equal(updateChannel.settings.updateChannel, "nightly"); + assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true); + }), + ), + ); + + it.effect("does not persist no-op semantic updates", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + const exposure = yield* settings.setServerExposureMode("local-only"); + assert.isFalse(exposure.changed); + + const tailscale = yield* settings.setTailscaleServe({ + enabled: false, + port: Option.none(), + }); + assert.isFalse(tailscale.changed); + + const updateChannel = yield* settings.setUpdateChannel("latest"); + assert.isFalse(updateChannel.changed); + assert.equal(updateChannel.settings.updateChannelConfiguredByUser, false); + }), + ), + ); + + it.effect("falls back to defaults when the settings file is malformed", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.desktopSettingsPath, "{not-json"); + + assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS); + }), + ), + ); + + it.effect("loads lenient persisted desktop settings JSON", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.desktopSettingsPath, + `{ + // JSONC-style comments and trailing commas match server settings parsing. + "serverExposureMode": "network-accessible", + "tailscaleServeEnabled": true, + "tailscaleServePort": 8443, + }\n`, + ); + + assert.deepEqual(yield* settings.load, { + serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "latest", + updateChannelConfiguredByUser: false, + } satisfies DesktopSettingsValue); + }), + ), + ); + + it.effect("persists sparse desktop settings documents", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.setServerExposureMode("network-accessible"); + + const persisted = yield* decodeDesktopSettingsPatch( + yield* fileSystem.readFileString(environment.desktopSettingsPath), + ); + assert.deepEqual(persisted, { + serverExposureMode: "network-accessible", + } satisfies typeof DesktopSettingsPatch.Type); + }), + ), + ); + + it.effect("migrates legacy implicit update channels to the runtime default", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + serverExposureMode: "local-only", + updateChannel: "latest", + }); + + assert.deepEqual(yield* settings.load, { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + } satisfies DesktopSettingsValue); + }), + { appVersion: "0.0.17-nightly.20260415.1" }, + ), + ); + + it.effect("preserves explicit stable update channel on nightly builds", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }); + + assert.deepEqual(yield* settings.load, { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "latest", + updateChannelConfiguredByUser: true, + } satisfies DesktopSettingsValue); + }), + { appVersion: "0.0.17-nightly.20260415.1" }, + ), + ); + + it.effect("normalizes invalid persisted Tailscale Serve ports", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + tailscaleServeEnabled: true, + tailscaleServePort: 0, + }); + + assert.deepEqual(yield* settings.load, { + serverExposureMode: "local-only", + tailscaleServeEnabled: true, + tailscaleServePort: 443, + updateChannel: "latest", + updateChannelConfiguredByUser: false, + } satisfies DesktopSettingsValue); + }), + ), + ); +}); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts new file mode 100644 index 00000000000..177f05a4b2b --- /dev/null +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -0,0 +1,318 @@ +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 = { -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; + 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; +} + +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 = {}; + + 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.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 { + 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 => + 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)), + }); + }), + ); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts new file mode 100644 index 00000000000..f666e692860 --- /dev/null +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -0,0 +1,185 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; +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 Schema from "effect/Schema"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopClientSettings from "./DesktopClientSettings.ts"; + +const clientSettings: ClientSettings = { + autoOpenPlanSidebar: false, + confirmThreadArchive: true, + confirmThreadDelete: false, + dismissedProviderUpdateNotificationKeys: [], + diffIgnoreWhitespace: true, + diffWordWrap: true, + favorites: [], + providerModelPreferences: {}, + sidebarProjectGroupingMode: "repository_path", + sidebarProjectGroupingOverrides: { + "environment-1:/tmp/project-a": "separate", + }, + sidebarProjectSortOrder: "manual", + sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, + timestampFormat: "24-hour", +}; + +const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema)); +const decodeRecordJson = Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +function makeLayer(baseDir: string) { + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); + + return DesktopClientSettings.layer.pipe( + Layer.provideMerge(environmentLayer), + Layer.provideMerge(NodeServices.layer), + ); +} + +const withClientSettings = ( + effect: Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-client-settings-test-", + }); + return yield* effect.pipe(Effect.provide(makeLayer(baseDir))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("DesktopClientSettings", () => { + it.effect("returns none when no client settings file exists", () => + withClientSettings( + Effect.gen(function* () { + const settings = yield* DesktopClientSettings.DesktopClientSettings; + assert.isTrue(Option.isNone(yield* settings.get)); + }), + ), + ); + + it.effect("persists and reloads client settings", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* settings.set(clientSettings); + + assert.deepEqual(yield* settings.get, Option.some(clientSettings)); + assert.deepEqual( + yield* decodeClientSettingsJson( + yield* fileSystem.readFileString(environment.clientSettingsPath), + ), + clientSettings, + ); + assert.isFalse( + Object.hasOwn( + yield* decodeRecordJson( + yield* fileSystem.readFileString(environment.clientSettingsPath), + ), + "settings", + ), + ); + }), + ), + ); + + it.effect("loads lenient direct client settings documents", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.clientSettingsPath, + `{ + // Matches server settings parsing. + "timestampFormat": "24-hour", + }\n`, + ); + + const persisted = yield* settings.get; + assert.isTrue(Option.isSome(persisted)); + if (Option.isSome(persisted)) { + assert.equal(persisted.value.timestampFormat, "24-hour"); + } + }), + ), + ); + + it.effect("loads legacy wrapped client settings documents", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.clientSettingsPath, + `{ + "settings": { + "timestampFormat": "12-hour" + } + }\n`, + ); + + const persisted = yield* settings.get; + assert.isTrue(Option.isSome(persisted)); + if (Option.isSome(persisted)) { + assert.equal(persisted.value.timestampFormat, "12-hour"); + } + }), + ), + ); + + it.effect("loads defaults from empty client settings documents", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.clientSettingsPath, "{}\n"); + + assert.deepEqual(yield* settings.get, Option.some(yield* decodeClientSettingsJson("{}"))); + }), + ), + ); + + it.effect("treats malformed client settings documents as absent", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json"); + + assert.isTrue(Option.isNone(yield* settings.get)); + }), + ), + ); +}); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts new file mode 100644 index 00000000000..41f300ecc5a --- /dev/null +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -0,0 +1,118 @@ +import { ClientSettingsSchema, type ClientSettings } 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 Ref from "effect/Ref"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; + +const ClientSettingsDocumentSchema = Schema.Struct({ + settings: ClientSettingsSchema, +}); + +const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); +const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema); +const decodeClientSettingsJson = (raw: string): Effect.Effect => + Schema.decodeEffect(LegacyClientSettingsDocumentJson)(raw).pipe( + Effect.map((document) => document.settings), + Effect.catch(() => Schema.decodeEffect(ClientSettingsJson)(raw)), + ); +const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); + +export class DesktopClientSettingsWriteError extends Data.TaggedError( + "DesktopClientSettingsWriteError", +)<{ + readonly cause: PlatformError.PlatformError | Schema.SchemaError; +}> { + override get message() { + return `Failed to write desktop client settings: ${this.cause.message}`; + } +} + +export interface DesktopClientSettingsShape { + readonly get: Effect.Effect>; + readonly set: (settings: ClientSettings) => Effect.Effect; +} + +export class DesktopClientSettings extends Context.Service< + DesktopClientSettings, + DesktopClientSettingsShape +>()("t3/desktop/ClientSettings") {} + +const readClientSettings = ( + fileSystem: FileSystem.FileSystem, + settingsPath: string, +): Effect.Effect> => + fileSystem.readFileString(settingsPath).pipe( + Effect.option, + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (raw) => + decodeClientSettingsJson(raw).pipe( + Effect.map((settings) => Option.some(settings)), + Effect.catch(() => Effect.succeed(Option.none())), + ), + }), + ), + ); + +const writeClientSettings = Effect.fnUntraced(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly settingsPath: string; + readonly settings: ClientSettings; +}): Effect.fn.Return { + 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* encodeClientSettingsJson(input.settings); + 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( + DesktopClientSettings, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + return DesktopClientSettings.of({ + get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( + Effect.withSpan("desktop.clientSettings.get"), + ), + set: (settings) => + writeClientSettings({ + fileSystem, + path, + settingsPath: environment.clientSettingsPath, + settings, + }).pipe( + Effect.mapError((cause) => new DesktopClientSettingsWriteError({ cause })), + Effect.withSpan("desktop.clientSettings.set"), + ), + }); + }), +); + +export const layerTest = (initialSettings: Option.Option = Option.none()) => + Layer.effect( + DesktopClientSettings, + Effect.gen(function* () { + const settingsRef = yield* Ref.make(initialSettings); + return DesktopClientSettings.of({ + get: Ref.get(settingsRef), + set: (settings) => Ref.set(settingsRef, Option.some(settings)), + }); + }), + ); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts new file mode 100644 index 00000000000..d1d37b96e11 --- /dev/null +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -0,0 +1,344 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +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 Schema from "effect/Schema"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as DesktopSavedEnvironments from "./DesktopSavedEnvironments.ts"; + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +const savedRegistryRecord: PersistedSavedEnvironmentRecord = { + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + createdAt: "2026-04-09T00:00:00.000Z", + lastConnectedAt: "2026-04-09T01:00:00.000Z", + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, +}; + +const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({ + version: Schema.Number, + records: Schema.Array(Schema.Unknown), +}); +const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect( + Schema.fromJsonString(SavedEnvironmentRegistryDocumentProbe), +); + +function makeSafeStorageLayer(input: { + readonly available: boolean; + readonly availabilityError?: unknown; + readonly encryptError?: unknown; + readonly decryptError?: unknown; +}) { + return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { + isEncryptionAvailable: + input.availabilityError === undefined + ? Effect.succeed(input.available) + : Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageAvailabilityError({ + cause: input.availabilityError, + }), + ), + encryptString: (value) => + input.encryptError === undefined + ? Effect.succeed(textEncoder.encode(`enc:${value}`)) + : Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageEncryptError({ + cause: input.encryptError, + }), + ), + decryptString: (value) => { + if (input.decryptError !== undefined) { + return Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + cause: input.decryptError, + }), + ); + } + + const decoded = textDecoder.decode(value); + if (!decoded.startsWith("enc:")) { + return Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + cause: new Error("invalid secret"), + }), + ); + } + return Effect.succeed(decoded.slice("enc:".length)); + }, + } satisfies ElectronSafeStorage.ElectronSafeStorageShape); +} + +function makeLayer( + baseDir: string, + options?: { + readonly availableSecretStorage?: boolean; + readonly availabilityError?: unknown; + readonly encryptError?: unknown; + readonly decryptError?: unknown; + }, +) { + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); + + return DesktopSavedEnvironments.layer.pipe( + Layer.provideMerge(environmentLayer), + Layer.provideMerge( + makeSafeStorageLayer({ + available: options?.availableSecretStorage ?? true, + availabilityError: options?.availabilityError, + encryptError: options?.encryptError, + decryptError: options?.decryptError, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); +} + +const withSavedEnvironments = ( + effect: Effect.Effect, + options?: { + readonly availableSecretStorage?: boolean; + readonly availabilityError?: unknown; + readonly encryptError?: unknown; + readonly decryptError?: unknown; + }, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-saved-environments-test-", + }); + return yield* effect.pipe(Effect.provide(makeLayer(baseDir, options))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("DesktopSavedEnvironments", () => { + it.effect("persists and reloads saved environment metadata", () => + withSavedEnvironments( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + + assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); + const persisted = yield* decodeSavedEnvironmentRegistryDocumentProbe( + yield* fileSystem.readFileString(environment.savedEnvironmentRegistryPath), + ); + assert.equal(persisted.version, 1); + assert.lengthOf(persisted.records, 1); + }), + ), + ); + + it.effect("loads lenient saved environment registry documents", () => + withSavedEnvironments( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.savedEnvironmentRegistryPath, + `{ + // Same optional envelope shape as browser saved environments. + "version": 1, + "records": [ + { + "environmentId": "${savedRegistryRecord.environmentId}", + "label": "Remote environment", + "httpBaseUrl": "https://remote.example.com/", + "wsBaseUrl": "wss://remote.example.com/", + "createdAt": "2026-04-09T00:00:00.000Z", + "lastConnectedAt": "2026-04-09T01:00:00.000Z", + "desktopSsh": { + "alias": "devbox", + "hostname": "devbox.example.com", + "username": "julius", + "port": 22, + }, + }, + ], + }\n`, + ); + + assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); + }), + ), + ); + + it.effect("persists encrypted saved environment secrets when encryption is available", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + + assert.isTrue( + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + }), + ); + + assert.deepEqual( + yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId), + Option.some("bearer-token"), + ); + }), + ), + ); + + it.effect("returns false when writing secrets while encryption is unavailable", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + + assert.isFalse( + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "next-token", + }), + ); + }), + { availableSecretStorage: false }, + ), + ); + + it.effect("surfaces typed safe storage availability failures", () => { + const cause = new Error("safe storage unavailable"); + return withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + + const error = yield* savedEnvironments + .setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "next-token", + }) + .pipe(Effect.flip); + + assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); + assert.equal(error.cause, cause); + }), + { availabilityError: cause }, + ); + }); + + it.effect("removes saved environment secrets", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + }); + + yield* savedEnvironments.removeSecret(savedRegistryRecord.environmentId); + + assert.isTrue( + Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), + ); + }), + ), + ); + + it.effect("treats empty saved environment documents as empty", () => + withSavedEnvironments( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{}\n"); + + assert.deepEqual(yield* savedEnvironments.getRegistry, []); + assert.isTrue( + Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), + ); + }), + ), + ); + + it.effect("treats malformed saved environment documents as empty", () => + withSavedEnvironments( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); + + assert.deepEqual(yield* savedEnvironments.getRegistry, []); + assert.isTrue( + Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), + ); + }), + ), + ); + + it.effect("returns false when writing a secret without metadata", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + + assert.isFalse( + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + }), + ); + }), + ), + ); + + it.effect("preserves encrypted secrets when metadata is rewritten", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + }); + + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + + assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); + assert.deepEqual( + yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId), + Option.some("bearer-token"), + ); + }), + ), + ); +}); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts new file mode 100644 index 00000000000..ec36aa4f6ef --- /dev/null +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -0,0 +1,390 @@ +import { EnvironmentId, type PersistedSavedEnvironmentRecord } 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 Encoding from "effect/Encoding"; +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 Ref from "effect/Ref"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; + +type PersistedSavedEnvironmentDesktopSsh = NonNullable< + PersistedSavedEnvironmentRecord["desktopSsh"] +>; + +interface PersistedSavedEnvironmentStorageRecord extends Omit< + PersistedSavedEnvironmentRecord, + "desktopSsh" +> { + readonly desktopSsh?: PersistedSavedEnvironmentDesktopSsh; + readonly encryptedBearerToken?: string; +} + +interface SavedEnvironmentRegistryDocument { + readonly version: number; + readonly records: readonly PersistedSavedEnvironmentStorageRecord[]; +} + +interface SavedEnvironmentRegistryStorageDocument { + readonly version?: number; + readonly records?: readonly PersistedSavedEnvironmentStorageRecord[]; +} + +const DesktopSshTargetSchema = Schema.Struct({ + alias: Schema.String, + hostname: Schema.String, + username: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Number), +}); + +const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + createdAt: Schema.String, + lastConnectedAt: Schema.NullOr(Schema.String), + desktopSsh: Schema.optionalKey(DesktopSshTargetSchema), + encryptedBearerToken: Schema.optionalKey(Schema.String), +}); + +const SavedEnvironmentRegistryDocumentSchema = Schema.Struct({ + version: Schema.optionalKey(Schema.Number), + records: Schema.optionalKey(Schema.Array(PersistedSavedEnvironmentStorageRecordSchema)), +}); + +const SavedEnvironmentRegistryDocumentJson = fromLenientJson( + SavedEnvironmentRegistryDocumentSchema, +); +const decodeSavedEnvironmentRegistryDocumentJson = Schema.decodeEffect( + SavedEnvironmentRegistryDocumentJson, +); +const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect( + SavedEnvironmentRegistryDocumentJson, +); + +export class DesktopSavedEnvironmentsWriteError extends Data.TaggedError( + "DesktopSavedEnvironmentsWriteError", +)<{ + readonly cause: PlatformError.PlatformError | Schema.SchemaError; +}> { + override get message() { + return `Failed to write desktop saved environments: ${this.cause.message}`; + } +} + +export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError( + "DesktopSavedEnvironmentSecretDecodeError", +)<{ + readonly cause: Encoding.EncodingError; +}> { + override get message() { + return "Failed to decode desktop saved environment secret."; + } +} + +export type DesktopSavedEnvironmentsGetSecretError = + | DesktopSavedEnvironmentSecretDecodeError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageDecryptError; + +export type DesktopSavedEnvironmentsSetSecretError = + | DesktopSavedEnvironmentsWriteError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageEncryptError; + +export interface DesktopSavedEnvironmentsShape { + readonly getRegistry: Effect.Effect; + readonly setRegistry: ( + records: readonly PersistedSavedEnvironmentRecord[], + ) => Effect.Effect; + readonly getSecret: ( + environmentId: string, + ) => Effect.Effect, DesktopSavedEnvironmentsGetSecretError>; + readonly setSecret: (input: { + readonly environmentId: string; + readonly secret: string; + }) => Effect.Effect; + readonly removeSecret: ( + environmentId: string, + ) => Effect.Effect; +} + +export class DesktopSavedEnvironments extends Context.Service< + DesktopSavedEnvironments, + DesktopSavedEnvironmentsShape +>()("t3/desktop/SavedEnvironments") {} + +function toPersistedSavedEnvironmentRecord( + record: PersistedSavedEnvironmentStorageRecord, +): PersistedSavedEnvironmentRecord { + const nextRecord = { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + }; + return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; +} + +function toSavedEnvironmentStorageRecord( + record: PersistedSavedEnvironmentRecord | PersistedSavedEnvironmentStorageRecord, + encryptedBearerToken: Option.Option, +): PersistedSavedEnvironmentStorageRecord { + const nextRecord = { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + }; + const desktopSsh = record.desktopSsh; + if (desktopSsh) { + return Option.match(encryptedBearerToken, { + onNone: () => ({ ...nextRecord, desktopSsh }), + onSome: (value) => ({ + ...nextRecord, + desktopSsh, + encryptedBearerToken: value, + }), + }); + } + return Option.match(encryptedBearerToken, { + onNone: () => nextRecord, + onSome: (value) => ({ ...nextRecord, encryptedBearerToken: value }), + }); +} + +function normalizeSavedEnvironmentRegistryDocument( + document: SavedEnvironmentRegistryStorageDocument, +): SavedEnvironmentRegistryDocument { + return { + version: document.version ?? 1, + records: document.records ?? [], + }; +} + +function readRegistryDocument( + fileSystem: FileSystem.FileSystem, + registryPath: string, +): Effect.Effect { + return fileSystem.readFileString(registryPath).pipe( + Effect.option, + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed({ version: 1, records: [] }), + onSome: (raw) => + decodeSavedEnvironmentRegistryDocumentJson(raw).pipe( + Effect.map(normalizeSavedEnvironmentRegistryDocument), + Effect.catch(() => Effect.succeed({ version: 1, records: [] })), + ), + }), + ), + ); +} + +const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistryDocument")( + function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly registryPath: string; + readonly document: SavedEnvironmentRegistryDocument; + }): Effect.fn.Return { + const directory = input.path.dirname(input.registryPath); + const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); + const tempPath = `${input.registryPath}.${process.pid}.${suffix}.tmp`; + const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); + yield* input.fileSystem.rename(tempPath, input.registryPath); + }, +); + +function preserveExistingSecrets( + currentDocument: SavedEnvironmentRegistryDocument, + records: readonly PersistedSavedEnvironmentRecord[], +): SavedEnvironmentRegistryDocument { + const encryptedBearerTokenById = new Map( + currentDocument.records.flatMap((record) => + record.encryptedBearerToken + ? [[record.environmentId, record.encryptedBearerToken] as const] + : [], + ), + ); + + return { + version: currentDocument.version, + records: records.map((record) => { + const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId); + return toSavedEnvironmentStorageRecord(record, Option.fromNullishOr(encryptedBearerToken)); + }), + }; +} + +function decodeSecretBytes( + encoded: string, +): Effect.Effect { + return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( + Effect.mapError((cause) => new DesktopSavedEnvironmentSecretDecodeError({ cause })), + ); +} + +export const layer = Layer.effect( + DesktopSavedEnvironments, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; + + const writeDocument = (document: SavedEnvironmentRegistryDocument) => + writeRegistryDocument({ + fileSystem, + path, + registryPath: environment.savedEnvironmentRegistryPath, + document, + }).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); + + return DesktopSavedEnvironments.of({ + getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe( + Effect.map((document) => + document.records.map((record) => toPersistedSavedEnvironmentRecord(record)), + ), + Effect.withSpan("desktop.savedEnvironments.getRegistry"), + ), + setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) { + const currentDocument = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + yield* writeDocument(preserveExistingSecrets(currentDocument, records)); + }), + getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + const encoded = Option.fromNullishOr( + document.records.find((record) => record.environmentId === environmentId) + ?.encryptedBearerToken, + ); + if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) { + return Option.none(); + } + + const secretBytes = yield* decodeSecretBytes(encoded.value); + return Option.some(yield* safeStorage.decryptString(secretBytes)); + }), + setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) { + const { environmentId, secret } = input; + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + + if (!(yield* safeStorage.isEncryptionAvailable)) { + return false; + } + + const encryptedBearerToken = Encoding.encodeBase64( + yield* safeStorage.encryptString(secret), + ); + let found = false; + const nextDocument: SavedEnvironmentRegistryDocument = { + version: document.version, + records: document.records.map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + + found = true; + return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken)); + }), + }; + + if (found) { + yield* writeDocument(nextDocument); + } + return found; + }), + removeSecret: Effect.fn("desktop.savedEnvironments.removeSecret")(function* (environmentId) { + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + if ( + !document.records.some( + (record) => + record.environmentId === environmentId && record.encryptedBearerToken !== undefined, + ) + ) { + return; + } + + yield* writeDocument({ + version: document.version, + records: document.records.map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + return toPersistedSavedEnvironmentRecord(record); + }), + }); + }), + }); + }), +); + +export const layerTest = (input?: { + readonly records?: readonly PersistedSavedEnvironmentRecord[]; + readonly secrets?: ReadonlyMap; +}) => + Layer.effect( + DesktopSavedEnvironments, + Effect.gen(function* () { + const recordsRef = yield* Ref.make(input?.records ?? []); + const secretsRef = yield* Ref.make(new Map(input?.secrets ?? [])); + + return DesktopSavedEnvironments.of({ + getRegistry: Ref.get(recordsRef), + setRegistry: (records) => Ref.set(recordsRef, records), + getSecret: (environmentId) => + Ref.get(secretsRef).pipe( + Effect.map((secrets) => Option.fromNullishOr(secrets.get(environmentId))), + ), + setSecret: ({ environmentId, secret }) => + Ref.get(recordsRef).pipe( + Effect.flatMap((records) => { + if (!records.some((record) => record.environmentId === environmentId)) { + return Effect.succeed(false); + } + return Ref.update(secretsRef, (secrets) => { + const nextSecrets = new Map(secrets); + nextSecrets.set(environmentId, secret); + return nextSecrets; + }).pipe(Effect.as(true)); + }), + ), + removeSecret: (environmentId) => + Ref.update(secretsRef, (secrets) => { + const nextSecrets = new Map(secrets); + nextSecrets.delete(environmentId); + return nextSecrets; + }), + }); + }), + ); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts new file mode 100644 index 00000000000..897e7336a24 --- /dev/null +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -0,0 +1,232 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; + +const textEncoder = new TextEncoder(); + +function envOutput(values: Readonly>): string { + return Object.entries(values) + .flatMap(([name, value]) => [ + `__T3CODE_ENV_${name}_START__`, + value, + `__T3CODE_ENV_${name}_END__`, + ]) + .join("\n"); +} + +function makeProcess(output: string): ChildProcessSpawner.ChildProcessHandle { + const stdout = output.length === 0 ? Stream.empty : Stream.make(textEncoder.encode(output)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout, + stderr: Stream.empty, + all: stdout, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); +} + +function withProcessEnv( + env: NodeJS.ProcessEnv, + effect: Effect.Effect, +): Effect.Effect { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env; + process.env = env; + return previous; + }), + () => effect, + (previous) => + Effect.sync(() => { + process.env = previous; + }), + ); +} + +function runShellEnvironment(input: { + readonly env: NodeJS.ProcessEnv; + readonly platform: NodeJS.Platform; + readonly handler: (command: ChildProcess.Command) => string; +}) { + const environmentLayer = Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of({ + platform: input.platform, + } as DesktopEnvironment.DesktopEnvironmentShape), + ); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => Effect.succeed(makeProcess(input.handler(command)))), + ); + + const program = Effect.gen(function* () { + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; + yield* shellEnvironment.installIntoProcess; + }).pipe( + Effect.provide( + DesktopShellEnvironment.layer.pipe( + Layer.provide(Layer.mergeAll(environmentLayer, spawnerLayer)), + ), + ), + ); + + return withProcessEnv(input.env, program); +} + +describe("DesktopShellEnvironment", () => { + it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/Users/test/.local/bin:/usr/bin", + }; + const commands: ChildProcess.Command[] = []; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: (command) => { + commands.push(command); + return envOutput({ + PATH: "/opt/homebrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + HOMEBREW_PREFIX: "/opt/homebrew", + }); + }, + }); + + assert.equal(commands.length, 1); + assert.equal(commands[0]?._tag === "StandardCommand" ? commands[0].command : "", "/bin/zsh"); + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); + assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); + assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew"); + }), + ); + + it.effect("preserves inherited POSIX values when present", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + SSH_AUTH_SOCK: "/tmp/inherited.sock", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => + envOutput({ + PATH: "/opt/homebrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/login-shell.sock", + }), + }); + + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); + assert.equal(env.SSH_AUTH_SOCK, "/tmp/inherited.sock"); + }), + ); + + it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + + yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => + envOutput({ + PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + }), + }); + + assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin"); + assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); + }), + ); + + it.effect("falls back to launchctl PATH on macOS when shell probing does not return one", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/opt/homebrew/bin/nu", + PATH: "/usr/bin", + }; + const commands: string[] = []; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: (command) => { + if (command._tag !== "StandardCommand") return ""; + commands.push(command.command); + return command.command === "/bin/launchctl" ? "/opt/homebrew/bin:/usr/bin" : ""; + }, + }); + + assert.deepEqual(commands, ["/opt/homebrew/bin/nu", "/bin/zsh", "/bin/launchctl"]); + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); + }), + ); + + it.effect("loads PowerShell profile environment on Windows", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + + yield* runShellEnvironment({ + env, + platform: "win32", + handler: (command) => { + if (command._tag !== "StandardCommand") return ""; + const loadProfile = !command.args.includes("-NoProfile"); + return loadProfile + ? envOutput({ + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + }) + : envOutput({ PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }); + }, + }); + + assert.equal( + env.PATH, + [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + ].join(";"), + ); + assert.equal(env.FNM_DIR, "C:\\Users\\testuser\\AppData\\Roaming\\fnm"); + assert.equal( + env.FNM_MULTISHELL_PATH, + "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + ); + }), + ); +}); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts new file mode 100644 index 00000000000..358729e05ef --- /dev/null +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -0,0 +1,356 @@ +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; + +type EnvironmentPatch = Record; + +interface ShellEnvironmentConfig { + readonly env: NodeJS.ProcessEnv; + readonly platform: NodeJS.Platform; + readonly userShell: Option.Option; +} + +interface WindowsProbeOptions { + readonly loadProfile: boolean; +} + +export interface DesktopShellEnvironmentShape { + readonly installIntoProcess: Effect.Effect; +} + +export class DesktopShellEnvironment extends Context.Service< + DesktopShellEnvironment, + DesktopShellEnvironmentShape +>()("t3/desktop/ShellEnvironment") {} + +const LOGIN_SHELL_ENV_NAMES = [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", +] as const; +const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; +const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; +const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); +const LAUNCHCTL_TIMEOUT = Duration.seconds(2); +const PROCESS_TERMINATE_GRACE = Duration.seconds(1); + +const trimNonEmpty = (value: string | null | undefined): Option.Option => + Option.fromNullishOr(value).pipe( + Option.map((entry) => entry.trim()), + Option.filter((entry) => entry.length > 0), + ); + +const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" : ":"); + +const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option => + trimNonEmpty(env.PATH ?? env.Path ?? env.path); + +const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { + const normalized = entry.trim().replace(/^"+|"+$/g, ""); + return platform === "win32" ? normalized.toLowerCase() : normalized; +}; + +const mergePaths = ( + platform: NodeJS.Platform, + values: ReadonlyArray>, +): Option.Option => { + const delimiter = pathDelimiter(platform); + const entries: string[] = []; + const seen = new Set(); + + for (const value of values) { + if (Option.isNone(value)) continue; + + for (const entry of value.value.split(delimiter)) { + const trimmed = entry.trim(); + if (trimmed.length === 0) continue; + + const key = pathComparisonKey(trimmed, platform); + if (key.length === 0 || seen.has(key)) continue; + + seen.add(key); + entries.push(trimmed); + } + } + + return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none(); +}; + +const listLoginShellCandidates = (config: ShellEnvironmentConfig): ReadonlyArray => { + const fallback = + config.platform === "darwin" ? "/bin/zsh" : config.platform === "linux" ? "/bin/bash" : ""; + const seen = new Set(); + const candidates: string[] = []; + + for (const candidate of [ + trimNonEmpty(config.env.SHELL), + config.userShell, + trimNonEmpty(fallback), + ]) { + if (Option.isNone(candidate) || seen.has(candidate.value)) continue; + seen.add(candidate.value); + candidates.push(candidate.value); + } + + return candidates; +}; + +const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ + ...trimNonEmpty(env.APPDATA).pipe( + Option.match({ + onNone: () => [], + onSome: (value) => [`${value}\\npm`], + }), + ), + ...trimNonEmpty(env.LOCALAPPDATA).pipe( + Option.match({ + onNone: () => [], + onSome: (value) => [`${value}\\Programs\\nodejs`, `${value}\\Volta\\bin`, `${value}\\pnpm`], + }), + ), + ...trimNonEmpty(env.USERPROFILE).pipe( + Option.match({ + onNone: () => [], + onSome: (value) => [`${value}\\.bun\\bin`, `${value}\\scoop\\shims`], + }), + ), +]; + +const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`; +const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`; + +const capturePosixEnvironmentCommand = (names: ReadonlyArray) => + names + .map((name) => { + return [ + `printf '%s\\n' '${startMarker(name)}'`, + `printenv ${name} || true`, + `printf '%s\\n' '${endMarker(name)}'`, + ].join("; "); + }) + .join("; "); + +const captureWindowsEnvironmentCommand = (names: ReadonlyArray) => + [ + "$ErrorActionPreference = 'Stop'", + ...names.flatMap((name) => { + return [ + `Write-Output '${startMarker(name)}'`, + `$value = [Environment]::GetEnvironmentVariable('${name}')`, + "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", + `Write-Output '${endMarker(name)}'`, + ]; + }), + ].join("; "); + +const extractEnvironment = (output: string, names: ReadonlyArray): EnvironmentPatch => { + const environment: EnvironmentPatch = {}; + + for (const name of names) { + const start = output.indexOf(startMarker(name)); + if (start === -1) continue; + + const valueStart = start + startMarker(name).length; + const end = output.indexOf(endMarker(name), valueStart); + if (end === -1) continue; + + const value = output + .slice(valueStart, end) + .replace(/^\r?\n/, "") + .replace(/\r?\n$/, ""); + if (value.length > 0) { + environment[name] = value; + } + } + + return environment; +}; + +const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: { + readonly command: string; + readonly args: ReadonlyArray; + readonly timeout: Duration.Duration; + readonly shell?: boolean; +}): Effect.fn.Return { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner + .string( + ChildProcess.make(input.command, input.args, { + shell: input.shell ?? false, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }), + ) + .pipe( + Effect.timeoutOption(input.timeout), + Effect.map(Option.getOrElse(() => "")), + Effect.catch(() => Effect.succeed("")), + ); +}); + +const readLoginShellEnvironment = ( + shell: string, + names: ReadonlyArray, +): Effect.Effect => + names.length === 0 + ? Effect.succeed({}) + : runCommandOutput({ + command: shell, + args: ["-ilc", capturePosixEnvironmentCommand(names)], + timeout: LOGIN_SHELL_TIMEOUT, + }).pipe(Effect.map((output) => extractEnvironment(output, names))); + +const readLaunchctlPath: Effect.Effect< + Option.Option, + never, + ChildProcessSpawner.ChildProcessSpawner +> = runCommandOutput({ + command: "/bin/launchctl", + args: ["getenv", "PATH"], + timeout: LAUNCHCTL_TIMEOUT, +}).pipe(Effect.map(trimNonEmpty)); + +const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEnvironment")( + function* ( + names: ReadonlyArray, + options: WindowsProbeOptions, + ): Effect.fn.Return { + if (names.length === 0) return {}; + + const args = [ + "-NoLogo", + ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), + "-NonInteractive", + "-Command", + captureWindowsEnvironmentCommand(names), + ]; + + for (const command of WINDOWS_SHELL_CANDIDATES) { + const output = yield* runCommandOutput({ + command, + args, + shell: true, + timeout: LOGIN_SHELL_TIMEOUT, + }); + const environment = extractEnvironment(output, names); + if (Object.keys(environment).length > 0) { + return environment; + } + } + + return {}; + }, +); + +const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWindowsEnvironment")( + function* ( + config: ShellEnvironmentConfig, + ): Effect.fn.Return { + const noProfile = yield* readWindowsEnvironment(["PATH"], { loadProfile: false }); + const profile = yield* readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { + loadProfile: true, + }); + const mergedPath = mergePaths("win32", [ + trimNonEmpty(profile.PATH), + trimNonEmpty(knownWindowsCliDirs(config.env).join(";")), + trimNonEmpty(noProfile.PATH), + readEnvPath(config.env), + ]); + + if (Option.isSome(mergedPath)) { + config.env.PATH = mergedPath.value; + } + if (!config.env.FNM_DIR && profile.FNM_DIR) { + config.env.FNM_DIR = profile.FNM_DIR; + } + if (!config.env.FNM_MULTISHELL_PATH && profile.FNM_MULTISHELL_PATH) { + config.env.FNM_MULTISHELL_PATH = profile.FNM_MULTISHELL_PATH; + } + }, +); + +const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")( + function* ( + config: ShellEnvironmentConfig, + ): Effect.fn.Return { + const shellEnvironment: EnvironmentPatch = {}; + + for (const shell of listLoginShellCandidates(config)) { + Object.assign( + shellEnvironment, + yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES), + ); + if (shellEnvironment.PATH) break; + } + + const launchctlPath = + config.platform === "darwin" && !shellEnvironment.PATH + ? yield* readLaunchctlPath + : Option.none(); + const mergedPath = mergePaths(config.platform, [ + trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), + readEnvPath(config.env), + ]); + + if (Option.isSome(mergedPath)) { + config.env.PATH = mergedPath.value; + } + if (!config.env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { + config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; + } + + for (const name of [ + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ] as const) { + if (!config.env[name] && shellEnvironment[name]) { + config.env[name] = shellEnvironment[name]; + } + } + }, +); + +const installShellEnvironment = ( + config: ShellEnvironmentConfig, +): Effect.Effect => { + if (config.platform === "win32") { + return installWindowsEnvironment(config); + } + if (config.platform === "darwin" || config.platform === "linux") { + return installPosixEnvironment(config); + } + return Effect.void; +}; + +export const layer = Layer.effect( + DesktopShellEnvironment, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return DesktopShellEnvironment.of({ + installIntoProcess: installShellEnvironment({ + env: process.env, + platform: environment.platform, + userShell: Option.none(), + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), + ), + }); + }), +); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts new file mode 100644 index 00000000000..77c86be39d2 --- /dev/null +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -0,0 +1,118 @@ +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as NetService from "@t3tools/shared/Net"; +import { SshPasswordPromptError } from "@t3tools/ssh/errors"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as DesktopSshEnvironment from "./DesktopSshEnvironment.ts"; +import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; + +function makeTempHomeDir() { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-env-test-" }); + }); +} + +describe("sshEnvironment", () => { + it("treats password prompt timeouts as cancellable authentication prompts", () => { + assert.equal( + DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation( + new SshPasswordPromptError({ + message: "SSH authentication timed out for devbox.", + cause: new DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError({ + requestId: "prompt-1", + destination: "devbox", + }), + }), + ), + true, + ); + }); + + it.effect("wires desktop host discovery through the ssh package runtime", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDir = yield* makeTempHomeDir(); + const sshDir = path.join(homeDir, ".ssh"); + yield* fs.makeDirectory(path.join(sshDir, "config.d"), { recursive: true }); + yield* fs.writeFileString( + path.join(sshDir, "config"), + ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"), + ); + yield* fs.writeFileString( + path.join(sshDir, "config.d", "team.conf"), + [ + "Host staging", + " HostName staging.example.com", + "Host *", + " ServerAliveInterval 30", + "", + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(sshDir, "known_hosts"), + [ + "known.example.com ssh-ed25519 AAAA", + "|1|hashed|entry ssh-ed25519 AAAA", + "[bastion.example.com]:2222 ssh-ed25519 AAAA", + "", + ].join("\n"), + ); + + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + const hosts = yield* sshEnvironment.discoverHosts({ homeDir }); + assert.deepEqual(hosts, [ + { + alias: "bastion.example.com", + hostname: "bastion.example.com", + username: null, + port: null, + source: "known-hosts", + }, + { + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + source: "ssh-config", + }, + { + alias: "known.example.com", + hostname: "known.example.com", + username: null, + port: null, + source: "known-hosts", + }, + { + alias: "staging", + hostname: "staging", + username: null, + port: null, + source: "ssh-config", + }, + ]); + }).pipe( + Effect.provide( + DesktopSshEnvironment.layer().pipe( + Layer.provideMerge( + Layer.succeed(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, { + request: () => Effect.die("unexpected password prompt request"), + resolve: () => Effect.die("unexpected password prompt resolution"), + cancelPending: () => Effect.void, + }), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(NetService.layer), + ), + ), + Effect.scoped, + ), + ); +}); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts new file mode 100644 index 00000000000..98984a417c6 --- /dev/null +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -0,0 +1,150 @@ +import type { + DesktopDiscoveredSshHost, + DesktopSshEnvironmentBootstrap, + DesktopSshEnvironmentTarget, +} from "@t3tools/contracts"; +import { type NetError, NetService } from "@t3tools/shared/Net"; +import { + SshPasswordPrompt, + type SshPasswordPromptShape, + type SshPasswordRequest, +} from "@t3tools/ssh/auth"; +import { discoverSshHosts } from "@t3tools/ssh/config"; +import { + SshCommandError, + SshHostDiscoveryError, + SshInvalidTargetError, + SshLaunchError, + SshPairingError, + SshPasswordPromptError, + SshReadinessError, +} from "@t3tools/ssh/errors"; +import { SshEnvironmentManager, type RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; + +export type DesktopSshEnvironmentRuntimeServices = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | HttpClient.HttpClient + | NetService; + +export type DesktopSshEnvironmentOperationError = + | SshCommandError + | SshInvalidTargetError + | SshLaunchError + | SshPairingError + | SshReadinessError + | SshPasswordPromptError + | NetError; + +export type DesktopSshEnvironmentDiscoverError = SshHostDiscoveryError; + +export type DesktopSshEnvironmentError = + | DesktopSshEnvironmentDiscoverError + | DesktopSshEnvironmentOperationError; + +export interface DesktopSshEnvironmentShape { + readonly discoverHosts: (input?: { + readonly homeDir?: string; + }) => Effect.Effect; + readonly ensureEnvironment: ( + target: DesktopSshEnvironmentTarget, + options?: { readonly issuePairingToken?: boolean }, + ) => Effect.Effect; + readonly disconnectEnvironment: ( + target: DesktopSshEnvironmentTarget, + ) => Effect.Effect; +} + +export class DesktopSshEnvironment extends Context.Service< + DesktopSshEnvironment, + DesktopSshEnvironmentShape +>()("t3/desktop/SshEnvironment") {} + +export interface DesktopSshEnvironmentLayerOptions { + readonly resolveCliPackageSpec?: () => string; + readonly resolveCliRunner?: Effect.Effect; +} + +function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { + return discoverSshHosts(input ?? {}); +} + +export function isDesktopSshPasswordPromptCancellation( + error: unknown, +): error is SshPasswordPromptError { + return ( + error instanceof SshPasswordPromptError && + DesktopSshPasswordPrompts.isDesktopSshPasswordPromptCancellation(error.cause) + ); +} + +const makePasswordPrompt = ( + prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPromptsShape, +): SshPasswordPromptShape => ({ + isAvailable: true, + request: (request: SshPasswordRequest) => + prompts.request(request).pipe( + Effect.mapError( + (cause) => + new SshPasswordPromptError({ + message: cause.message, + cause, + }), + ), + ), +}); + +const make = Effect.gen(function* () { + const manager = yield* SshEnvironmentManager; + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const runtimeContext = yield* Effect.context(); + const passwordPrompt = SshPasswordPrompt.of(makePasswordPrompt(prompts)); + + return DesktopSshEnvironment.of({ + discoverHosts: (input) => + discoverDesktopSshHostsEffect(input).pipe( + Effect.provide(runtimeContext), + Effect.withSpan("desktop.ssh.discoverHosts"), + ), + ensureEnvironment: (target, ensureOptions) => + manager + .ensureEnvironment(target, ensureOptions) + .pipe( + Effect.provideService(SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + Effect.withSpan("desktop.ssh.ensureEnvironment"), + ), + disconnectEnvironment: (target) => + manager + .disconnectEnvironment(target) + .pipe( + Effect.provideService(SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + Effect.withSpan("desktop.ssh.disconnectEnvironment"), + ), + }); +}); + +export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => + Layer.effect(DesktopSshEnvironment, make).pipe( + Layer.provide( + SshEnvironmentManager.layer({ + ...(options.resolveCliPackageSpec === undefined + ? {} + : { resolveCliPackageSpec: options.resolveCliPackageSpec }), + ...(options.resolveCliRunner === undefined + ? {} + : { resolveCliRunner: options.resolveCliRunner }), + }), + ), + ); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts new file mode 100644 index 00000000000..c074241a3a2 --- /dev/null +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -0,0 +1,144 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { TestClock } from "effect/testing"; +import type * as Electron from "electron"; + +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as IpcChannels from "../ipc/channels.ts"; +import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; + +interface SentMessage { + readonly channel: string; + readonly args: readonly unknown[]; +} + +function makeTestWindow() { + const listeners = new Map void>>(); + const sentMessages: SentMessage[] = []; + let destroyed = false; + let minimized = true; + let restored = false; + let focused = false; + + const window = { + isDestroyed: () => destroyed, + isMinimized: () => minimized, + restore: () => { + restored = true; + minimized = false; + }, + focus: () => { + focused = true; + }, + once: (eventName: string, listener: () => void) => { + const eventListeners = listeners.get(eventName) ?? new Set<() => void>(); + eventListeners.add(listener); + listeners.set(eventName, eventListeners); + }, + removeListener: (eventName: string, listener: () => void) => { + listeners.get(eventName)?.delete(listener); + }, + webContents: { + send: (channel: string, ...args: readonly unknown[]) => { + sentMessages.push({ channel, args }); + }, + }, + }; + + return { + window, + sentMessages, + isRestored: () => restored, + isFocused: () => focused, + close: () => { + destroyed = true; + const closedListeners = [...(listeners.get("closed") ?? [])]; + listeners.delete("closed"); + for (const listener of closedListeners) { + listener(); + } + }, + }; +} + +function makeElectronWindowLayer(window: ReturnType["window"]) { + return Layer.succeed( + ElectronWindow.ElectronWindow, + ElectronWindow.ElectronWindow.of({ + create: () => Effect.die("unexpected BrowserWindow creation"), + main: Effect.succeed(Option.some(window as Electron.BrowserWindow)), + currentMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), + focusedMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), + setMain: () => Effect.void, + clearMain: () => Effect.void, + reveal: () => Effect.void, + sendAll: () => Effect.void, + destroyAll: Effect.void, + syncAllAppearance: () => Effect.void, + }), + ); +} + +function makeLayer(window: ReturnType["window"]) { + return DesktopSshPasswordPrompts.layer({ passwordPromptTimeoutMs: 1_000 }).pipe( + Layer.provide(makeElectronWindowLayer(window)), + Layer.provideMerge(TestClock.layer()), + ); +} + +describe("DesktopSshPasswordPrompts", () => { + it.effect("sends renderer prompts and resolves them by request id", () => { + const testWindow = makeTestWindow(); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const fiber = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + assert.equal(testWindow.sentMessages.length, 1); + const sent = testWindow.sentMessages[0]; + assert.ok(sent); + assert.equal(sent.channel, IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL); + const request = sent.args[0] as { readonly requestId: string; readonly destination: string }; + assert.equal(request.destination, "devbox"); + assert.equal(testWindow.isRestored(), true); + assert.equal(testWindow.isFocused(), true); + + yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); + assert.equal(yield* Fiber.join(fiber), "secret"); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); + + it.effect("times out pending renderer prompts with a typed error", () => { + const testWindow = makeTestWindow(); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const fiber = yield* prompts + .request({ + destination: "devbox", + username: null, + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(1_000)); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError); + assert.equal(error.destination, "devbox"); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); +}); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts new file mode 100644 index 00000000000..a53de9fd8e4 --- /dev/null +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -0,0 +1,332 @@ +import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts"; +import { DesktopSshPasswordPromptResolutionInputSchema } from "@t3tools/contracts"; +import type { SshPasswordRequest } from "@t3tools/ssh/auth"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; + +import * as IpcChannels from "../ipc/channels.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; + +const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; +const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication."; + +type DesktopSshPasswordPromptResolutionInput = + typeof DesktopSshPasswordPromptResolutionInputSchema.Type; + +export class DesktopSshPromptUnavailableError extends Data.TaggedError( + "DesktopSshPromptUnavailableError", +)<{ + readonly reason: string; +}> { + override get message() { + return this.reason; + } +} + +export class DesktopSshPromptWindowUnavailableError extends Data.TaggedError( + "DesktopSshPromptWindowUnavailableError", +)<{ + readonly destination: string; +}> { + override get message() { + return WINDOW_UNAVAILABLE_MESSAGE; + } +} + +export class DesktopSshPromptSendError extends Data.TaggedError("DesktopSshPromptSendError")<{ + readonly requestId: string; + readonly destination: string; + readonly cause: unknown; +}> { + override get message() { + return WINDOW_UNAVAILABLE_MESSAGE; + } +} + +export class DesktopSshPromptTimedOutError extends Data.TaggedError( + "DesktopSshPromptTimedOutError", +)<{ + readonly requestId: string; + readonly destination: string; +}> { + override get message() { + return `SSH authentication timed out for ${this.destination}.`; + } +} + +export class DesktopSshPromptCancelledError extends Data.TaggedError( + "DesktopSshPromptCancelledError", +)<{ + readonly requestId: string; + readonly destination: string; + readonly reason: string; +}> { + override get message() { + return this.reason; + } +} + +export class DesktopSshPromptInvalidRequestIdError extends Data.TaggedError( + "DesktopSshPromptInvalidRequestIdError", +)<{ + readonly requestId: string; +}> { + override get message() { + return "Invalid SSH password prompt id."; + } +} + +export class DesktopSshPromptExpiredError extends Data.TaggedError("DesktopSshPromptExpiredError")<{ + readonly requestId: string; +}> { + override get message() { + return "SSH password prompt expired. Try connecting again."; + } +} + +export type DesktopSshPasswordPromptRequestError = + | DesktopSshPromptUnavailableError + | DesktopSshPromptWindowUnavailableError + | DesktopSshPromptSendError + | DesktopSshPromptTimedOutError + | DesktopSshPromptCancelledError; + +export type DesktopSshPasswordPromptResolveError = + | DesktopSshPromptInvalidRequestIdError + | DesktopSshPromptExpiredError; + +export type DesktopSshPasswordPromptError = + | DesktopSshPasswordPromptRequestError + | DesktopSshPasswordPromptResolveError; + +export function isDesktopSshPasswordPromptCancellation( + error: unknown, +): error is DesktopSshPromptCancelledError | DesktopSshPromptTimedOutError { + return ( + error instanceof DesktopSshPromptCancelledError || + error instanceof DesktopSshPromptTimedOutError + ); +} + +export interface DesktopSshPasswordPromptsShape { + readonly request: ( + request: SshPasswordRequest, + ) => Effect.Effect; + readonly resolve: ( + input: DesktopSshPasswordPromptResolutionInput, + ) => Effect.Effect; + readonly cancelPending: (reason: string) => Effect.Effect; +} + +export class DesktopSshPasswordPrompts extends Context.Service< + DesktopSshPasswordPrompts, + DesktopSshPasswordPromptsShape +>()("t3/desktop/SshPasswordPrompts") {} + +interface PendingSshPasswordPrompt { + readonly requestId: string; + readonly destination: string; + readonly deferred: Deferred.Deferred; +} + +interface LayerOptions { + readonly passwordPromptTimeoutMs?: number; +} + +const removePending = ( + pendingRef: Ref.Ref>, + requestId: string, +) => + Ref.modify(pendingRef, (pending) => { + const entry = pending.get(requestId); + if (entry === undefined) { + return [Option.none(), pending] as const; + } + + const nextPending = new Map(pending); + nextPending.delete(requestId); + return [Option.some(entry), nextPending] as const; + }); + +const failPending = ( + pending: PendingSshPasswordPrompt, + error: DesktopSshPasswordPromptRequestError, +) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid); + +const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: LayerOptions = {}) { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const pendingRef = yield* Ref.make(new Map()); + const passwordPromptTimeoutMs = + options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; + + const cancelPending = (reason: string): Effect.Effect => + Ref.getAndSet(pendingRef, new Map()).pipe( + Effect.flatMap((pending) => + Effect.forEach( + pending.values(), + (entry) => + failPending( + entry, + new DesktopSshPromptCancelledError({ + requestId: entry.requestId, + destination: entry.destination, + reason, + }), + ), + { discard: true }, + ), + ), + Effect.asVoid, + ); + + yield* Effect.addFinalizer(() => + cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore), + ); + + const resolve = Effect.fn("desktop.sshPasswordPrompts.resolve")(function* ( + input: DesktopSshPasswordPromptResolutionInput, + ): Effect.fn.Return { + const requestId = input.requestId.trim(); + if (requestId.length === 0) { + return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId }); + } + + const pending = yield* removePending(pendingRef, requestId); + if (Option.isNone(pending)) { + return yield* new DesktopSshPromptExpiredError({ requestId }); + } + + const entry = pending.value; + if (input.password === null) { + yield* failPending( + entry, + new DesktopSshPromptCancelledError({ + requestId, + destination: entry.destination, + reason: `SSH authentication cancelled for ${entry.destination}.`, + }), + ); + return; + } + + yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid); + }); + + const request = Effect.fn("desktop.sshPasswordPrompts.request")(function* ( + input: SshPasswordRequest, + ): Effect.fn.Return { + const window = yield* electronWindow.main; + if (Option.isNone(window) || window.value.isDestroyed()) { + return yield* new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + }); + } + + const requestId = yield* Random.nextUUIDv4; + const now = yield* DateTime.now; + const expiresAt = DateTime.formatIso( + DateTime.add(now, { milliseconds: passwordPromptTimeoutMs }), + ); + const promptRequest: DesktopSshPasswordPromptRequest = { + requestId, + destination: input.destination, + username: input.username, + prompt: input.prompt, + expiresAt, + }; + const deferred = yield* Deferred.make(); + const pending: PendingSshPasswordPrompt = { + requestId, + destination: input.destination, + deferred, + }; + yield* Ref.update(pendingRef, (entries) => new Map(entries).set(requestId, pending)); + + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + + const cancelOnWindowClosed = () => { + runFork( + removePending(pendingRef, requestId).pipe( + Effect.flatMap((entry) => + Option.match(entry, { + onNone: () => Effect.void, + onSome: (pending) => + failPending( + pending, + new DesktopSshPromptCancelledError({ + requestId, + destination: input.destination, + reason: "SSH authentication was cancelled because the app window closed.", + }), + ), + }), + ), + ), + ); + }; + const cleanup = Effect.sync(() => { + if (!window.value.isDestroyed()) { + window.value.removeListener("closed", cancelOnWindowClosed); + } + }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid); + const waitForPassword = Deferred.await(deferred).pipe( + Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new DesktopSshPromptTimedOutError({ + requestId, + destination: input.destination, + }), + ), + onSome: Effect.succeed, + }), + ), + ); + + return yield* Effect.try({ + try: () => { + if (window.value.isDestroyed()) { + throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + } + window.value.once("closed", cancelOnWindowClosed); + window.value.webContents.send(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); + if (window.value.isDestroyed()) { + throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + } + if (window.value.isMinimized()) { + window.value.restore(); + } + if (window.value.isDestroyed()) { + throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + } + window.value.focus(); + }, + catch: (cause) => + new DesktopSshPromptSendError({ + requestId, + destination: input.destination, + cause, + }), + }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup)); + }); + + return DesktopSshPasswordPrompts.of({ + request, + resolve, + cancelPending, + }); +}); + +export const layer = (options: LayerOptions = {}) => + Layer.effect(DesktopSshPasswordPrompts, make(options)); diff --git a/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts b/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts new file mode 100644 index 00000000000..8b6798d38cb --- /dev/null +++ b/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts @@ -0,0 +1,79 @@ +import { assert, describe, it } from "@effect/vitest"; +import { SshHttpBridgeError } from "@t3tools/ssh/errors"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import * as DesktopSshRemoteApi from "./DesktopSshRemoteApi.ts"; + +function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +function makeLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return DesktopSshRemoteApi.layer.pipe( + Layer.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ), + ), + ); +} + +describe("DesktopSshRemoteApi", () => { + it.effect("fetches and decodes the remote environment descriptor", () => { + const requestUrls: string[] = []; + const layer = makeLayer((request) => + Effect.sync(() => { + requestUrls.push(request.url); + return jsonResponse(request, { + environmentId: "remote-env", + label: "Remote Devbox", + platform: { os: "linux", arch: "x64" }, + serverVersion: "1.2.3", + capabilities: { repositoryIdentity: true }, + }); + }), + ); + + return Effect.gen(function* () { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + const descriptor = yield* remoteApi.fetchEnvironmentDescriptor({ + httpBaseUrl: "http://127.0.0.1:41773/", + }); + + assert.equal(descriptor.label, "Remote Devbox"); + assert.deepEqual(requestUrls, ["http://127.0.0.1:41773/.well-known/t3/environment"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("wraps schema decode failures in a typed remote api error", () => { + const layer = makeLayer((request) => + Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })), + ); + + return Effect.gen(function* () { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + const error = yield* remoteApi + .fetchEnvironmentDescriptor({ + httpBaseUrl: "http://127.0.0.1:41773/", + }) + .pipe(Effect.flip); + + assert.instanceOf(error, DesktopSshRemoteApi.DesktopSshRemoteApiError); + assert.equal(error.operation, "fetch-environment-descriptor"); + assert.equal(error.cause instanceof SshHttpBridgeError, false); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/ssh/DesktopSshRemoteApi.ts b/apps/desktop/src/ssh/DesktopSshRemoteApi.ts new file mode 100644 index 00000000000..60184d098a6 --- /dev/null +++ b/apps/desktop/src/ssh/DesktopSshRemoteApi.ts @@ -0,0 +1,124 @@ +import { + AuthBearerBootstrapResult, + AuthSessionState, + AuthWebSocketTokenResult, + type AuthBearerBootstrapResult as AuthBearerBootstrapResultType, + type AuthSessionState as AuthSessionStateType, + type AuthWebSocketTokenResult as AuthWebSocketTokenResultType, + ExecutionEnvironmentDescriptor, + type ExecutionEnvironmentDescriptor as ExecutionEnvironmentDescriptorType, +} from "@t3tools/contracts"; +import { SshHttpBridgeError } from "@t3tools/ssh/errors"; +import { fetchLoopbackSshJson } from "@t3tools/ssh/tunnel"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; + +export type DesktopSshRemoteApiOperation = + | "fetch-environment-descriptor" + | "bootstrap-bearer-session" + | "fetch-session-state" + | "issue-websocket-token"; + +export class DesktopSshRemoteApiError extends Data.TaggedError("DesktopSshRemoteApiError")<{ + readonly operation: DesktopSshRemoteApiOperation; + readonly cause: SshHttpBridgeError | Schema.SchemaError; +}> { + override get message() { + return `SSH remote API request failed during ${this.operation}.`; + } +} + +export interface DesktopSshRemoteApiShape { + readonly fetchEnvironmentDescriptor: (input: { + readonly httpBaseUrl: string; + }) => Effect.Effect; + readonly bootstrapBearerSession: (input: { + readonly httpBaseUrl: string; + readonly credential: string; + }) => Effect.Effect; + readonly fetchSessionState: (input: { + readonly httpBaseUrl: string; + readonly bearerToken: string; + }) => Effect.Effect; + readonly issueWebSocketToken: (input: { + readonly httpBaseUrl: string; + readonly bearerToken: string; + }) => Effect.Effect; +} + +export class DesktopSshRemoteApi extends Context.Service< + DesktopSshRemoteApi, + DesktopSshRemoteApiShape +>()("t3/desktop/SshRemoteApi") {} + +const decodeExecutionEnvironmentDescriptor = Schema.decodeUnknownEffect( + ExecutionEnvironmentDescriptor, +); +const decodeAuthBearerBootstrapResult = Schema.decodeUnknownEffect(AuthBearerBootstrapResult); +const decodeAuthSessionState = Schema.decodeUnknownEffect(AuthSessionState); +const decodeAuthWebSocketTokenResult = Schema.decodeUnknownEffect(AuthWebSocketTokenResult); + +const mapError = + (operation: DesktopSshRemoteApiOperation) => + (cause: SshHttpBridgeError | Schema.SchemaError): DesktopSshRemoteApiError => + new DesktopSshRemoteApiError({ operation, cause }); + +const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const provideHttpClient = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + + return DesktopSshRemoteApi.of({ + fetchEnvironmentDescriptor: ({ httpBaseUrl }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/.well-known/t3/environment", + }).pipe( + Effect.flatMap(decodeExecutionEnvironmentDescriptor), + Effect.mapError(mapError("fetch-environment-descriptor")), + provideHttpClient, + Effect.withSpan("desktop.sshRemoteApi.fetchEnvironmentDescriptor"), + ), + bootstrapBearerSession: ({ httpBaseUrl, credential }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/api/auth/bootstrap/bearer", + method: "POST", + body: { credential }, + }).pipe( + Effect.flatMap(decodeAuthBearerBootstrapResult), + Effect.mapError(mapError("bootstrap-bearer-session")), + provideHttpClient, + Effect.withSpan("desktop.sshRemoteApi.bootstrapBearerSession"), + ), + fetchSessionState: ({ httpBaseUrl, bearerToken }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/api/auth/session", + bearerToken, + }).pipe( + Effect.flatMap(decodeAuthSessionState), + Effect.mapError(mapError("fetch-session-state")), + provideHttpClient, + Effect.withSpan("desktop.sshRemoteApi.fetchSessionState"), + ), + issueWebSocketToken: ({ httpBaseUrl, bearerToken }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/api/auth/ws-token", + method: "POST", + bearerToken, + }).pipe( + Effect.flatMap(decodeAuthWebSocketTokenResult), + Effect.mapError(mapError("issue-websocket-token")), + provideHttpClient, + Effect.withSpan("desktop.sshRemoteApi.issueWebSocketToken"), + ), + }); +}); + +export const layer = Layer.effect(DesktopSshRemoteApi, make); diff --git a/apps/desktop/src/sshEnvironment.test.ts b/apps/desktop/src/sshEnvironment.test.ts deleted file mode 100644 index d22e09957d1..00000000000 --- a/apps/desktop/src/sshEnvironment.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import * as FS from "node:fs"; -import * as OS from "node:os"; -import * as Path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { SshPasswordPromptError } from "@t3tools/ssh/errors"; - -import { discoverDesktopSshHosts, isSshPasswordPromptCancellation } from "./sshEnvironment.ts"; - -const tempDirectories: string[] = []; - -afterEach(() => { - for (const directory of tempDirectories.splice(0)) { - FS.rmSync(directory, { recursive: true, force: true }); - } -}); - -function makeTempHomeDir(): string { - const directory = FS.mkdtempSync(Path.join(OS.tmpdir(), "t3-ssh-env-test-")); - tempDirectories.push(directory); - return directory; -} - -describe("sshEnvironment", () => { - it("treats password prompt timeouts as cancellable authentication prompts", () => { - expect( - isSshPasswordPromptCancellation( - new SshPasswordPromptError({ - message: "SSH authentication timed out for devbox.", - }), - ), - ).toBe(true); - }); - - it("wires desktop host discovery through the ssh package runtime", async () => { - const homeDir = makeTempHomeDir(); - const sshDir = Path.join(homeDir, ".ssh"); - FS.mkdirSync(Path.join(sshDir, "config.d"), { recursive: true }); - FS.writeFileSync( - Path.join(sshDir, "config"), - ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"), - "utf8", - ); - FS.writeFileSync( - Path.join(sshDir, "config.d", "team.conf"), - [ - "Host staging", - " HostName staging.example.com", - "Host *", - " ServerAliveInterval 30", - "", - ].join("\n"), - "utf8", - ); - FS.writeFileSync( - Path.join(sshDir, "known_hosts"), - [ - "known.example.com ssh-ed25519 AAAA", - "|1|hashed|entry ssh-ed25519 AAAA", - "[bastion.example.com]:2222 ssh-ed25519 AAAA", - "", - ].join("\n"), - "utf8", - ); - - await expect(discoverDesktopSshHosts({ homeDir })).resolves.toEqual([ - { - alias: "bastion.example.com", - hostname: "bastion.example.com", - username: null, - port: null, - source: "known-hosts", - }, - { - alias: "devbox", - hostname: "devbox", - username: null, - port: null, - source: "ssh-config", - }, - { - alias: "known.example.com", - hostname: "known.example.com", - username: null, - port: null, - source: "known-hosts", - }, - { - alias: "staging", - hostname: "staging", - username: null, - port: null, - source: "ssh-config", - }, - ]); - }); -}); diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts deleted file mode 100644 index e847e07d498..00000000000 --- a/apps/desktop/src/sshEnvironment.ts +++ /dev/null @@ -1,420 +0,0 @@ -import * as Crypto from "node:crypto"; - -import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { NetService } from "@t3tools/shared/Net"; -import type { - AuthBearerBootstrapResult, - AuthSessionState, - AuthWebSocketTokenResult, - DesktopDiscoveredSshHost, - DesktopSshEnvironmentTarget, - DesktopSshPasswordPromptRequest, - ExecutionEnvironmentDescriptor, -} from "@t3tools/contracts"; -import { - SshPasswordPrompt, - type SshPasswordPromptShape, - type SshPasswordRequest, -} from "@t3tools/ssh/auth"; -import { discoverSshHosts } from "@t3tools/ssh/config"; -import { SshPasswordPromptError } from "@t3tools/ssh/errors"; -import { - fetchLoopbackSshJson, - SshEnvironmentManager, - type RemoteT3RunnerOptions, -} from "@t3tools/ssh/tunnel"; -import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect"; - -export { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; - -const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; -const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; -const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; -const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; -const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; -const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; -const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; -const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; -const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; -const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; -const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; - -interface DesktopSshEnvironmentManagerOptions { - readonly passwordProvider?: (request: SshPasswordRequest) => Promise; - readonly resolveCliPackageSpec?: () => string; - readonly resolveCliRunner?: () => RemoteT3RunnerOptions; -} - -const sshRuntime = ManagedRuntime.make( - Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, NetService.layer), -); - -function createDesktopSshRuntime( - passwordPrompt: SshPasswordPromptShape, - scope: Scope.Scope, - options: DesktopSshEnvironmentManagerOptions, -) { - return ManagedRuntime.make( - Layer.mergeAll( - NodeServices.layer, - NodeHttpClient.layerUndici, - NetService.layer, - Layer.succeed(Scope.Scope, scope), - Layer.succeed(SshPasswordPrompt, SshPasswordPrompt.of(passwordPrompt)), - SshEnvironmentManager.layer({ - ...(options.resolveCliPackageSpec === undefined - ? {} - : { resolveCliPackageSpec: options.resolveCliPackageSpec }), - ...(options.resolveCliRunner === undefined - ? {} - : { resolveCliRunner: options.resolveCliRunner }), - }), - ), - ); -} - -export async function discoverDesktopSshHosts(input?: { - readonly homeDir?: string; -}): Promise { - return await sshRuntime.runPromise(discoverSshHosts(input ?? {})); -} - -export class DesktopSshEnvironmentManager { - private readonly runtime: ReturnType; - private readonly scope: Scope.Scope; - - constructor(options: DesktopSshEnvironmentManagerOptions = {}) { - const passwordPrompt: SshPasswordPromptShape = { - isAvailable: options.passwordProvider !== undefined, - request: (request) => { - const passwordProvider = options.passwordProvider; - if (!passwordProvider) { - return Effect.succeed(null); - } - - return Effect.tryPromise({ - try: () => passwordProvider(request), - catch: (cause) => - new SshPasswordPromptError({ - message: cause instanceof Error ? cause.message : "SSH password prompt failed.", - cause, - }), - }); - }, - }; - this.scope = Effect.runSync(Scope.make()); - this.runtime = createDesktopSshRuntime(passwordPrompt, this.scope, options); - } - - async discoverHosts(): Promise { - return await discoverDesktopSshHosts(); - } - - async ensureEnvironment( - target: DesktopSshEnvironmentTarget, - options?: { readonly issuePairingToken?: boolean }, - ) { - return await this.runtime.runPromise( - Effect.service(SshEnvironmentManager).pipe( - Effect.flatMap((manager) => manager.ensureEnvironment(target, options)), - ), - ); - } - - async disconnectEnvironment(target: DesktopSshEnvironmentTarget): Promise { - await this.runtime.runPromise( - Effect.service(SshEnvironmentManager).pipe( - Effect.flatMap((manager) => manager.disconnectEnvironment(target)), - ), - ); - } - - async dispose(): Promise { - await this.runtime.runPromise(Scope.close(this.scope, Exit.void)); - await this.runtime.dispose(); - } -} - -function getSafeDesktopSshTarget(rawTarget: unknown): DesktopSshEnvironmentTarget | null { - if (typeof rawTarget !== "object" || rawTarget === null) { - return null; - } - - const target = rawTarget as Partial; - if (typeof target.alias !== "string" || typeof target.hostname !== "string") { - return null; - } - if ( - target.username !== null && - target.username !== undefined && - typeof target.username !== "string" - ) { - return null; - } - if (target.port !== null && target.port !== undefined && !Number.isInteger(target.port)) { - return null; - } - - const alias = target.alias.trim(); - const hostname = target.hostname.trim(); - if (alias.length === 0 || hostname.length === 0) { - return null; - } - - return { - alias, - hostname, - username: target.username?.trim() || null, - port: target.port ?? null, - }; -} - -/** Minimal subset of Electron's BrowserWindow used by the SSH bridge. */ -export interface DesktopSshBridgeWindow { - isDestroyed(): boolean; - isMinimized(): boolean; - restore(): void; - focus(): void; - readonly webContents: { - send(channel: string, ...args: readonly unknown[]): void; - }; -} - -/** Minimal subset of Electron's ipcMain used by the SSH bridge. */ -export interface DesktopSshBridgeIpcMain { - removeHandler(channel: string): void; - handle( - channel: string, - listener: (event: unknown, ...args: readonly unknown[]) => unknown | Promise, - ): void; -} - -export interface DesktopSshEnvironmentBridgeOptions { - readonly getMainWindow: () => DesktopSshBridgeWindow | null; - readonly resolveCliPackageSpec?: () => string; - readonly resolveCliRunner?: () => RemoteT3RunnerOptions; - readonly passwordPromptTimeoutMs?: number; -} - -interface PendingSshPasswordPrompt { - readonly resolve: (password: string | null) => void; - readonly reject: (error: Error) => void; - readonly timeout: ReturnType; -} - -export function isSshPasswordPromptCancellation(error: unknown): error is SshPasswordPromptError { - const message = error instanceof SshPasswordPromptError ? error.message.toLowerCase() : ""; - return ( - error instanceof SshPasswordPromptError && - (message.includes("cancelled") || message.includes("timed out")) - ); -} - -/** - * Wires the SSH environment manager to Electron IPC, owning the renderer-facing - * password prompt state so `main.ts` only needs to register, cancel, and dispose. - */ -export class DesktopSshEnvironmentBridge { - private readonly options: DesktopSshEnvironmentBridgeOptions; - private readonly manager: DesktopSshEnvironmentManager; - private readonly pendingPrompts = new Map(); - private readonly passwordPromptTimeoutMs: number; - - constructor(options: DesktopSshEnvironmentBridgeOptions) { - this.options = options; - this.passwordPromptTimeoutMs = - options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; - this.manager = new DesktopSshEnvironmentManager({ - passwordProvider: (request) => this.requestPasswordFromRenderer(request), - ...(options.resolveCliPackageSpec === undefined - ? {} - : { resolveCliPackageSpec: options.resolveCliPackageSpec }), - ...(options.resolveCliRunner === undefined - ? {} - : { resolveCliRunner: options.resolveCliRunner }), - }); - } - - registerIpcHandlers(ipcMain: DesktopSshBridgeIpcMain): void { - ipcMain.removeHandler(DISCOVER_SSH_HOSTS_CHANNEL); - ipcMain.handle(DISCOVER_SSH_HOSTS_CHANNEL, async () => this.manager.discoverHosts()); - - ipcMain.removeHandler(ENSURE_SSH_ENVIRONMENT_CHANNEL); - ipcMain.handle(ENSURE_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget, rawOptions) => { - const target = getSafeDesktopSshTarget(rawTarget); - if (!target) { - throw new Error("Invalid desktop SSH target."); - } - - const issuePairingToken = - typeof rawOptions === "object" && - rawOptions !== null && - "issuePairingToken" in rawOptions && - (rawOptions as { issuePairingToken?: unknown }).issuePairingToken === true; - - try { - return await this.manager.ensureEnvironment(target, { - issuePairingToken, - }); - } catch (error) { - if (isSshPasswordPromptCancellation(error)) { - return { - type: SSH_PASSWORD_PROMPT_CANCELLED_RESULT, - message: error.message, - }; - } - throw error; - } - }); - - ipcMain.removeHandler(DISCONNECT_SSH_ENVIRONMENT_CHANNEL); - ipcMain.handle(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget) => { - const target = getSafeDesktopSshTarget(rawTarget); - if (!target) { - throw new Error("Invalid desktop SSH target."); - } - - await this.manager.disconnectEnvironment(target); - }); - - ipcMain.removeHandler(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL); - ipcMain.handle(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, async (_event, rawHttpBaseUrl) => - sshRuntime.runPromise( - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/.well-known/t3/environment", - }), - ), - ); - - ipcMain.removeHandler(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL); - ipcMain.handle( - BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, - async (_event, rawHttpBaseUrl, rawCredential) => - sshRuntime.runPromise( - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/api/auth/bootstrap/bearer", - method: "POST", - body: { credential: rawCredential }, - }), - ), - ); - - ipcMain.removeHandler(FETCH_SSH_SESSION_STATE_CHANNEL); - ipcMain.handle( - FETCH_SSH_SESSION_STATE_CHANNEL, - async (_event, rawHttpBaseUrl, rawBearerToken) => - sshRuntime.runPromise( - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/api/auth/session", - bearerToken: rawBearerToken, - }), - ), - ); - - ipcMain.removeHandler(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL); - ipcMain.handle( - ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, - async (_event, rawHttpBaseUrl, rawBearerToken) => - sshRuntime.runPromise( - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/api/auth/ws-token", - method: "POST", - bearerToken: rawBearerToken, - }), - ), - ); - - ipcMain.removeHandler(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL); - ipcMain.handle( - RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, - async (_event, rawRequestId, rawPassword) => { - if (typeof rawRequestId !== "string" || rawRequestId.trim().length === 0) { - throw new Error("Invalid SSH password prompt id."); - } - if (rawPassword !== null && typeof rawPassword !== "string") { - throw new Error("Invalid SSH password prompt response."); - } - - const pending = this.pendingPrompts.get(rawRequestId); - if (!pending) { - throw new Error("SSH password prompt expired. Try connecting again."); - } - - clearTimeout(pending.timeout); - this.pendingPrompts.delete(rawRequestId); - pending.resolve(rawPassword); - }, - ); - } - - cancelPendingPasswordPrompts(reason: string): void { - for (const [requestId, pending] of this.pendingPrompts) { - clearTimeout(pending.timeout); - this.pendingPrompts.delete(requestId); - pending.reject(new Error(reason)); - } - } - - async dispose(): Promise { - this.cancelPendingPasswordPrompts("SSH environment bridge disposed."); - await this.manager.dispose(); - } - - private async requestPasswordFromRenderer(input: SshPasswordRequest): Promise { - const window = this.options.getMainWindow(); - if (!window || window.isDestroyed()) { - throw new Error("T3 Code window is not available for SSH authentication."); - } - - const request: DesktopSshPasswordPromptRequest = { - requestId: Crypto.randomUUID(), - destination: input.destination, - username: input.username, - prompt: input.prompt, - expiresAt: new Date(Date.now() + this.passwordPromptTimeoutMs).toISOString(), - }; - - return await new Promise((resolve, reject) => { - const rejectPrompt = (error: Error) => { - clearTimeout(timeout); - this.pendingPrompts.delete(request.requestId); - reject(error); - }; - const timeout = setTimeout(() => { - this.pendingPrompts.delete(request.requestId); - reject(new Error(`SSH authentication timed out for ${input.destination}.`)); - }, this.passwordPromptTimeoutMs); - timeout.unref(); - - this.pendingPrompts.set(request.requestId, { resolve, reject, timeout }); - - try { - if (window.isDestroyed()) { - throw new Error("T3 Code window is not available for SSH authentication."); - } - window.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, request); - if (window.isDestroyed()) { - throw new Error("T3 Code window is not available for SSH authentication."); - } - if (window.isMinimized()) { - window.restore(); - } - if (window.isDestroyed()) { - throw new Error("T3 Code window is not available for SSH authentication."); - } - window.focus(); - } catch (error) { - rejectPrompt( - error instanceof Error - ? error - : new Error("T3 Code window is not available for SSH authentication."), - ); - } - }); - } -} diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts deleted file mode 100644 index 1c13f77256c..00000000000 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { syncShellEnvironment } from "./syncShellEnvironment.ts"; - -describe("syncShellEnvironment", () => { - it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/Users/test/.local/bin:/usr/bin", - }; - const readEnvironment = vi.fn(() => ({ - PATH: "/opt/homebrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/secretive.sock", - HOMEBREW_PREFIX: "/opt/homebrew", - })); - - syncShellEnvironment(env, { - platform: "darwin", - readEnvironment, - }); - - expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ - "PATH", - "SSH_AUTH_SOCK", - "HOMEBREW_PREFIX", - "HOMEBREW_CELLAR", - "HOMEBREW_REPOSITORY", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - ]); - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); - expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); - expect(env.HOMEBREW_PREFIX).toBe("/opt/homebrew"); - }); - - it("preserves an inherited SSH_AUTH_SOCK value", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - SSH_AUTH_SOCK: "/tmp/inherited.sock", - }; - const readEnvironment = vi.fn(() => ({ - PATH: "/opt/homebrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/login-shell.sock", - })); - - syncShellEnvironment(env, { - platform: "darwin", - readEnvironment, - }); - - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); - expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); - }); - - it("preserves inherited values when the login shell omits them", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - SSH_AUTH_SOCK: "/tmp/inherited.sock", - }; - const readEnvironment = vi.fn(() => ({ - PATH: "/opt/homebrew/bin:/usr/bin", - })); - - syncShellEnvironment(env, { - platform: "darwin", - readEnvironment, - }); - - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); - expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); - }); - - it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - }; - const readEnvironment = vi.fn(() => ({ - PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/secretive.sock", - })); - - syncShellEnvironment(env, { - platform: "linux", - readEnvironment, - }); - - expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ - "PATH", - "SSH_AUTH_SOCK", - "HOMEBREW_PREFIX", - "HOMEBREW_CELLAR", - "HOMEBREW_REPOSITORY", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - ]); - expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin"); - expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); - }); - - it("falls back to launchctl PATH on macOS when shell probing does not return one", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/opt/homebrew/bin/nu", - PATH: "/usr/bin", - }; - const readEnvironment = vi - .fn() - .mockImplementationOnce(() => { - throw new Error("unknown flag"); - }) - .mockImplementationOnce(() => ({})); - const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); - const logWarning = vi.fn(); - - syncShellEnvironment(env, { - platform: "darwin", - readEnvironment, - readLaunchctlPath, - userShell: "/bin/zsh", - logWarning, - }); - - expect(readEnvironment).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu", [ - "PATH", - "SSH_AUTH_SOCK", - "HOMEBREW_PREFIX", - "HOMEBREW_CELLAR", - "HOMEBREW_REPOSITORY", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - ]); - expect(readEnvironment).toHaveBeenNthCalledWith(2, "/bin/zsh", [ - "PATH", - "SSH_AUTH_SOCK", - "HOMEBREW_PREFIX", - "HOMEBREW_CELLAR", - "HOMEBREW_REPOSITORY", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - ]); - expect(readLaunchctlPath).toHaveBeenCalledTimes(1); - expect(logWarning).toHaveBeenCalledWith( - "Failed to read login shell environment from /opt/homebrew/bin/nu.", - expect.any(Error), - ); - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); - }); - - it("does nothing on unsupported platforms", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "C:/Program Files/Git/bin/bash.exe", - PATH: "C:\\Windows\\System32", - SSH_AUTH_SOCK: "/tmp/inherited.sock", - }; - const readEnvironment = vi.fn(() => ({ - PATH: "/usr/local/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/secretive.sock", - })); - - syncShellEnvironment(env, { - platform: "freebsd", - readEnvironment, - }); - - expect(readEnvironment).not.toHaveBeenCalled(); - expect(env.PATH).toBe("C:\\Windows\\System32"); - expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); - }); - - it("hydrates PATH on Windows by merging PowerShell PATH with inherited PATH", () => { - const env: NodeJS.ProcessEnv = { - PATH: "C:\\Windows\\System32", - APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", - LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", - USERPROFILE: "C:\\Users\\testuser", - }; - const readWindowsEnvironment = vi.fn(() => ({ - PATH: "C:\\Custom\\Bin;C:\\Windows\\System32", - })); - const isWindowsCommandAvailable = vi.fn(() => true); - - syncShellEnvironment(env, { - platform: "win32", - readWindowsEnvironment, - isWindowsCommandAvailable, - }); - - expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); - expect(env.PATH).toBe( - [ - "C:\\Users\\testuser\\AppData\\Roaming\\npm", - "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", - "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", - "C:\\Users\\testuser\\AppData\\Local\\pnpm", - "C:\\Users\\testuser\\.bun\\bin", - "C:\\Users\\testuser\\scoop\\shims", - "C:\\Custom\\Bin", - "C:\\Windows\\System32", - ].join(";"), - ); - expect(isWindowsCommandAvailable).toHaveBeenCalledTimes(1); - }); - - it("loads the PowerShell profile on Windows when node is not available", () => { - const env: NodeJS.ProcessEnv = { - PATH: "C:\\Windows\\System32", - APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", - LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", - USERPROFILE: "C:\\Users\\testuser", - }; - const readWindowsEnvironment = vi.fn( - (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => - options?.loadProfile - ? { - PATH: "C:\\Profile\\Node;C:\\Windows\\System32", - FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", - FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", - } - : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }, - ); - const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); - - syncShellEnvironment(env, { - platform: "win32", - readWindowsEnvironment, - isWindowsCommandAvailable, - }); - - expect(env.PATH).toBe( - [ - "C:\\Profile\\Node", - "C:\\Windows\\System32", - "C:\\Users\\testuser\\AppData\\Roaming\\npm", - "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", - "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", - "C:\\Users\\testuser\\AppData\\Local\\pnpm", - "C:\\Users\\testuser\\.bun\\bin", - "C:\\Users\\testuser\\scoop\\shims", - "C:\\Custom\\Bin", - ].join(";"), - ); - expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm"); - expect(env.FNM_MULTISHELL_PATH).toBe( - "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", - ); - expect(readWindowsEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); - expect(readWindowsEnvironment).toHaveBeenNthCalledWith( - 2, - ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], - { loadProfile: true }, - ); - }); - - it("preserves baseline Windows env when the profile probe fails", () => { - const env: NodeJS.ProcessEnv = { - PATH: "C:\\Windows\\System32", - APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", - USERPROFILE: "C:\\Users\\testuser", - }; - const readWindowsEnvironment = vi.fn( - (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => { - if (options?.loadProfile) { - throw new Error("profile load failed"); - } - return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; - }, - ); - const isWindowsCommandAvailable = vi.fn(() => false); - - syncShellEnvironment(env, { - platform: "win32", - readWindowsEnvironment, - isWindowsCommandAvailable, - }); - - expect(env.PATH).toBe( - [ - "C:\\Users\\testuser\\AppData\\Roaming\\npm", - "C:\\Users\\testuser\\.bun\\bin", - "C:\\Users\\testuser\\scoop\\shims", - "C:\\Custom\\Bin", - "C:\\Windows\\System32", - ].join(";"), - ); - expect(env.SSH_AUTH_SOCK).toBeUndefined(); - }); -}); diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts deleted file mode 100644 index 373187bda6d..00000000000 --- a/apps/desktop/src/syncShellEnvironment.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { - listLoginShellCandidates, - mergePathEntries, - readPathFromLaunchctl, - readEnvironmentFromLoginShell, - resolveWindowsEnvironment, -} from "@t3tools/shared/shell"; -import type { - CommandAvailabilityOptions, - ShellEnvironmentReader, - WindowsShellEnvironmentReader, -} from "@t3tools/shared/shell"; - -type WindowsCommandAvailabilityChecker = ( - command: string, - options?: CommandAvailabilityOptions, -) => boolean; - -const LOGIN_SHELL_ENV_NAMES = [ - "PATH", - "SSH_AUTH_SOCK", - "HOMEBREW_PREFIX", - "HOMEBREW_CELLAR", - "HOMEBREW_REPOSITORY", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", -] as const; - -function logShellEnvironmentWarning(message: string, error?: unknown): void { - console.warn(`[desktop] ${message}`, error instanceof Error ? error.message : (error ?? "")); -} - -export function syncShellEnvironment( - env: NodeJS.ProcessEnv = process.env, - options: { - platform?: NodeJS.Platform; - readEnvironment?: ShellEnvironmentReader; - readWindowsEnvironment?: WindowsShellEnvironmentReader; - isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; - readLaunchctlPath?: typeof readPathFromLaunchctl; - userShell?: string; - logWarning?: (message: string, error?: unknown) => void; - } = {}, -): void { - const platform = options.platform ?? process.platform; - - const logWarning = options.logWarning ?? logShellEnvironmentWarning; - const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell; - const shellEnvironment: Partial> = {}; - - try { - if (platform === "win32") { - const repairedEnvironment = resolveWindowsEnvironment(env, { - ...(options.readWindowsEnvironment - ? { readEnvironment: options.readWindowsEnvironment } - : {}), - ...(options.isWindowsCommandAvailable - ? { commandAvailable: options.isWindowsCommandAvailable } - : {}), - }); - for (const [key, value] of Object.entries(repairedEnvironment)) { - if (value !== undefined) { - env[key] = value; - } - } - return; - } - - if (platform !== "darwin" && platform !== "linux") return; - - for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { - try { - Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES)); - if (shellEnvironment.PATH) { - break; - } - } catch (error) { - logWarning(`Failed to read login shell environment from ${shell}.`, error); - } - } - - const launchctlPath = - platform === "darwin" && !shellEnvironment.PATH - ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() - : undefined; - const mergedPath = mergePathEntries(shellEnvironment.PATH ?? launchctlPath, env.PATH, platform); - if (mergedPath) { - env.PATH = mergedPath; - } - - if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { - env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; - } - - for (const name of [ - "HOMEBREW_PREFIX", - "HOMEBREW_CELLAR", - "HOMEBREW_REPOSITORY", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - ] as const) { - if (!env[name] && shellEnvironment[name]) { - env[name] = shellEnvironment[name]; - } - } - } catch (error) { - logWarning("Failed to synchronize the desktop shell environment.", error); - } -} diff --git a/apps/desktop/src/tailscaleEndpointProvider.test.ts b/apps/desktop/src/tailscaleEndpointProvider.test.ts deleted file mode 100644 index 2e92b7ee5d3..00000000000 --- a/apps/desktop/src/tailscaleEndpointProvider.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Effect } from "effect"; - -import { - isTailscaleIpv4Address, - parseTailscaleMagicDnsName, - resolveTailscaleAdvertisedEndpoints, -} from "./tailscaleEndpointProvider.ts"; - -describe("tailscale endpoint provider", () => { - it("detects Tailnet IPv4 addresses", () => { - expect(isTailscaleIpv4Address("100.64.0.1")).toBe(true); - expect(isTailscaleIpv4Address("100.127.255.254")).toBe(true); - expect(isTailscaleIpv4Address("100.128.0.1")).toBe(false); - expect(isTailscaleIpv4Address("192.168.1.44")).toBe(false); - }); - - it("parses MagicDNS names from tailscale status", async () => { - expect( - Effect.runSync( - parseTailscaleMagicDnsName(JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } })), - ), - ).toBe("desktop.tail.ts.net"); - expect(Effect.runSync(parseTailscaleMagicDnsName("{}"))).toBeNull(); - await expect(Effect.runPromise(parseTailscaleMagicDnsName("not-json"))).rejects.toBeDefined(); - }); - - it("resolves Tailscale endpoints as add-on advertised endpoints", async () => { - await expect( - resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: { - tailscale0: [ - { - address: "100.100.100.100", - family: "IPv4", - internal: false, - netmask: "255.192.0.0", - cidr: "100.100.100.100/10", - mac: "00:00:00:00:00:00", - }, - ], - }, - statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }), - }), - ).resolves.toEqual([ - { - id: "tailscale-ip:http://100.100.100.100:3773", - label: "Tailscale IP", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "http://100.100.100.100:3773/", - wsBaseUrl: "ws://100.100.100.100:3773/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "available", - description: "Reachable from devices on the same Tailnet.", - }, - { - id: "tailscale-magicdns:https://desktop.tail.ts.net/", - label: "Tailscale HTTPS", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "https://desktop.tail.ts.net/", - wsBaseUrl: "wss://desktop.tail.ts.net/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "requires-configuration", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "unavailable", - description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", - }, - ]); - }); - - it("marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", async () => { - await expect( - resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: {}, - statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }), - serveEnabled: true, - probe: async () => true, - }), - ).resolves.toEqual([ - { - id: "tailscale-magicdns:https://desktop.tail.ts.net/", - label: "Tailscale HTTPS", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "https://desktop.tail.ts.net/", - wsBaseUrl: "wss://desktop.tail.ts.net/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "compatible", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "available", - description: "HTTPS endpoint served by Tailscale Serve.", - }, - ]); - }); -}); diff --git a/apps/desktop/src/tailscaleEndpointProvider.ts b/apps/desktop/src/tailscaleEndpointProvider.ts deleted file mode 100644 index 053eac5d442..00000000000 --- a/apps/desktop/src/tailscaleEndpointProvider.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { NetworkInterfaceInfo } from "node:os"; - -import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { - createAdvertisedEndpoint, - type CreateAdvertisedEndpointInput, -} from "@t3tools/client-runtime"; -import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; -import { - buildTailscaleHttpsBaseUrl, - isTailscaleIpv4Address, - parseTailscaleMagicDnsName, - probeTailscaleHttpsEndpoint, - readTailscaleStatus, -} from "@t3tools/tailscale"; -import { Effect, Layer } from "effect"; - -export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; - -const TailscaleDesktopLayer = Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici); - -const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, -}; - -function createTailscaleEndpoint( - input: Omit, -): AdvertisedEndpoint { - return createAdvertisedEndpoint({ - ...input, - provider: TAILSCALE_ENDPOINT_PROVIDER, - source: "desktop-addon", - }); -} - -export function resolveTailscaleIpAdvertisedEndpoints(input: { - readonly port: number; - readonly networkInterfaces: NodeJS.Dict; -}): readonly AdvertisedEndpoint[] { - const seen = new Set(); - const endpoints: AdvertisedEndpoint[] = []; - - for (const interfaceAddresses of Object.values(input.networkInterfaces)) { - if (!interfaceAddresses) continue; - - for (const address of interfaceAddresses) { - if (address.internal) continue; - if (address.family !== "IPv4") continue; - if (!isTailscaleIpv4Address(address.address)) continue; - if (seen.has(address.address)) continue; - seen.add(address.address); - - endpoints.push( - createTailscaleEndpoint({ - id: `tailscale-ip:http://${address.address}:${input.port}`, - label: "Tailscale IP", - httpBaseUrl: `http://${address.address}:${input.port}`, - reachability: "private-network", - status: "available", - description: "Reachable from devices on the same Tailnet.", - }), - ); - } - } - - return endpoints; -} - -export async function resolveTailscaleMagicDnsAdvertisedEndpoint(input: { - readonly dnsName: string | null; - readonly serveEnabled: boolean; - readonly servePort?: number; - readonly probe?: (baseUrl: string) => Promise; -}): Promise { - if (!input.dnsName) { - return null; - } - - const httpBaseUrl = buildTailscaleHttpsBaseUrl({ - magicDnsName: input.dnsName, - ...(input.servePort === undefined ? {} : { servePort: input.servePort }), - }); - const isReachable = input.serveEnabled - ? await (input.probe?.(httpBaseUrl) ?? - Effect.runPromise( - probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe( - Effect.provide(TailscaleDesktopLayer), - ), - )) - : false; - - return createTailscaleEndpoint({ - id: `tailscale-magicdns:${httpBaseUrl}`, - label: "Tailscale HTTPS", - httpBaseUrl, - reachability: "private-network", - hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration", - status: isReachable ? "available" : "unavailable", - description: isReachable - ? "HTTPS endpoint served by Tailscale Serve." - : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", - }); -} - -export async function resolveTailscaleAdvertisedEndpoints(input: { - readonly port: number; - readonly serveEnabled?: boolean; - readonly servePort?: number; - readonly networkInterfaces: NodeJS.Dict; - readonly statusJson?: string | null; - readonly probe?: (baseUrl: string) => Promise; -}): Promise { - const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); - const dnsName = - input.statusJson === undefined - ? await Effect.runPromise( - readTailscaleStatus.pipe( - Effect.map((status) => status.magicDnsName), - Effect.catch(() => Effect.succeed(null)), - Effect.provide(TailscaleDesktopLayer), - ), - ) - : input.statusJson - ? await Effect.runPromise( - parseTailscaleMagicDnsName(input.statusJson).pipe( - Effect.catch(() => Effect.succeed(null)), - ), - ) - : null; - const magicDnsEndpoint = await resolveTailscaleMagicDnsAdvertisedEndpoint({ - dnsName, - serveEnabled: input.serveEnabled === true, - ...(input.servePort === undefined ? {} : { servePort: input.servePort }), - ...(input.probe === undefined ? {} : { probe: input.probe }), - }); - - return magicDnsEndpoint ? [...ipEndpoints, magicDnsEndpoint] : ipEndpoints; -} diff --git a/apps/desktop/src/updateChannels.test.ts b/apps/desktop/src/updateChannels.test.ts deleted file mode 100644 index f815fbd81cc..00000000000 --- a/apps/desktop/src/updateChannels.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - doesVersionMatchDesktopUpdateChannel, - isNightlyDesktopVersion, - resolveDefaultDesktopUpdateChannel, -} from "./updateChannels.ts"; - -describe("isNightlyDesktopVersion", () => { - it("detects packaged nightly versions", () => { - expect(isNightlyDesktopVersion("0.0.17-nightly.20260415.1")).toBe(true); - }); - - it("does not flag stable versions as nightly", () => { - expect(isNightlyDesktopVersion("0.0.17")).toBe(false); - }); -}); - -describe("resolveDefaultDesktopUpdateChannel", () => { - it("defaults stable builds to latest", () => { - expect(resolveDefaultDesktopUpdateChannel("0.0.17")).toBe("latest"); - }); - - it("defaults nightly builds to nightly", () => { - expect(resolveDefaultDesktopUpdateChannel("0.0.17-nightly.20260415.1")).toBe("nightly"); - }); -}); - -describe("doesVersionMatchDesktopUpdateChannel", () => { - it("accepts nightly releases on the nightly channel", () => { - expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "nightly")).toBe(true); - }); - - it("rejects stable releases on the nightly channel", () => { - expect(doesVersionMatchDesktopUpdateChannel("0.0.17", "nightly")).toBe(false); - }); - - it("rejects nightly releases on the stable channel", () => { - expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "latest")).toBe(false); - }); -}); diff --git a/apps/desktop/src/updateState.test.ts b/apps/desktop/src/updateState.test.ts deleted file mode 100644 index c2bb4ba12dd..00000000000 --- a/apps/desktop/src/updateState.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DesktopUpdateState } from "@t3tools/contracts"; - -import { - getCanRetryAfterDownloadFailure, - getAutoUpdateDisabledReason, - nextStatusAfterDownloadFailure, - shouldBroadcastDownloadProgress, -} from "./updateState.ts"; - -const baseState: DesktopUpdateState = { - enabled: true, - status: "idle", - channel: "latest", - currentVersion: "1.0.0", - hostArch: "x64", - appArch: "x64", - runningUnderArm64Translation: false, - availableVersion: null, - downloadedVersion: null, - downloadPercent: null, - checkedAt: null, - message: null, - errorContext: null, - canRetry: false, -}; - -describe("shouldBroadcastDownloadProgress", () => { - it("broadcasts the first downloading progress update", () => { - expect( - shouldBroadcastDownloadProgress( - { ...baseState, status: "downloading", downloadPercent: null }, - 1, - ), - ).toBe(true); - }); - - it("skips progress updates within the same 10% bucket", () => { - expect( - shouldBroadcastDownloadProgress( - { ...baseState, status: "downloading", downloadPercent: 11.2 }, - 18.7, - ), - ).toBe(false); - }); - - it("broadcasts progress updates when a new 10% bucket is reached", () => { - expect( - shouldBroadcastDownloadProgress( - { ...baseState, status: "downloading", downloadPercent: 19.9 }, - 20.1, - ), - ).toBe(true); - }); - - it("broadcasts progress updates when a retry resets the download percentage", () => { - expect( - shouldBroadcastDownloadProgress( - { ...baseState, status: "downloading", downloadPercent: 50.4 }, - 0.2, - ), - ).toBe(true); - }); -}); - -describe("getAutoUpdateDisabledReason", () => { - it("reports development builds as disabled", () => { - expect( - getAutoUpdateDisabledReason({ - isDevelopment: true, - isPackaged: false, - platform: "darwin", - appImage: undefined, - disabledByEnv: false, - hasUpdateFeedConfig: true, - }), - ).toContain("packaged production builds"); - }); - - it("reports packaged local builds without an update feed as disabled", () => { - expect( - getAutoUpdateDisabledReason({ - isDevelopment: false, - isPackaged: true, - platform: "darwin", - appImage: undefined, - disabledByEnv: false, - hasUpdateFeedConfig: false, - }), - ).toContain("no update feed"); - }); - - it("allows packaged builds with an update feed", () => { - expect( - getAutoUpdateDisabledReason({ - isDevelopment: false, - isPackaged: true, - platform: "darwin", - appImage: undefined, - disabledByEnv: false, - hasUpdateFeedConfig: true, - }), - ).toBeNull(); - }); - - it("reports env-disabled auto updates", () => { - expect( - getAutoUpdateDisabledReason({ - isDevelopment: false, - isPackaged: true, - platform: "darwin", - appImage: undefined, - disabledByEnv: true, - hasUpdateFeedConfig: true, - }), - ).toContain("T3CODE_DISABLE_AUTO_UPDATE"); - }); - - it("reports linux non-AppImage builds as disabled", () => { - expect( - getAutoUpdateDisabledReason({ - isDevelopment: false, - isPackaged: true, - platform: "linux", - appImage: undefined, - disabledByEnv: false, - hasUpdateFeedConfig: true, - }), - ).toContain("AppImage"); - }); -}); - -describe("nextStatusAfterDownloadFailure", () => { - it("returns available when an update version is still known", () => { - expect( - nextStatusAfterDownloadFailure({ - ...baseState, - status: "downloading", - availableVersion: "1.1.0", - }), - ).toBe("available"); - }); - - it("returns error when no update version can be retried", () => { - expect( - nextStatusAfterDownloadFailure({ - ...baseState, - status: "downloading", - availableVersion: null, - }), - ).toBe("error"); - }); -}); - -describe("getCanRetryAfterDownloadFailure", () => { - it("returns true when an available version is still present", () => { - expect( - getCanRetryAfterDownloadFailure({ - ...baseState, - status: "downloading", - availableVersion: "1.1.0", - }), - ).toBe(true); - }); - - it("returns false when no version is available to retry", () => { - expect( - getCanRetryAfterDownloadFailure({ - ...baseState, - status: "downloading", - availableVersion: null, - }), - ).toBe(false); - }); -}); diff --git a/apps/desktop/src/updateState.ts b/apps/desktop/src/updateState.ts deleted file mode 100644 index 928bb408865..00000000000 --- a/apps/desktop/src/updateState.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { DesktopUpdateState } from "@t3tools/contracts"; - -export function shouldBroadcastDownloadProgress( - currentState: DesktopUpdateState, - nextPercent: number, -): boolean { - if (currentState.status !== "downloading") { - return true; - } - - const currentPercent = currentState.downloadPercent; - if (currentPercent === null) { - return true; - } - - const previousStep = Math.floor(currentPercent / 10); - const nextStep = Math.floor(nextPercent / 10); - return nextStep !== previousStep || nextPercent === 100; -} - -export function nextStatusAfterDownloadFailure( - currentState: DesktopUpdateState, -): DesktopUpdateState["status"] { - return currentState.availableVersion ? "available" : "error"; -} - -export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState): boolean { - return currentState.availableVersion !== null; -} - -export function getAutoUpdateDisabledReason(args: { - isDevelopment: boolean; - isPackaged: boolean; - platform: NodeJS.Platform; - appImage?: string | undefined; - disabledByEnv: boolean; - hasUpdateFeedConfig: boolean; -}): string | null { - if (!args.hasUpdateFeedConfig) { - return "Automatic updates are not available because no update feed is configured."; - } - if (args.isDevelopment || !args.isPackaged) { - return "Automatic updates are only available in packaged production builds."; - } - if (args.disabledByEnv) { - return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."; - } - if (args.platform === "linux" && !args.appImage) { - return "Automatic updates on Linux require running the AppImage build."; - } - return null; -} diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts new file mode 100644 index 00000000000..9838fe10747 --- /dev/null +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -0,0 +1,295 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { DesktopUpdateState } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { TestClock } from "effect/testing"; + +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopUpdates from "./DesktopUpdates.ts"; + +interface UpdatesHarnessOptions { + readonly checkForUpdates?: Effect.Effect< + void, + ElectronUpdater.ElectronUpdaterCheckForUpdatesError + >; + readonly env?: Record; +} + +const flushCallbacks = Effect.yieldNow; + +function makeHarness(options: UpdatesHarnessOptions = {}) { + let checkCount = 0; + let allowDowngrade = false; + const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; + const listeners = new Map void>>(); + const sentStates: DesktopUpdateState[] = []; + + const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { + const eventListeners = listeners.get(eventName) ?? new Set(); + eventListeners.add(listener); + listeners.set(eventName, eventListeners); + }; + + const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { + const eventListeners = listeners.get(eventName); + if (!eventListeners) { + return; + } + eventListeners.delete(listener); + if (eventListeners.size === 0) { + listeners.delete(eventName); + } + }; + + const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, { + setFeedURL: (options) => + Effect.sync(() => { + feedUrls.push(options); + }), + setAutoDownload: () => Effect.void, + setAutoInstallOnAppQuit: () => Effect.void, + setChannel: () => Effect.void, + setAllowPrerelease: () => Effect.void, + allowDowngrade: Effect.sync(() => allowDowngrade), + setAllowDowngrade: (value) => + Effect.sync(() => { + allowDowngrade = value; + }), + setDisableDifferentialDownload: () => Effect.void, + checkForUpdates: Effect.sync(() => { + checkCount += 1; + }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), + downloadUpdate: Effect.void, + quitAndInstall: () => Effect.void, + on: (eventName, listener) => + Effect.acquireRelease( + Effect.sync(() => { + addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); + }), + () => + Effect.sync(() => { + removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); + }), + ).pipe(Effect.asVoid), + } satisfies ElectronUpdater.ElectronUpdaterShape); + + const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { + create: () => Effect.die("unexpected BrowserWindow creation"), + main: Effect.succeed(Option.none()), + currentMainOrFirst: Effect.succeed(Option.none()), + focusedMainOrFirst: Effect.succeed(Option.none()), + setMain: () => Effect.void, + clearMain: () => Effect.void, + reveal: () => Effect.void, + sendAll: (_channel, state) => + Effect.sync(() => { + sentStates.push(state as DesktopUpdateState); + }), + destroyAll: Effect.void, + syncAllAppearance: () => Effect.void, + } satisfies ElectronWindow.ElectronWindowShape); + + const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { + start: Effect.void, + stop: () => Effect.void, + currentConfig: Effect.succeed(Option.none()), + snapshot: Effect.succeed({ + desiredRunning: false, + ready: false, + activePid: Option.none(), + restartAttempt: 0, + restartScheduled: false, + }), + }); + + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, + T3CODE_DESKTOP_MOCK_UPDATES: "true", + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", + ...options.env, + }), + ), + ), + ); + + const layer = DesktopUpdates.layer.pipe( + Layer.provideMerge(updaterLayer), + Layer.provideMerge(windowLayer), + Layer.provideMerge(backendLayer), + Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(DesktopAppSettings.layer), + Layer.provideMerge( + DesktopConfig.layerTest({ + T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, + T3CODE_DESKTOP_MOCK_UPDATES: "true", + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", + ...options.env, + }), + ), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return { + layer, + checkCount: () => checkCount, + feedUrls: () => feedUrls, + listenerCount: () => + Array.from(listeners.values()).reduce( + (total, eventListeners) => total + eventListeners.size, + 0, + ), + sentStates, + emit: (eventName: string, payload?: unknown) => { + for (const listener of listeners.get(eventName) ?? []) { + listener(payload); + } + }, + }; +} + +describe("DesktopUpdates", () => { + it.effect("configures the updater and runs startup checks on the test clock", () => { + const harness = makeHarness(); + + return Effect.gen(function* () { + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const state = yield* updates.getState; + assert.equal(state.enabled, true); + assert.equal(state.status, "idle"); + assert.deepEqual(harness.feedUrls(), [ + { provider: "generic", url: "http://localhost:4141" }, + ]); + assert.equal(harness.listenerCount(), 6); + assert.equal(harness.checkCount(), 0); + + yield* TestClock.adjust(Duration.millis(15_000)); + assert.equal(harness.checkCount(), 1); + }), + ); + + assert.equal(harness.listenerCount(), 0); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("updates and broadcasts state from updater events", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.status, "available"); + assert.equal(state.availableVersion, "1.2.4"); + assert.isNotNull(state.checkedAt); + assert.equal(harness.sentStates.at(-1)?.status, "available"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("persists channel changes through the settings service", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const state = yield* updates.setChannel("nightly"); + const persistedSettings = yield* settings.get; + + assert.equal(state.channel, "nightly"); + assert.equal(persistedSettings.updateChannel, "nightly"); + assert.equal(persistedSettings.updateChannelConfiguredByUser, true); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("does not persist an unchanged update channel as a user preference", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const state = yield* updates.setChannel("latest"); + const persistedSettings = yield* settings.get; + + assert.equal(state.channel, "latest"); + assert.equal(persistedSettings.updateChannel, "latest"); + assert.equal(persistedSettings.updateChannelConfiguredByUser, false); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("fails channel changes with a typed error while a check is in progress", () => + Effect.gen(function* () { + const checkStarted = yield* Deferred.make(); + const releaseCheck = yield* Deferred.make(); + const harness = makeHarness({ + checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCheck)), + ), + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped); + yield* Deferred.await(checkStarted); + + const exit = yield* Effect.exit(updates.setChannel("nightly")); + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError); + assert.equal(error.action, "check"); + } + + yield* Deferred.succeed(releaseCheck, undefined); + yield* Fiber.join(checkFiber); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); +}); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts new file mode 100644 index 00000000000..9e052067bee --- /dev/null +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -0,0 +1,664 @@ +import type { + DesktopRuntimeInfo, + DesktopUpdateActionResult, + DesktopUpdateChannel, + DesktopUpdateCheckResult, + DesktopUpdateState, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as IpcChannels from "../ipc/channels.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; +import { + createInitialDesktopUpdateState, + reduceDesktopUpdateStateOnCheckFailure, + reduceDesktopUpdateStateOnCheckStart, + reduceDesktopUpdateStateOnDownloadComplete, + reduceDesktopUpdateStateOnDownloadFailure, + reduceDesktopUpdateStateOnDownloadProgress, + reduceDesktopUpdateStateOnDownloadStart, + reduceDesktopUpdateStateOnInstallFailure, + reduceDesktopUpdateStateOnNoUpdate, + reduceDesktopUpdateStateOnUpdateAvailable, +} from "./updateMachine.ts"; + +const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; +const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; + +const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); +type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; + +const UpdateInfo = Schema.Struct({ + version: Schema.String, +}); + +const DownloadProgressInfo = Schema.Struct({ + percent: Schema.Number, +}); + +const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +export class DesktopUpdateActionInProgressError extends Data.TaggedError( + "DesktopUpdateActionInProgressError", +)<{ + readonly action: "check" | "download" | "install"; +}> { + override get message() { + return `Cannot change update tracks while an update ${this.action} action is in progress.`; + } +} + +export class DesktopUpdatePersistenceError extends Data.TaggedError( + "DesktopUpdatePersistenceError", +)<{ + readonly cause: DesktopAppSettings.DesktopSettingsWriteError; +}> { + override get message() { + return "Failed to persist desktop update settings."; + } +} + +export type DesktopUpdateConfigureError = never; + +export type DesktopUpdateSetChannelError = + | DesktopUpdateActionInProgressError + | DesktopUpdatePersistenceError; + +export interface DesktopUpdatesShape { + readonly getState: Effect.Effect; + readonly emitState: Effect.Effect; + readonly disabledReason: Effect.Effect>; + readonly configure: Effect.Effect; + readonly setChannel: ( + channel: DesktopUpdateChannel, + ) => Effect.Effect; + readonly check: (reason: string) => Effect.Effect; + readonly download: Effect.Effect; + readonly install: Effect.Effect; +} + +export class DesktopUpdates extends Context.Service()( + "t3/desktop/Updates", +) {} + +const { + logInfo: logUpdaterInfo, + logWarning: logUpdaterWarning, + logError: logUpdaterError, +} = DesktopObservability.makeComponentLogger("desktop-updater"); + +function parseAppUpdateYml(raw: string): Effect.Effect> { + const entries: Record = {}; + for (const line of raw.split("\n")) { + const match = line.match(/^(\w+):\s*(.+)$/); + if (match?.[1] && match[2]) { + entries[match[1]] = match[2].trim(); + } + } + + return Schema.decodeUnknownEffect(AppUpdateYmlConfig)(entries).pipe( + Effect.map((config) => (config.provider ? Option.some(config) : Option.none())), + Effect.catch(() => Effect.succeed(Option.none())), + ); +} + +function createBaseUpdateState( + channel: DesktopUpdateChannel, + enabled: boolean, + environment: DesktopEnvironment.DesktopEnvironmentShape, +): DesktopUpdateState { + return { + ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel), + enabled, + status: enabled ? "idle" : "disabled", + }; +} + +function getCanRetryFromState(state: DesktopUpdateState): boolean { + return state.availableVersion !== null || state.downloadedVersion !== null; +} + +function shouldBroadcastDownloadProgress( + currentState: DesktopUpdateState, + nextPercent: number, +): boolean { + if (currentState.status !== "downloading") { + return true; + } + + const currentPercent = currentState.downloadPercent; + if (currentPercent === null) { + return true; + } + + const previousStep = Math.floor(currentPercent / 10); + const nextStep = Math.floor(nextPercent / 10); + return nextStep !== previousStep || nextPercent === 100; +} + +function getAutoUpdateDisabledReason(args: { + isDevelopment: boolean; + isPackaged: boolean; + platform: NodeJS.Platform; + appImage?: string | undefined; + disabledByEnv: boolean; + hasUpdateFeedConfig: boolean; +}): string | null { + if (!args.hasUpdateFeedConfig) { + return "Automatic updates are not available because no update feed is configured."; + } + if (args.isDevelopment || !args.isPackaged) { + return "Automatic updates are only available in packaged production builds."; + } + if (args.disabledByEnv) { + return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."; + } + if (args.platform === "linux" && !args.appImage) { + return "Automatic updates on Linux require running the AppImage build."; + } + return null; +} + +function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean { + return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; +} + +const make = Effect.gen(function* () { + const config = yield* DesktopConfig.DesktopConfig; + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const desktopState = yield* DesktopState.DesktopState; + const electronUpdater = yield* ElectronUpdater.ElectronUpdater; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + + const appUpdateYmlConfigRef = yield* Ref.make>(Option.none()); + const updateCheckInFlightRef = yield* Ref.make(false); + const updateDownloadInFlightRef = yield* Ref.make(false); + const updateInstallInFlightRef = yield* Ref.make(false); + const updaterConfiguredRef = yield* Ref.make(false); + const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); + const updateStateRef = yield* Ref.make( + createInitialDesktopUpdateState( + environment.appVersion, + environment.runtimeInfo, + environment.defaultDesktopSettings.updateChannel, + ), + ); + + const emitState = Ref.get(updateStateRef).pipe( + Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)), + ); + + const setState = (state: DesktopUpdateState): Effect.Effect => + Ref.set(updateStateRef, state).pipe(Effect.andThen(emitState)); + + const updateState = ( + f: (state: DesktopUpdateState) => DesktopUpdateState, + ): Effect.Effect => + Ref.get(updateStateRef).pipe( + Effect.flatMap((state) => { + const nextState = f(state); + return setState(nextState).pipe(Effect.as(nextState)); + }), + ); + + const readAppUpdateYml = fileSystem.readFileString(environment.appUpdateYmlPath, "utf-8").pipe( + Effect.option, + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: parseAppUpdateYml, + }), + ), + ); + + const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe( + Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates), + ); + + const resolveDisabledReason = Effect.gen(function* () { + const hasFeedConfig = yield* hasUpdateFeedConfig; + return Option.fromNullishOr( + getAutoUpdateDisabledReason({ + isDevelopment: environment.isDevelopment, + isPackaged: environment.isPackaged, + platform: environment.platform, + appImage: Option.getOrUndefined(config.appImagePath), + disabledByEnv: config.disableAutoUpdate, + hasUpdateFeedConfig: hasFeedConfig, + }), + ); + }); + + const resolveUpdaterErrorContext = Effect.gen(function* () { + if (yield* Ref.get(updateInstallInFlightRef)) return "install" as const; + if (yield* Ref.get(updateDownloadInFlightRef)) return "download" as const; + if (yield* Ref.get(updateCheckInFlightRef)) return "check" as const; + return (yield* Ref.get(updateStateRef)).errorContext; + }); + + const activeUpdateAction = Effect.gen(function* () { + if (yield* Ref.get(updateInstallInFlightRef)) return Option.some("install" as const); + if (yield* Ref.get(updateDownloadInFlightRef)) return Option.some("download" as const); + if (yield* Ref.get(updateCheckInFlightRef)) return Option.some("check" as const); + return Option.none<"check" | "download" | "install">(); + }); + + const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* ( + channel: DesktopUpdateChannel, + ) { + yield* Effect.annotateCurrentSpan({ channel }); + const allowsPrerelease = channel === "nightly"; + yield* electronUpdater.setChannel(channel); + yield* electronUpdater.setAllowPrerelease(allowsPrerelease); + yield* electronUpdater.setAllowDowngrade(allowsPrerelease); + yield* logUpdaterInfo("using update channel", { + channel, + allowPrerelease: allowsPrerelease, + allowDowngrade: allowsPrerelease, + }); + }); + + const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone)); + + const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* (reason: string) { + yield* Effect.annotateCurrentSpan({ reason }); + if (yield* Ref.get(desktopState.quitting)) return false; + if (!(yield* Ref.get(updaterConfiguredRef))) return false; + if (yield* Ref.get(updateCheckInFlightRef)) return false; + + const state = yield* Ref.get(updateStateRef); + if (state.status === "downloading" || state.status === "downloaded") { + yield* logUpdaterInfo("skipping update check while update is active", { + reason, + status: state.status, + }); + return false; + } + + yield* Ref.set(updateCheckInFlightRef, true); + const checkedAt = yield* currentIsoTimestamp; + yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt)); + yield* logUpdaterInfo("checking for updates", { reason }); + + return yield* electronUpdater.checkForUpdates.pipe( + Effect.as(true), + Effect.catch( + Effect.fn("desktop.updates.handleCheckForUpdatesFailure")(function* (error) { + const failedAt = yield* currentIsoTimestamp; + yield* updateState((current) => + reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt), + ); + yield* logUpdaterError("failed to check for updates", { message: error.message }); + return true; + }), + ), + Effect.ensuring(Ref.set(updateCheckInFlightRef, false)), + ); + }); + + const downloadAvailableUpdate = Effect.gen(function* () { + const state = yield* Ref.get(updateStateRef); + if ( + !(yield* Ref.get(updaterConfiguredRef)) || + (yield* Ref.get(updateDownloadInFlightRef)) || + state.status !== "available" + ) { + return { accepted: false, completed: false }; + } + + yield* Ref.set(updateDownloadInFlightRef, true); + return yield* Effect.gen(function* () { + yield* setState(reduceDesktopUpdateStateOnDownloadStart(state)); + yield* electronUpdater.setDisableDifferentialDownload( + isArm64HostRunningIntelBuild(environment.runtimeInfo), + ); + yield* logUpdaterInfo("downloading update"); + yield* electronUpdater.downloadUpdate; + return { accepted: true, completed: true }; + }).pipe( + Effect.catch( + Effect.fn("desktop.updates.handleDownloadFailure")(function* (error) { + yield* updateState((current) => + reduceDesktopUpdateStateOnDownloadFailure(current, error.message), + ); + yield* logUpdaterError("failed to download update", { message: error.message }); + return { accepted: true, completed: false }; + }), + ), + Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)), + ); + }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate")); + + const installDownloadedUpdate = Effect.gen(function* () { + const state = yield* Ref.get(updateStateRef); + if ( + (yield* Ref.get(desktopState.quitting)) || + !(yield* Ref.get(updaterConfiguredRef)) || + state.status !== "downloaded" + ) { + return { accepted: false, completed: false }; + } + + yield* Ref.set(desktopState.quitting, true); + yield* Ref.set(updateInstallInFlightRef, true); + + return yield* Effect.gen(function* () { + yield* backendManager.stop({ timeout: Duration.seconds(5) }); + yield* electronWindow.destroyAll; + yield* electronUpdater.quitAndInstall({ + isSilent: true, + isForceRunAfter: true, + }); + return { accepted: true, completed: false }; + }).pipe( + Effect.catch( + Effect.fn("desktop.updates.handleInstallFailure")(function* (error) { + yield* Ref.set(updateInstallInFlightRef, false); + yield* updateState((current) => + reduceDesktopUpdateStateOnInstallFailure(current, error.message), + ); + yield* Ref.set(desktopState.quitting, false); + yield* logUpdaterError("failed to install update", { message: error.message }); + return { accepted: true, completed: false }; + }), + ), + ); + }).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate")); + + const startUpdatePollers: Effect.Effect = Effect.gen(function* () { + yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( + Effect.andThen(checkForUpdates("startup")), + Effect.catchCause((cause) => + logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }), + ), + Effect.forkScoped, + ); + yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe( + Effect.andThen(checkForUpdates("poll")), + Effect.forever, + Effect.catchCause((cause) => + logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }), + ), + Effect.forkScoped, + ); + }).pipe(Effect.withSpan("desktop.updates.startPollers")); + + const handleUpdateAvailable = Effect.fn("desktop.updates.handleUpdateAvailable")(function* ( + raw: unknown, + ) { + yield* Schema.decodeUnknownEffect(UpdateInfo)(raw).pipe( + Effect.flatMap( + Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) { + const state = yield* Ref.get(updateStateRef); + if (resolveDefaultDesktopUpdateChannel(info.version) !== state.channel) { + yield* logUpdaterInfo("ignoring update that does not match selected channel", { + version: info.version, + channel: state.channel, + }); + const checkedAt = yield* currentIsoTimestamp; + yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); + yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); + return; + } + + const checkedAt = yield* currentIsoTimestamp; + yield* setState( + reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt), + ); + yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); + yield* logUpdaterInfo("update available", { version: info.version }); + }), + ), + Effect.catchCause((cause) => + logUpdaterWarning("ignored malformed update-available event", { + cause: Cause.pretty(cause), + }), + ), + ); + }); + + const handleUpdateNotAvailable = Effect.gen(function* () { + const checkedAt = yield* currentIsoTimestamp; + const state = yield* Ref.get(updateStateRef); + yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); + yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); + yield* logUpdaterInfo("no updates available"); + }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable")); + + const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* ( + error: unknown, + ) { + const message = error instanceof Error ? error.message : String(error); + if (yield* Ref.get(updateInstallInFlightRef)) { + yield* Ref.set(updateInstallInFlightRef, false); + yield* Ref.set(desktopState.quitting, false); + yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message)); + yield* logUpdaterError("updater error", { message }); + return; + } + + if (!(yield* Ref.get(updateCheckInFlightRef)) && !(yield* Ref.get(updateDownloadInFlightRef))) { + const errorContext = yield* resolveUpdaterErrorContext; + const checkedAt = yield* currentIsoTimestamp; + yield* updateState((current) => ({ + ...current, + status: "error", + message, + checkedAt, + downloadPercent: null, + errorContext, + canRetry: getCanRetryFromState(current), + })); + } + + yield* logUpdaterError("updater error", { message }); + }); + + const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* ( + raw: unknown, + ) { + yield* Schema.decodeUnknownEffect(DownloadProgressInfo)(raw).pipe( + Effect.flatMap( + Effect.fn("desktop.updates.applyDownloadProgress")(function* (progress) { + const state = yield* Ref.get(updateStateRef); + const percent = Math.floor(progress.percent); + if (shouldBroadcastDownloadProgress(state, progress.percent) || state.message !== null) { + yield* setState(reduceDesktopUpdateStateOnDownloadProgress(state, progress.percent)); + } + const milestone = percent - (percent % 10); + const lastLoggedMilestone = yield* Ref.get(lastLoggedDownloadMilestoneRef); + if (milestone > lastLoggedMilestone) { + yield* Ref.set(lastLoggedDownloadMilestoneRef, milestone); + yield* logUpdaterInfo("download progress", { percent }); + } + }), + ), + Effect.catchCause((cause) => + logUpdaterWarning("ignored malformed download-progress event", { + cause: Cause.pretty(cause), + }), + ), + ); + }); + + const handleUpdateDownloaded = Effect.fn("desktop.updates.handleUpdateDownloaded")(function* ( + raw: unknown, + ) { + yield* Schema.decodeUnknownEffect(UpdateInfo)(raw).pipe( + Effect.flatMap( + Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) { + const state = yield* Ref.get(updateStateRef); + yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); + yield* logUpdaterInfo("update downloaded", { version: info.version }); + }), + ), + Effect.catchCause((cause) => + logUpdaterWarning("ignored malformed update-downloaded event", { + cause: Cause.pretty(cause), + }), + ), + ); + }); + + return DesktopUpdates.of({ + getState: Ref.get(updateStateRef), + emitState, + disabledReason: resolveDisabledReason, + configure: Effect.gen(function* () { + const context = yield* Effect.context(); + const runEffect = (effect: Effect.Effect) => { + void Effect.runPromiseWith(context)(effect); + }; + + const appUpdateYmlConfig = yield* readAppUpdateYml; + yield* Ref.set(appUpdateYmlConfigRef, appUpdateYmlConfig); + + if (config.mockUpdates) { + yield* electronUpdater.setFeedURL({ + provider: "generic", + url: `http://localhost:${config.mockUpdateServerPort}`, + } as ElectronUpdater.ElectronUpdaterFeedUrl); + } + + const settings = yield* desktopSettings.get; + const enabled = yield* shouldEnableAutoUpdates; + yield* setState(createBaseUpdateState(settings.updateChannel, enabled, environment)); + if (!enabled) { + return; + } + yield* Ref.set(updaterConfiguredRef, true); + + yield* electronUpdater.setAutoDownload(false); + yield* electronUpdater.setAutoInstallOnAppQuit(false); + yield* applyAutoUpdaterChannel(settings.updateChannel); + yield* electronUpdater.setDisableDifferentialDownload( + isArm64HostRunningIntelBuild(environment.runtimeInfo), + ); + + if (isArm64HostRunningIntelBuild(environment.runtimeInfo)) { + yield* logUpdaterInfo( + "Apple Silicon host detected while running Intel build; updates will switch to arm64 packages", + ); + } + + yield* electronUpdater.on("checking-for-update", () => { + runEffect( + logUpdaterInfo("looking for updates").pipe( + Effect.withSpan("desktop.updates.handleCheckingForUpdate"), + ), + ); + }); + yield* electronUpdater.on("update-available", (info: unknown) => { + runEffect(handleUpdateAvailable(info)); + }); + yield* electronUpdater.on("update-not-available", () => { + runEffect(handleUpdateNotAvailable); + }); + yield* electronUpdater.on("error", (error: unknown) => { + runEffect(handleUpdaterError(error)); + }); + yield* electronUpdater.on("download-progress", (progress: unknown) => { + runEffect(handleDownloadProgress(progress)); + }); + yield* electronUpdater.on("update-downloaded", (info: unknown) => { + runEffect(handleUpdateDownloaded(info)); + }); + + yield* startUpdatePollers; + }).pipe(Effect.withSpan("desktop.updates.configure")), + setChannel: Effect.fn("desktop.updates.setChannel")(function* ( + nextChannel: DesktopUpdateChannel, + ) { + yield* Effect.annotateCurrentSpan({ channel: nextChannel }); + const activeAction = yield* activeUpdateAction; + if (Option.isSome(activeAction)) { + return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value }); + } + + const state = yield* Ref.get(updateStateRef); + if (nextChannel === state.channel) { + return state; + } + + yield* desktopSettings + .setUpdateChannel(nextChannel) + .pipe(Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause }))); + + const enabled = yield* shouldEnableAutoUpdates; + yield* setState(createBaseUpdateState(nextChannel, enabled, environment)); + + if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) { + return yield* Ref.get(updateStateRef); + } + + yield* applyAutoUpdaterChannel(nextChannel); + const allowDowngrade = yield* electronUpdater.allowDowngrade; + yield* electronUpdater.setAllowDowngrade(true); + yield* checkForUpdates("channel-change").pipe( + Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)), + ); + return yield* Ref.get(updateStateRef); + }), + check: Effect.fn("desktop.updates.check")(function* (reason: string) { + yield* Effect.annotateCurrentSpan({ reason }); + if (!(yield* Ref.get(updaterConfiguredRef))) { + return { + checked: false, + state: yield* Ref.get(updateStateRef), + }; + } + const checked = yield* checkForUpdates(reason); + return { + checked, + state: yield* Ref.get(updateStateRef), + }; + }), + download: Effect.gen(function* () { + const result = yield* downloadAvailableUpdate; + return { + accepted: result.accepted, + completed: result.completed, + state: yield* Ref.get(updateStateRef), + }; + }).pipe(Effect.withSpan("desktop.updates.download")), + install: Effect.gen(function* () { + if (yield* Ref.get(desktopState.quitting)) { + return { + accepted: false, + completed: false, + state: yield* Ref.get(updateStateRef), + }; + } + const result = yield* installDownloadedUpdate; + return { + accepted: result.accepted, + completed: result.completed, + state: yield* Ref.get(updateStateRef), + }; + }).pipe(Effect.withSpan("desktop.updates.install")), + }); +}); + +export const layer = Layer.effect(DesktopUpdates, make); diff --git a/apps/desktop/src/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts similarity index 68% rename from apps/desktop/src/updateChannels.ts rename to apps/desktop/src/updates/updateChannels.ts index 615b8e6db66..731910e441f 100644 --- a/apps/desktop/src/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -9,10 +9,3 @@ export function isNightlyDesktopVersion(version: string): boolean { export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; } - -export function doesVersionMatchDesktopUpdateChannel( - version: string, - channel: DesktopUpdateChannel, -): boolean { - return resolveDefaultDesktopUpdateChannel(version) === channel; -} diff --git a/apps/desktop/src/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts similarity index 100% rename from apps/desktop/src/updateMachine.test.ts rename to apps/desktop/src/updates/updateMachine.test.ts diff --git a/apps/desktop/src/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts similarity index 91% rename from apps/desktop/src/updateMachine.ts rename to apps/desktop/src/updates/updateMachine.ts index 7d5ed271e05..b5037225774 100644 --- a/apps/desktop/src/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -4,7 +4,15 @@ import type { DesktopUpdateState, } from "@t3tools/contracts"; -import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState.ts"; +export function nextStatusAfterDownloadFailure( + currentState: DesktopUpdateState, +): DesktopUpdateState["status"] { + return currentState.availableVersion ? "available" : "error"; +} + +export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState): boolean { + return currentState.availableVersion !== null; +} export function createInitialDesktopUpdateState( currentVersion: string, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts new file mode 100644 index 00000000000..fc589b3e39b --- /dev/null +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -0,0 +1,132 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import type * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; +import * as DesktopWindow from "./DesktopWindow.ts"; + +const environmentInput = { + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "linux", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: false, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("T3 Code"), + whenReady: Effect.void, + quit: Effect.void, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + on: () => Effect.void, +} satisfies ElectronApp.ElectronAppShape); + +const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { + pickFolder: () => Effect.succeed(Option.none()), + confirm: () => Effect.succeed(false), + showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), + showErrorBox: () => Effect.void, +} satisfies ElectronDialog.ElectronDialogShape); + +const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { + getState: Effect.die("unexpected getState"), + emitState: Effect.void, + disabledReason: Effect.succeed(Option.none()), + configure: Effect.void, + setChannel: () => Effect.die("unexpected setChannel"), + check: () => Effect.die("unexpected check"), + download: Effect.die("unexpected download"), + install: Effect.die("unexpected install"), +} satisfies DesktopUpdates.DesktopUpdatesShape); + +const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => + Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected createMain"), + ensureMain: Effect.die("unexpected ensureMain"), + revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + handleBackendReady: Effect.void, + dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), + syncAppearance: Effect.void, + } satisfies DesktopWindow.DesktopWindowShape); + +const makeElectronMenuLayer = ( + applicationMenuTemplate: Deferred.Deferred, +) => + Layer.succeed(ElectronMenu.ElectronMenu, { + setApplicationMenu: (template) => + Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid), + popupTemplate: () => Effect.void, + showContextMenu: () => Effect.succeed(Option.none()), + } satisfies ElectronMenu.ElectronMenuShape); + +describe("DesktopApplicationMenu", () => { + it.effect("installs the native menu and routes Settings through DesktopWindow", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* Effect.gen(function* () { + const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; + yield* menu.configure; + }).pipe( + Effect.provide( + DesktopApplicationMenu.layer.pipe( + Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), + Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), + Layer.provideMerge(desktopUpdatesLayer), + Layer.provideMerge(electronDialogLayer), + Layer.provideMerge(electronAppLayer), + Layer.provideMerge( + DesktopEnvironment.layer(environmentInput).pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), + ), + ), + ), + ), + ); + + const template = yield* Deferred.await(applicationMenuTemplate); + const fileMenu = template.find((item) => item.label === "File"); + assert.isDefined(fileMenu); + if (!Array.isArray(fileMenu.submenu)) { + throw new Error("Expected File menu submenu to be an array."); + } + const settingsItem = fileMenu.submenu.find((item) => item.label === "Settings..."); + assert.isDefined(settingsItem); + const settingsClick = settingsItem.click; + if (typeof settingsClick !== "function") { + throw new Error("Expected Settings menu item to have a click handler."); + } + + settingsClick({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + assert.equal(yield* Deferred.await(selectedAction), "open-settings"); + }), + ); +}); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts new file mode 100644 index 00000000000..5e65d81910a --- /dev/null +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -0,0 +1,212 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import type * as Electron from "electron"; + +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; +import * as DesktopWindow from "./DesktopWindow.ts"; + +export interface DesktopApplicationMenuShape { + readonly configure: Effect.Effect; +} + +export class DesktopApplicationMenu extends Context.Service< + DesktopApplicationMenu, + DesktopApplicationMenuShape +>()("t3/desktop/ApplicationMenu") {} + +type DesktopApplicationMenuRuntimeServices = + | DesktopUpdates.DesktopUpdates + | DesktopWindow.DesktopWindow + | ElectronDialog.ElectronDialog; + +const { logInfo: logUpdaterInfo } = DesktopObservability.makeComponentLogger("desktop-updater"); + +const { logError: logMenuError } = DesktopObservability.makeComponentLogger("desktop-menu"); + +const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* ( + action: string, +): Effect.fn.Return { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.dispatchMenuAction(action); +}); + +const checkForUpdatesFromMenu: Effect.Effect< + void, + never, + DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog +> = Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const result = yield* updates.check("menu"); + const updateState = result.state; + + if (updateState.status === "up-to-date") { + yield* electronDialog.showMessageBox({ + type: "info", + title: "You're up to date!", + message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, + buttons: ["OK"], + }); + } else if (updateState.status === "error") { + yield* electronDialog.showMessageBox({ + type: "warning", + title: "Update check failed", + message: "Could not check for updates.", + detail: updateState.message ?? "An unknown error occurred. Please try again later.", + buttons: ["OK"], + }); + } +}).pipe(Effect.withSpan("desktop.menu.checkForUpdates")); + +const handleCheckForUpdatesMenuClick: Effect.Effect< + void, + DesktopWindow.DesktopWindowError, + DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog | DesktopWindow.DesktopWindow +> = Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const disabledReason = yield* updates.disabledReason; + if (Option.isSome(disabledReason)) { + yield* logUpdaterInfo("manual update check requested, but updates are disabled", { + disabledReason: disabledReason.value, + }); + yield* electronDialog.showMessageBox({ + type: "info", + title: "Updates unavailable", + message: "Automatic updates are not available right now.", + detail: disabledReason.value, + buttons: ["OK"], + }); + return; + } + + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.ensureMain; + yield* checkForUpdatesFromMenu; +}).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick")); + +const make = Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const electronMenu = yield* ElectronMenu.ElectronMenu; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const appName = yield* electronApp.name; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + const runMenuEffect = ( + action: string, + effect: Effect.Effect, + ) => { + void runPromise( + effect.pipe( + Effect.annotateLogs({ action }), + Effect.withSpan("desktop.menu.action"), + Effect.catchCause((cause) => + logMenuError("desktop menu action failed", { + action, + cause: Cause.pretty(cause), + }), + ), + ), + ); + }; + + const configure = Effect.gen(function* () { + const checkForUpdatesClick = () => { + runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick); + }; + const settingsClick = () => { + runMenuEffect("open-settings", dispatchMenuAction("open-settings")); + }; + const template: Electron.MenuItemConstructorOptions[] = []; + + if (environment.platform === "darwin") { + template.push({ + label: appName, + submenu: [ + { role: "about" }, + { + label: "Check for Updates...", + click: checkForUpdatesClick, + }, + { type: "separator" }, + { + label: "Settings...", + accelerator: "CmdOrCtrl+,", + click: settingsClick, + }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }); + } + + template.push( + { + label: "File", + submenu: [ + ...(environment.platform === "darwin" + ? [] + : [ + { + label: "Settings...", + accelerator: "CmdOrCtrl+,", + click: settingsClick, + }, + { type: "separator" as const }, + ]), + { role: environment.platform === "darwin" ? "close" : "quit" }, + ], + }, + { role: "editMenu" }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, + { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { role: "windowMenu" }, + { + role: "help", + submenu: [ + { + label: "Check for Updates...", + click: checkForUpdatesClick, + }, + ], + }, + ); + + yield* electronMenu.setApplicationMenu(template); + }).pipe(Effect.withSpan("desktop.menu.configure")); + + return DesktopApplicationMenu.of({ + configure, + }); +}); + +export const layer = Layer.effect(DesktopApplicationMenu, make); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts new file mode 100644 index 00000000000..e53a02f5394 --- /dev/null +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -0,0 +1,180 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import type * as Electron from "electron"; +import { vi } from "vitest"; + +import * as DesktopAssets from "../app/DesktopAssets.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; +import * as DesktopWindow from "./DesktopWindow.ts"; + +const environmentInput = { + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: false, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +function makeFakeBrowserWindow() { + const webContents = { + copyImageAt: vi.fn(), + isLoadingMainFrame: vi.fn(() => false), + on: vi.fn(), + once: vi.fn(), + openDevTools: vi.fn(), + replaceMisspelling: vi.fn(), + send: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + + const window = { + focus: vi.fn(), + isDestroyed: vi.fn(() => false), + isMinimized: vi.fn(() => false), + isVisible: vi.fn(() => true), + loadURL: vi.fn(() => Promise.resolve()), + on: vi.fn(), + once: vi.fn(), + restore: vi.fn(), + setBackgroundColor: vi.fn(), + setTitle: vi.fn(), + setTitleBarOverlay: vi.fn(), + show: vi.fn(), + webContents, + }; + + return { + window: window as unknown as Electron.BrowserWindow, + loadURL: window.loadURL, + openDevTools: webContents.openDevTools, + }; +} + +const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { + iconPaths: Effect.succeed({ + ico: Option.none(), + icns: Option.none(), + png: Option.none(), + }), + resolveResourcePath: () => Effect.succeed(Option.none()), +} satisfies DesktopAssets.DesktopAssetsShape); + +const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { + getState: Effect.die("unexpected getState"), + backendConfig: Effect.succeed({ + port: 3773, + bindHost: "127.0.0.1", + httpBaseUrl: new URL("http://127.0.0.1:3773"), + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }), + configureFromSettings: () => Effect.die("unexpected configureFromSettings"), + setMode: () => Effect.die("unexpected setMode"), + setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), + getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"), +} satisfies DesktopServerExposure.DesktopServerExposureShape); + +const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { + setApplicationMenu: () => Effect.void, + popupTemplate: () => Effect.void, + showContextMenu: () => Effect.succeed(Option.none()), +} satisfies ElectronMenu.ElectronMenuShape); + +const electronShellLayer = Layer.succeed(ElectronShell.ElectronShell, { + openExternal: () => Effect.succeed(true), + copyText: () => Effect.void, +} satisfies ElectronShell.ElectronShellShape); + +const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { + shouldUseDarkColors: Effect.succeed(false), + setSource: () => Effect.void, + onUpdated: () => Effect.void, +} satisfies ElectronTheme.ElectronThemeShape); + +const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_PORT: "3773", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", + }), + ), + ), +); + +function makeTestLayer(input: { + readonly window: Electron.BrowserWindow; + readonly createCount: Ref.Ref; + readonly mainWindow: Ref.Ref>; +}) { + const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { + create: () => Ref.update(input.createCount, (count) => count + 1).pipe(Effect.as(input.window)), + main: Ref.get(input.mainWindow), + currentMainOrFirst: Ref.get(input.mainWindow), + focusedMainOrFirst: Ref.get(input.mainWindow), + setMain: (window) => Ref.set(input.mainWindow, Option.some(window)), + clearMain: () => Ref.set(input.mainWindow, Option.none()), + reveal: () => Effect.void, + sendAll: () => Effect.void, + destroyAll: Effect.void, + syncAllAppearance: (sync) => sync(input.window), + } satisfies ElectronWindow.ElectronWindowShape); + + return DesktopWindow.layer.pipe( + Layer.provide( + Layer.mergeAll( + desktopAssetsLayer, + desktopEnvironmentLayer, + desktopServerExposureLayer, + DesktopState.layer, + electronMenuLayer, + electronShellLayer, + electronThemeLayer, + electronWindowLayer, + ), + ), + ); +} + +describe("DesktopWindow", () => { + it.effect("does not open a development window until the backend is ready", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 0); + + yield* desktopWindow.handleBackendReady; + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]); + assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); +}); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts new file mode 100644 index 00000000000..2cf69ae8b7c --- /dev/null +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -0,0 +1,368 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import type * as Electron from "electron"; + +import * as DesktopAssets from "../app/DesktopAssets.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as IpcChannels from "../ipc/channels.ts"; +import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; + +const TITLEBAR_HEIGHT = 40; +const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux +const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; +const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; + +type WindowTitleBarOptions = Pick< + Electron.BrowserWindowConstructorOptions, + "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" +>; + +type DesktopWindowRuntimeServices = + | DesktopEnvironment.DesktopEnvironment + | DesktopAssets.DesktopAssets + | DesktopServerExposure.DesktopServerExposure + | DesktopState.DesktopState + | ElectronMenu.ElectronMenu + | ElectronShell.ElectronShell + | ElectronTheme.ElectronTheme + | ElectronWindow.ElectronWindow; + +export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( + "DesktopWindowDevServerUrlMissingError", +)<{}> { + override get message() { + return "VITE_DEV_SERVER_URL is required in desktop development."; + } +} + +export type DesktopWindowError = + | DesktopWindowDevServerUrlMissingError + | ElectronWindow.ElectronWindowCreateError; + +export interface DesktopWindowShape { + readonly createMain: Effect.Effect; + readonly ensureMain: Effect.Effect; + readonly revealOrCreateMain: Effect.Effect; + readonly activate: Effect.Effect; + readonly createMainIfBackendReady: Effect.Effect; + readonly handleBackendReady: Effect.Effect; + readonly dispatchMenuAction: (action: string) => Effect.Effect; + readonly syncAppearance: Effect.Effect; +} + +export class DesktopWindow extends Context.Service()( + "t3/desktop/Window", +) {} + +const { logInfo: logWindowInfo, logWarning: logWindowWarning } = + DesktopObservability.makeComponentLogger("desktop-window"); + +function resolveDesktopDevServerUrl( + environment: DesktopEnvironment.DesktopEnvironmentShape, +): Effect.Effect { + return Option.match(environment.devServerUrl, { + onNone: () => Effect.fail(new DesktopWindowDevServerUrlMissingError()), + onSome: (url) => Effect.succeed(url.href), + }); +} + +function getIconOption( + iconPaths: DesktopAssets.DesktopIconPaths, +): { icon: string } | Record { + if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle + const ext = process.platform === "win32" ? "ico" : "png"; + return Option.match(iconPaths[ext], { + onNone: () => ({}), + onSome: (icon) => ({ icon }), + }); +} + +function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { + return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; +} + +function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions { + if (process.platform === "darwin") { + return { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 16, y: 18 }, + }; + } + + return { + titleBarStyle: "hidden", + titleBarOverlay: { + color: TITLEBAR_COLOR, + height: TITLEBAR_HEIGHT, + symbolColor: shouldUseDarkColors ? TITLEBAR_DARK_SYMBOL_COLOR : TITLEBAR_LIGHT_SYMBOL_COLOR, + }, + }; +} + +function syncWindowAppearance( + window: Electron.BrowserWindow, + shouldUseDarkColors: boolean, +): Effect.Effect { + return Effect.sync(() => { + if (window.isDestroyed()) { + return; + } + + window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); + const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors); + if (typeof titleBarOverlay === "object") { + window.setTitleBarOverlay(titleBarOverlay); + } + }); +} + +type RevealSubscription = (listener: () => void) => void; + +function bindFirstRevealTrigger( + subscribers: readonly RevealSubscription[], + reveal: () => void, +): void { + let revealed = false; + const fire = () => { + if (revealed) return; + revealed = true; + reveal(); + }; + for (const subscribe of subscribers) { + subscribe(fire); + } +} + +const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const assets = yield* DesktopAssets.DesktopAssets; + const electronMenu = yield* ElectronMenu.ElectronMenu; + const electronShell = yield* ElectronShell.ElectronShell; + const electronTheme = yield* ElectronTheme.ElectronTheme; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const state = yield* DesktopState.DesktopState; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + const createWindow = Effect.fn("desktop.window.createWindow")(function* ( + backendHttpUrl: URL, + ): Effect.fn.Return { + const iconPaths = yield* assets.iconPaths; + const iconOption = getIconOption(iconPaths); + const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const window = yield* electronWindow.create({ + width: 1100, + height: 780, + minWidth: 840, + minHeight: 620, + show: false, + autoHideMenuBar: true, + backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), + ...iconOption, + title: environment.displayName, + ...getWindowTitleBarOptions(shouldUseDarkColors), + webPreferences: { + preload: environment.preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + window.webContents.on("context-menu", (event, params) => { + event.preventDefault(); + + const menuTemplate: Electron.MenuItemConstructorOptions[] = []; + + if (params.misspelledWord) { + for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { + menuTemplate.push({ + label: suggestion, + click: () => window.webContents.replaceMisspelling(suggestion), + }); + } + if (params.dictionarySuggestions.length === 0) { + menuTemplate.push({ label: "No suggestions", enabled: false }); + } + menuTemplate.push({ type: "separator" }); + } + + if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) { + menuTemplate.push( + { + label: "Copy Link", + click: () => { + void runPromise(electronShell.copyText(params.linkURL)); + }, + }, + { type: "separator" }, + ); + } + + if (params.mediaType === "image") { + menuTemplate.push({ + label: "Copy Image", + click: () => window.webContents.copyImageAt(params.x, params.y), + }); + menuTemplate.push({ type: "separator" }); + } + + menuTemplate.push( + { role: "cut", enabled: params.editFlags.canCut }, + { role: "copy", enabled: params.editFlags.canCopy }, + { role: "paste", enabled: params.editFlags.canPaste }, + { role: "selectAll", enabled: params.editFlags.canSelectAll }, + ); + + void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate })); + }); + + window.webContents.setWindowOpenHandler(({ url }) => { + if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) { + void runPromise(electronShell.openExternal(url)); + } + return { action: "deny" }; + }); + + window.on("page-title-updated", (event) => { + event.preventDefault(); + window.setTitle(environment.displayName); + }); + window.webContents.on("did-finish-load", () => { + window.setTitle(environment.displayName); + }); + window.webContents.on( + "did-fail-load", + (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame) { + return; + } + void runPromise( + logWindowWarning("main window failed to load", { + errorCode, + errorDescription, + url: validatedURL, + }), + ); + }, + ); + window.webContents.on("render-process-gone", (_event, details) => { + void runPromise( + logWindowWarning("main window render process gone", { + reason: details.reason, + exitCode: details.exitCode, + }), + ); + }); + + const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; + if (process.platform === "linux") { + revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); + } + bindFirstRevealTrigger(revealSubscribers, () => { + void runPromise(electronWindow.reveal(window)); + }); + + if (environment.isDevelopment) { + const devServerUrl = yield* resolveDesktopDevServerUrl(environment); + void window.loadURL(devServerUrl); + window.webContents.openDevTools({ mode: "detach" }); + } else { + void window.loadURL(backendHttpUrl.href); + } + + window.on("closed", () => { + void runPromise(electronWindow.clearMain(Option.some(window))); + }); + + return window; + }); + + const createMain = Effect.gen(function* () { + const backendConfig = yield* serverExposure.backendConfig; + const window = yield* createWindow(backendConfig.httpBaseUrl); + yield* electronWindow.setMain(window); + yield* logWindowInfo("main window created"); + return window; + }).pipe(Effect.withSpan("desktop.window.createMain")); + + const ensureMain = Effect.gen(function* () { + const existingWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(existingWindow)) { + return existingWindow.value; + } + return yield* createMain; + }).pipe(Effect.withSpan("desktop.window.ensureMain")); + + const revealOrCreateMain = Effect.gen(function* () { + const window = yield* ensureMain; + yield* electronWindow.reveal(window); + return window; + }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); + + const createMainIfBackendReady = Effect.gen(function* () { + const backendReady = yield* Ref.get(state.backendReady); + if (!backendReady) return; + const existingWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(existingWindow)) return; + yield* createMain; + }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); + + return DesktopWindow.of({ + createMain, + ensureMain, + revealOrCreateMain, + activate: Effect.gen(function* () { + const existingWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(existingWindow)) { + yield* electronWindow.reveal(existingWindow.value); + } else { + yield* createMainIfBackendReady; + } + }).pipe(Effect.withSpan("desktop.window.activate")), + createMainIfBackendReady, + handleBackendReady: Effect.gen(function* () { + yield* Ref.set(state.backendReady, true); + yield* logWindowInfo("backend ready", { source: "http" }); + yield* createMainIfBackendReady; + }).pipe(Effect.withSpan("desktop.window.handleBackendReady")), + dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { + yield* Effect.annotateCurrentSpan({ action }); + const existingWindow = yield* electronWindow.focusedMainOrFirst; + const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* createMain; + + const send = () => { + if (targetWindow.isDestroyed()) return; + targetWindow.webContents.send(IpcChannels.MENU_ACTION_CHANNEL, action); + void runPromise(electronWindow.reveal(targetWindow)); + }; + + if (targetWindow.webContents.isLoadingMainFrame()) { + targetWindow.webContents.once("did-finish-load", send); + return; + } + + send(); + }), + syncAppearance: Effect.gen(function* () { + const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + yield* electronWindow.syncAllAppearance((window) => + syncWindowAppearance(window, shouldUseDarkColors), + ); + }).pipe(Effect.withSpan("desktop.window.syncAppearance")), + }); +}); + +export const layer = Layer.effect(DesktopWindow, make); diff --git a/apps/desktop/src/windowReveal.test.ts b/apps/desktop/src/windowReveal.test.ts deleted file mode 100644 index 88285fec0cb..00000000000 --- a/apps/desktop/src/windowReveal.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { EventEmitter } from "node:events"; - -import { describe, expect, it, vi } from "vitest"; - -import { bindFirstRevealTrigger } from "./windowReveal.ts"; - -describe("bindFirstRevealTrigger", () => { - it("reveals when the first trigger fires", () => { - const window = new EventEmitter(); - const webContents = new EventEmitter(); - const reveal = vi.fn(); - - bindFirstRevealTrigger( - [ - (fire) => window.once("ready-to-show", fire), - (fire) => webContents.once("did-finish-load", fire), - ], - reveal, - ); - - window.emit("ready-to-show"); - - expect(reveal).toHaveBeenCalledTimes(1); - }); - - it("reveals when only the fallback trigger fires (Wayland deadlock case)", () => { - const window = new EventEmitter(); - const webContents = new EventEmitter(); - const reveal = vi.fn(); - - bindFirstRevealTrigger( - [ - (fire) => window.once("ready-to-show", fire), - (fire) => webContents.once("did-finish-load", fire), - ], - reveal, - ); - - webContents.emit("did-finish-load"); - - expect(reveal).toHaveBeenCalledTimes(1); - }); - - it("only reveals once when multiple triggers fire", () => { - const window = new EventEmitter(); - const webContents = new EventEmitter(); - const reveal = vi.fn(); - - bindFirstRevealTrigger( - [ - (fire) => window.once("ready-to-show", fire), - (fire) => webContents.once("did-finish-load", fire), - ], - reveal, - ); - - webContents.emit("did-finish-load"); - window.emit("ready-to-show"); - - expect(reveal).toHaveBeenCalledTimes(1); - }); - - it("subscribers using `once` ignore re-emitted events after reveal", () => { - const window = new EventEmitter(); - const reveal = vi.fn(); - - bindFirstRevealTrigger([(fire) => window.once("ready-to-show", fire)], reveal); - - window.emit("ready-to-show"); - window.emit("ready-to-show"); - - expect(reveal).toHaveBeenCalledTimes(1); - }); -}); diff --git a/apps/desktop/src/windowReveal.ts b/apps/desktop/src/windowReveal.ts deleted file mode 100644 index 8faf65aeb15..00000000000 --- a/apps/desktop/src/windowReveal.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type RevealSubscription = (listener: () => void) => void; - -/** - * Wire a reveal callback to fire exactly once, on whichever of the provided - * event subscribers fires first. Each subscriber is responsible for binding - * its own event source. - * - * Used by the desktop main window's first-paint reveal logic. The standard - * Electron pattern is to wait for `ready-to-show` before calling `show()`, - * but on Linux/Wayland with `show: false`, `ready-to-show` only fires after - * `show()` is called, deadlocking that pattern. Subscribing to both - * `ready-to-show` and `did-finish-load` (or any other "renderer is alive" - * signal) lets the window surface reliably across platforms. - */ -export function bindFirstRevealTrigger( - subscribers: readonly RevealSubscription[], - reveal: () => void, -): void { - let revealed = false; - const fire = () => { - if (revealed) return; - revealed = true; - reveal(); - }; - for (const subscribe of subscribers) { - subscribe(fire); - } -} diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index ff3e4cd0f38..6fee28207ab 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -3,7 +3,19 @@ "compilerOptions": { "composite": true, "types": ["node", "electron"], - "lib": ["ESNext", "DOM", "esnext.disposable"] + "lib": ["ESNext", "DOM", "esnext.disposable"], + "plugins": [ + { + "name": "@effect/language-service", + "namespaceImportPackages": ["@effect/platform-node", "effect"], + "diagnosticSeverity": { + "importFromBarrel": "error", + "anyUnknownInErrorContext": "error", + "instanceOfSchema": "warning", + "deterministicKeys": "warning" + } + } + ] }, "include": ["src", "tsdown.config.ts"] } diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index c1bd9f1a189..fbadf2cf4ea 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -1,13 +1,33 @@ import os from "node:os"; import { assert, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; +import { + DesktopBackendBootstrap, + type DesktopBackendBootstrap as DesktopBackendBootstrapValue, +} from "@t3tools/contracts"; import { NetService } from "@t3tools/shared/Net"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { deriveServerPaths } from "../config.ts"; import { resolveServerConfig } from "./config.ts"; +const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); + +const makeDesktopBootstrap = ( + overrides: Partial = {}, +): DesktopBackendBootstrapValue => ({ + mode: "desktop", + noBrowser: true, + port: 4888, + t3Home: "/tmp/t3-bootstrap-home", + host: "127.0.0.1", + desktopBootstrapToken: "desktop-bootstrap-token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + ...overrides, +}); + it.layer(NodeServices.layer)("cli config resolution", (it) => { const defaultObservabilityConfig = { traceMinLevel: "Info", @@ -21,10 +41,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpServiceName: "t3-server", } as const; - const openBootstrapFd = Effect.fn(function* (payload: Record) { + const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) { const fs = yield* FileSystem.FileSystem; const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); - yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`); + const encoded = yield* encodeDesktopBootstrap(payload); + yield* fs.writeFileString(filePath, `${encoded}\n`); const { fd } = yield* fs.open(filePath, { flag: "r" }); return fd; }); @@ -165,13 +186,13 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = join(os.tmpdir(), "t3-cli-config-false-flags"); - const fd = yield* openBootstrapFd({ - noBrowser: true, - autoBootstrapProjectFromCwd: true, - logWebSocketEvents: true, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }); + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ + noBrowser: true, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }), + ); const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); const resolved = yield* resolveServerConfig( @@ -221,7 +242,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { devUrl: new URL("http://127.0.0.1:4173"), noBrowser: false, startupPresentation: "browser", - desktopBootstrapToken: undefined, + desktopBootstrapToken: "desktop-bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, @@ -234,21 +255,20 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = "/tmp/t3-bootstrap-home"; - const fd = yield* openBootstrapFd({ - mode: "desktop", - port: 4888, - host: "127.0.0.2", - t3Home: baseDir, - devUrl: "http://127.0.0.1:5173", - noBrowser: true, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: true, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", - }); - const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ + port: 4888, + host: "127.0.0.2", + t3Home: baseDir, + noBrowser: true, + desktopBootstrapToken: "desktop-token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + }), + ); + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); const resolved = yield* resolveServerConfig( { @@ -292,17 +312,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { baseDir, ...derivedPaths, host: "127.0.0.2", - staticDir: undefined, - devUrl: new URL("http://127.0.0.1:5173"), + staticDir: resolved.staticDir, + devUrl: undefined, noBrowser: true, startupPresentation: "browser", - desktopBootstrapToken: undefined, + desktopBootstrapToken: "desktop-token", autoBootstrapProjectFromCwd: false, - logWebSocketEvents: true, + logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, }); - assert.equal(join(baseDir, "dev"), resolved.stateDir); + assert.equal(join(baseDir, "userdata"), resolved.stateDir); }), ); @@ -359,18 +379,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins"); - const fd = yield* openBootstrapFd({ - mode: "desktop", - port: 4888, - host: "127.0.0.2", - t3Home: "/tmp/t3-bootstrap-home", - devUrl: "http://127.0.0.1:5173", - noBrowser: false, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }); + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ + port: 4888, + host: "127.0.0.2", + t3Home: "/tmp/t3-bootstrap-home", + noBrowser: false, + desktopBootstrapToken: "desktop-token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }), + ); const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); const resolved = yield* resolveServerConfig( @@ -422,7 +441,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, startupPresentation: "browser", - desktopBootstrapToken: undefined, + desktopBootstrapToken: "desktop-token", autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, tailscaleServeEnabled: false, diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index baac5c708b6..0af25ab45c8 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -1,5 +1,6 @@ import { NetService } from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import { Config, Duration, @@ -26,24 +27,6 @@ import { } from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; -export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); - -const BootstrapEnvelopeSchema = Schema.Struct({ - mode: Schema.optional(RuntimeMode), - port: Schema.optional(PortSchema), - host: Schema.optional(Schema.String), - t3Home: Schema.optional(Schema.String), - devUrl: Schema.optional(Schema.URLFromString), - noBrowser: Schema.optional(Schema.Boolean), - desktopBootstrapToken: Schema.optional(Schema.String), - autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), - logWebSocketEvents: Schema.optional(Schema.Boolean), - tailscaleServeEnabled: Schema.optional(Schema.Boolean), - tailscaleServePort: Schema.optional(PortSchema), - otlpTracesUrl: Schema.optional(Schema.String), - otlpMetricsUrl: Schema.optional(Schema.String), -}); - export const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), Flag.optional, @@ -253,7 +236,7 @@ export const resolveServerConfig = ( const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; const bootstrapEnvelope = bootstrapFd !== undefined - ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) + ? yield* readBootstrapEnvelope(DesktopBackendBootstrap, bootstrapFd) : Option.none(); const bootstrap = Option.getOrUndefined(bootstrapEnvelope); @@ -283,11 +266,7 @@ export const resolveServerConfig = ( }, ); const devUrl = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.devUrl, - Option.fromUndefinedOr(env.devUrl), - Option.fromUndefinedOr(bootstrap?.devUrl), - ), + resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)), () => undefined, ); const baseDir = yield* resolveBaseDir( @@ -327,7 +306,6 @@ export const resolveServerConfig = ( isHeadlessStartup ? Option.some(false) : Option.none(), normalizedFlags.autoBootstrapProjectFromCwd, Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), - Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd), ), () => mode === "web", ); @@ -335,7 +313,6 @@ export const resolveServerConfig = ( resolveOptionPrecedence( normalizedFlags.logWebSocketEvents, Option.fromUndefinedOr(env.logWebSocketEvents), - Option.fromUndefinedOr(bootstrap?.logWebSocketEvents), ), () => Boolean(devUrl), ); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 88cc5adae92..23ee0f54cf8 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,4 +1,5 @@ import Mime from "@effect/platform-node/Mime"; +import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import { Data, Effect, FileSystem, Option, Path } from "effect"; import { cast } from "effect/Function"; import { @@ -18,7 +19,6 @@ import { } from "./attachmentPaths.ts"; import { resolveAttachmentPathById } from "./attachmentStore.ts"; import { resolveStaticDir, ServerConfig } from "./config.ts"; -import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; diff --git a/apps/server/src/observability/Attributes.test.ts b/apps/server/src/observability/Attributes.test.ts index 4b495598ea3..d9ed2e1271f 100644 --- a/apps/server/src/observability/Attributes.test.ts +++ b/apps/server/src/observability/Attributes.test.ts @@ -1,43 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; -import { compactTraceAttributes, normalizeModelMetricLabel } from "./Attributes.ts"; +import { normalizeModelMetricLabel } from "./Attributes.ts"; describe("Attributes", () => { - it("normalizes circular arrays, maps, and sets without recursing forever", () => { - const array: Array = ["alpha"]; - array.push(array); - - const map = new Map(); - map.set("self", map); - - const set = new Set(); - set.add(set); - - assert.deepStrictEqual( - compactTraceAttributes({ - array, - map, - set, - }), - { - array: ["alpha", "[Circular]"], - map: { self: "[Circular]" }, - set: ["[Circular]"], - }, - ); - }); - - it("normalizes invalid dates without throwing", () => { - assert.deepStrictEqual( - compactTraceAttributes({ - invalidDate: new Date("not-a-real-date"), - }), - { - invalidDate: "Invalid Date", - }, - ); - }); - it("groups GPT-family models under a shared metric label", () => { assert.strictEqual(normalizeModelMetricLabel("gpt-4o"), "gpt"); assert.strictEqual(normalizeModelMetricLabel("gpt-5.4"), "gpt"); diff --git a/apps/server/src/observability/Attributes.ts b/apps/server/src/observability/Attributes.ts index 2251fcfea69..1da76d3b325 100644 --- a/apps/server/src/observability/Attributes.ts +++ b/apps/server/src/observability/Attributes.ts @@ -2,88 +2,8 @@ import { Cause, Exit } from "effect"; export type MetricAttributeValue = string; export type MetricAttributes = Readonly>; -export type TraceAttributes = Readonly>; export type ObservabilityOutcome = "success" | "failure" | "interrupt"; -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function markSeen(value: object, seen: WeakSet): boolean { - if (seen.has(value)) { - return true; - } - seen.add(value); - return false; -} - -function normalizeJsonValue(value: unknown, seen: WeakSet = new WeakSet()): unknown { - if ( - value === null || - value === undefined || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ) { - return value ?? null; - } - if (typeof value === "bigint") { - return value.toString(); - } - if (value instanceof Date) { - return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString(); - } - if (value instanceof Error) { - return { - name: value.name, - message: value.message, - ...(value.stack ? { stack: value.stack } : {}), - }; - } - if (Array.isArray(value)) { - if (markSeen(value, seen)) { - return "[Circular]"; - } - return value.map((entry) => normalizeJsonValue(entry, seen)); - } - if (value instanceof Map) { - if (markSeen(value, seen)) { - return "[Circular]"; - } - return Object.fromEntries( - Array.from(value.entries(), ([key, entryValue]) => [ - String(key), - normalizeJsonValue(entryValue, seen), - ]), - ); - } - if (value instanceof Set) { - if (markSeen(value, seen)) { - return "[Circular]"; - } - return Array.from(value.values(), (entry) => normalizeJsonValue(entry, seen)); - } - if (!isPlainObject(value)) { - return String(value); - } - if (markSeen(value, seen)) { - return "[Circular]"; - } - return Object.fromEntries( - Object.entries(value).map(([key, entryValue]) => [key, normalizeJsonValue(entryValue, seen)]), - ); -} - -export function compactTraceAttributes( - attributes: Readonly>, -): TraceAttributes { - return Object.fromEntries( - Object.entries(attributes) - .filter(([, value]) => value !== undefined) - .map(([key, value]) => [key, normalizeJsonValue(value)]), - ); -} - export function compactMetricAttributes( attributes: Readonly>, ): MetricAttributes { diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 7c6a28005f4..ae3c1ecb276 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -1,11 +1,10 @@ +import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; import { Effect, Layer, References, Tracer } from "effect"; import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { ServerConfig } from "../../config.ts"; import { ServerLoggerLive } from "../../serverLogger.ts"; -import { makeLocalFileTracer } from "../LocalFileTracer.ts"; import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts"; -import { makeTraceSink } from "../TraceSink.ts"; const otlpSerializationLayer = OtlpSerialization.layerJson; diff --git a/apps/server/src/observability/LocalFileTracer.test.ts b/apps/server/src/observability/LocalFileTracer.test.ts deleted file mode 100644 index 19efffaf10b..00000000000 --- a/apps/server/src/observability/LocalFileTracer.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { assert, describe, it } from "@effect/vitest"; -import { Effect, Layer, Logger, References, Tracer } from "effect"; - -import type { EffectTraceRecord } from "./TraceRecord.ts"; -import { makeLocalFileTracer } from "./LocalFileTracer.ts"; - -const makeTestLayer = (tracePath: string) => - Layer.mergeAll( - Layer.effect( - Tracer.Tracer, - makeLocalFileTracer({ - filePath: tracePath, - maxBytes: 1024 * 1024, - maxFiles: 2, - batchWindowMs: 10_000, - }), - ), - Logger.layer([Logger.tracerLogger], { mergeWithExisting: false }), - Layer.succeed(References.MinimumLogLevel, "Info"), - ); - -const readTraceRecords = (tracePath: string): Array => - fs - .readFileSync(tracePath, "utf8") - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as EffectTraceRecord); - -describe("LocalFileTracer", () => { - it.effect("writes nested spans to disk and captures log messages as span events", () => - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); - const tracePath = path.join(tempDir, "server.trace.ndjson"); - - try { - yield* Effect.scoped( - Effect.gen(function* () { - const program = Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ - "demo.parent": true, - }); - yield* Effect.logInfo("parent event"); - yield* Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ - "demo.child": true, - }); - yield* Effect.logInfo("child event"); - }).pipe(Effect.withSpan("child-span")); - }).pipe(Effect.withSpan("parent-span")); - - yield* program.pipe(Effect.provide(makeTestLayer(tracePath))); - }), - ); - - const records = readTraceRecords(tracePath); - assert.equal(records.length, 2); - - const parent = records.find((record) => record.name === "parent-span"); - const child = records.find((record) => record.name === "child-span"); - - assert.notEqual(parent, undefined); - assert.notEqual(child, undefined); - if (!parent || !child) { - return; - } - - assert.equal(child.parentSpanId, parent.spanId); - assert.equal(parent.attributes["demo.parent"], true); - assert.equal(child.attributes["demo.child"], true); - assert.equal( - parent.events.some((event) => event.name === "parent event"), - true, - ); - assert.equal( - child.events.some((event) => event.name === "child event"), - true, - ); - assert.equal( - child.events.some((event) => event.attributes["effect.logLevel"] === "INFO"), - true, - ); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("serializes interrupted spans with an interrupted exit status", () => - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); - const tracePath = path.join(tempDir, "server.trace.ndjson"); - - try { - yield* Effect.scoped( - Effect.exit( - Effect.interrupt.pipe( - Effect.withSpan("interrupt-span"), - Effect.provide(makeTestLayer(tracePath)), - ), - ), - ); - - const records = readTraceRecords(tracePath); - assert.equal(records.length, 1); - assert.equal(records[0]?.name, "interrupt-span"); - assert.equal(records[0]?.exit._tag, "Interrupted"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); -}); diff --git a/apps/server/src/observability/LocalFileTracer.ts b/apps/server/src/observability/LocalFileTracer.ts deleted file mode 100644 index a3d43ea118c..00000000000 --- a/apps/server/src/observability/LocalFileTracer.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type * as Exit from "effect/Exit"; -import { Effect, Option, Tracer } from "effect"; - -import { spanToTraceRecord } from "./TraceRecord.ts"; -import type { EffectTraceRecord } from "./TraceRecord.ts"; -import { makeTraceSink, type TraceSink } from "./TraceSink.ts"; - -export interface LocalFileTracerOptions { - readonly filePath: string; - readonly maxBytes: number; - readonly maxFiles: number; - readonly batchWindowMs: number; - readonly delegate?: Tracer.Tracer; - readonly sink?: TraceSink; -} - -class LocalFileSpan implements Tracer.Span { - readonly _tag = "Span"; - readonly name: string; - readonly spanId: string; - readonly traceId: string; - readonly parent: Option.Option; - readonly annotations: Tracer.Span["annotations"]; - readonly links: Array; - readonly sampled: boolean; - readonly kind: Tracer.SpanKind; - - status: Tracer.SpanStatus; - attributes: Map; - events: Array<[name: string, startTime: bigint, attributes: Record]>; - private readonly delegate: Tracer.Span; - private readonly push: (record: EffectTraceRecord) => void; - - constructor( - options: Parameters[0], - delegate: Tracer.Span, - push: (record: EffectTraceRecord) => void, - ) { - this.delegate = delegate; - this.push = push; - this.name = delegate.name; - this.spanId = delegate.spanId; - this.traceId = delegate.traceId; - this.parent = options.parent; - this.annotations = options.annotations; - this.links = [...options.links]; - this.sampled = delegate.sampled; - this.kind = delegate.kind; - this.status = { - _tag: "Started", - startTime: options.startTime, - }; - this.attributes = new Map(); - this.events = []; - } - - end(endTime: bigint, exit: Exit.Exit): void { - this.status = { - _tag: "Ended", - startTime: this.status.startTime, - endTime, - exit, - }; - this.delegate.end(endTime, exit); - - if (this.sampled) { - this.push(spanToTraceRecord(this)); - } - } - - attribute(key: string, value: unknown): void { - this.attributes.set(key, value); - this.delegate.attribute(key, value); - } - - event(name: string, startTime: bigint, attributes?: Record): void { - const nextAttributes = attributes ?? {}; - this.events.push([name, startTime, nextAttributes]); - this.delegate.event(name, startTime, nextAttributes); - } - - addLinks(links: ReadonlyArray): void { - this.links.push(...links); - this.delegate.addLinks(links); - } -} - -export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* ( - options: LocalFileTracerOptions, -) { - const sink = - options.sink ?? - (yield* makeTraceSink({ - filePath: options.filePath, - maxBytes: options.maxBytes, - maxFiles: options.maxFiles, - batchWindowMs: options.batchWindowMs, - })); - - const delegate = - options.delegate ?? - Tracer.make({ - span: (spanOptions) => new Tracer.NativeSpan(spanOptions), - }); - - return Tracer.make({ - span(spanOptions) { - return new LocalFileSpan(spanOptions, delegate.span(spanOptions), sink.push); - }, - ...(delegate.context ? { context: delegate.context } : {}), - }); -}); diff --git a/apps/server/src/observability/Services/BrowserTraceCollector.ts b/apps/server/src/observability/Services/BrowserTraceCollector.ts index e27a53c9c34..1018d536044 100644 --- a/apps/server/src/observability/Services/BrowserTraceCollector.ts +++ b/apps/server/src/observability/Services/BrowserTraceCollector.ts @@ -1,8 +1,7 @@ +import type { TraceRecord } from "@t3tools/shared/observability"; import { Context } from "effect"; import type { Effect } from "effect"; -import type { TraceRecord } from "../TraceRecord.ts"; - export interface BrowserTraceCollectorShape { readonly record: (records: ReadonlyArray) => Effect.Effect; } diff --git a/apps/server/src/observability/TraceSink.test.ts b/apps/server/src/observability/TraceSink.test.ts deleted file mode 100644 index f4db90516b1..00000000000 --- a/apps/server/src/observability/TraceSink.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { assert, describe, it } from "@effect/vitest"; -import { Effect } from "effect"; - -import type { TraceRecord } from "./TraceRecord.ts"; -import { makeTraceSink } from "./TraceSink.ts"; - -const makeRecord = (name: string, suffix = ""): TraceRecord => ({ - type: "effect-span", - name, - traceId: `trace-${name}-${suffix}`, - spanId: `span-${name}-${suffix}`, - sampled: true, - kind: "internal", - startTimeUnixNano: "1", - endTimeUnixNano: "2", - durationMs: 1, - attributes: { - payload: suffix, - }, - events: [], - links: [], - exit: { - _tag: "Success", - }, -}); - -describe("TraceSink", () => { - it.effect("flushes buffered records on close", () => - Effect.scoped( - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); - const tracePath = path.join(tempDir, "server.trace.ndjson"); - - try { - const sink = yield* makeTraceSink({ - filePath: tracePath, - maxBytes: 1024, - maxFiles: 2, - batchWindowMs: 10_000, - }); - - sink.push(makeRecord("alpha")); - sink.push(makeRecord("beta")); - yield* sink.close(); - - const lines = fs - .readFileSync(tracePath, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as TraceRecord); - - assert.equal(lines.length, 2); - assert.equal(lines[0]?.name, "alpha"); - assert.equal(lines[1]?.name, "beta"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ), - ); - - it.effect("rotates the trace file when the configured max size is exceeded", () => - Effect.scoped( - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); - const tracePath = path.join(tempDir, "server.trace.ndjson"); - - try { - const sink = yield* makeTraceSink({ - filePath: tracePath, - maxBytes: 180, - maxFiles: 2, - batchWindowMs: 10_000, - }); - - for (let index = 0; index < 8; index += 1) { - sink.push(makeRecord("rotate", `${index}-${"x".repeat(48)}`)); - yield* sink.flush; - } - yield* sink.close(); - - const matchingFiles = fs - .readdirSync(tempDir) - .filter( - (entry) => - entry === "server.trace.ndjson" || entry.startsWith("server.trace.ndjson."), - ) - .toSorted(); - - assert.equal( - matchingFiles.some((entry) => entry === "server.trace.ndjson.1"), - true, - ); - assert.equal( - matchingFiles.some((entry) => entry === "server.trace.ndjson.3"), - false, - ); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ), - ); - - it.effect("drops only the invalid record when serialization fails", () => - Effect.scoped( - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); - const tracePath = path.join(tempDir, "server.trace.ndjson"); - - try { - const sink = yield* makeTraceSink({ - filePath: tracePath, - maxBytes: 1024, - maxFiles: 2, - batchWindowMs: 10_000, - }); - - const circular: Array = []; - circular.push(circular); - - sink.push(makeRecord("alpha")); - sink.push({ - ...makeRecord("invalid"), - attributes: { - circular, - }, - } as TraceRecord); - sink.push(makeRecord("beta")); - yield* sink.close(); - - const lines = fs - .readFileSync(tracePath, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as TraceRecord); - - assert.deepStrictEqual( - lines.map((line) => line.name), - ["alpha", "beta"], - ); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ), - ); -}); diff --git a/apps/server/src/observability/TraceSink.ts b/apps/server/src/observability/TraceSink.ts deleted file mode 100644 index 1bd00b47341..00000000000 --- a/apps/server/src/observability/TraceSink.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { RotatingFileSink } from "@t3tools/shared/logging"; -import { Effect } from "effect"; - -import type { TraceRecord } from "./TraceRecord.ts"; - -const FLUSH_BUFFER_THRESHOLD = 32; - -export interface TraceSinkOptions { - readonly filePath: string; - readonly maxBytes: number; - readonly maxFiles: number; - readonly batchWindowMs: number; -} - -export interface TraceSink { - readonly filePath: string; - push: (record: TraceRecord) => void; - flush: Effect.Effect; - close: () => Effect.Effect; -} - -export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: TraceSinkOptions) { - const sink = new RotatingFileSink({ - filePath: options.filePath, - maxBytes: options.maxBytes, - maxFiles: options.maxFiles, - }); - - let buffer: Array = []; - - const flushUnsafe = () => { - if (buffer.length === 0) { - return; - } - - const chunk = buffer.join(""); - buffer = []; - - try { - sink.write(chunk); - } catch { - buffer.unshift(chunk); - } - }; - - const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false)); - - yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore)); - yield* Effect.forkScoped( - Effect.sleep(`${options.batchWindowMs} millis`).pipe(Effect.andThen(flush), Effect.forever), - ); - - return { - filePath: options.filePath, - push(record) { - try { - buffer.push(`${JSON.stringify(record)}\n`); - if (buffer.length >= FLUSH_BUFFER_THRESHOLD) { - flushUnsafe(); - } - } catch { - return; - } - }, - flush, - close: () => flush, - } satisfies TraceSink; -}); diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 375eec049a2..3c676b22ee5 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -113,6 +113,7 @@ async function waitForFileContent(filePath: string, attempts = 40) { // tests assumed. const makeResolveCursorSettings = Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; + // @effect-diagnostics effect/returnEffectInGen:off return serverSettings.getSettings.pipe( Effect.map((snapshot) => snapshot.providers.cursor), Effect.orDie, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 370e5c028ff..76b9da7b32c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -193,9 +193,7 @@ const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({ const OpenCodeAdapterTestLayer = Layer.effect( OpenCodeAdapter, - Effect.gen(function* () { - return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings); - }), + makeOpenCodeAdapter(openCodeAdapterTestSettings), ).pipe( Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), @@ -390,14 +388,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { ); it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { - const customInstanceId = ProviderInstanceId.make("opencode_zen"); + const instanceId = ProviderInstanceId.make("opencode_zen"); const adapterLayer = Layer.effect( OpenCodeAdapter, - Effect.gen(function* () { - return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { - instanceId: customInstanceId, - }); - }), + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), ).pipe( Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), @@ -441,14 +435,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }); it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => { - const customInstanceId = ProviderInstanceId.make("opencode_zen"); + const instanceId = ProviderInstanceId.make("opencode_zen"); const adapterLayer = Layer.effect( OpenCodeAdapter, - Effect.gen(function* () { - return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { - instanceId: customInstanceId, - }); - }), + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), ).pipe( Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), @@ -487,14 +477,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }); it.effect("rejects sendTurn model selections for another instance id", () => { - const customInstanceId = ProviderInstanceId.make("opencode_zen"); + const instanceId = ProviderInstanceId.make("opencode_zen"); const adapterLayer = Layer.effect( OpenCodeAdapter, - Effect.gen(function* () { - return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { - instanceId: customInstanceId, - }); - }), + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), ).pipe( Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), @@ -634,10 +620,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const adapterLayer = Layer.effect( OpenCodeAdapter, - Effect.gen(function* () { - return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { - nativeEventLogger, - }); + makeOpenCodeAdapter(openCodeAdapterTestSettings, { + nativeEventLogger, }), ).pipe( Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1bf71ac12dc..e7d488e317f 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -153,13 +153,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () function* (destinationPath: string) { const trimmed = destinationPath.trim(); if (trimmed.length === 0) { - return yield* Effect.fail( - repositoryError({ - operation: "cloneRepository", - provider: "unknown", - detail: "Choose a destination path before cloning.", - }), - ); + return yield* repositoryError({ + operation: "cloneRepository", + provider: "unknown", + detail: "Choose a destination path before cloning.", + }); } return path.resolve(expandHomePath(trimmed, path)); @@ -183,13 +181,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () ), ); if (entries.length > 0) { - return yield* Effect.fail( - repositoryError({ - operation: "cloneRepository", - provider: "unknown", - detail: "Destination path already exists and is not empty.", - }), - ); + return yield* repositoryError({ + operation: "cloneRepository", + provider: "unknown", + detail: "Destination path already exists and is not empty.", + }); } } else { yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true }); @@ -222,13 +218,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () } if (!remoteUrl) { - return yield* Effect.fail( - repositoryError({ - operation: "cloneRepository", - provider, - detail: "Enter a repository path or clone URL before cloning.", - }), - ); + return yield* repositoryError({ + operation: "cloneRepository", + provider, + detail: "Enter a repository path or clone URL before cloning.", + }); } yield* git.execute({ diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 6569cec2d27..489e3b3583b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -20,7 +20,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type VcsRef } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; -import { compactTraceAttributes } from "../observability/Attributes.ts"; +import { compactTraceAttributes } from "@t3tools/shared/observability"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { @@ -29,7 +30,6 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import { ServerConfig } from "../config.ts"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 4dd68d7213f..c721aa85f99 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -18,7 +18,7 @@ "namespaceImportPackages": ["@effect/platform-node"], "diagnosticSeverity": { "importFromBarrel": "error", - "anyUnknownInErrorContext": "warning", + "anyUnknownInErrorContext": "error", "instanceOfSchema": "warning", "deterministicKeys": "warning" } diff --git a/bun.lock b/bun.lock index c1c52a5b837..ae9812f6632 100644 --- a/bun.lock +++ b/bun.lock @@ -20,10 +20,12 @@ "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", - "electron": "40.9.3", + "electron": "41.5.0", "electron-updater": "^6.6.2", }, "devDependencies": { + "@effect/language-service": "catalog:", + "@effect/vitest": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", @@ -1150,7 +1152,7 @@ "effect-codex-app-server": ["effect-codex-app-server@workspace:packages/effect-codex-app-server"], - "electron": ["electron@40.9.3", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-rDcJOT6BBE689Ada+4jD3rVr05pMv9MZOgT0x/rIMVDF9c4ttx4RTb6lVARTyxZC7uqpirttCtcli1eg1DX5qg=="], + "electron": ["electron@41.5.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-x9j9//PubUA4EjDtQbZhtk3prolandqCKgit0uCIqc1jb8FTskPbnJtxcDFB1aejczJcuERgjPixBUaMwoWyJg=="], "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="], diff --git a/packages/client-runtime/tsconfig.json b/packages/client-runtime/tsconfig.json index 564a5990051..bd559329ef3 100644 --- a/packages/client-runtime/tsconfig.json +++ b/packages/client-runtime/tsconfig.json @@ -1,4 +1,18 @@ { "extends": "../../tsconfig.base.json", + "compilerOptions": { + "plugins": [ + { + "name": "@effect/language-service", + "namespaceImportPackages": ["@effect/platform-node", "effect"], + "diagnosticSeverity": { + "importFromBarrel": "error", + "anyUnknownInErrorContext": "error", + "instanceOfSchema": "warning", + "deterministicKeys": "warning" + } + } + ] + }, "include": ["src"] } diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 8110104e198..8439d12b069 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 10ee6851be2..5baf426a8c0 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -1,10 +1,11 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; export const TrimmedString = Schema.Trim; export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()); export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); +export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); export const IsoDateTime = Schema.String; export type IsoDateTime = typeof IsoDateTime.Type; diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts new file mode 100644 index 00000000000..c23dbbb3960 --- /dev/null +++ b/packages/contracts/src/desktopBootstrap.ts @@ -0,0 +1,18 @@ +import * as Schema from "effect/Schema"; + +import { PortSchema } from "./baseSchemas.ts"; + +export const DesktopBackendBootstrap = Schema.Struct({ + mode: Schema.Literal("desktop"), + noBrowser: Schema.Boolean, + port: PortSchema, + t3Home: Schema.String, + host: Schema.String, + desktopBootstrapToken: Schema.String, + tailscaleServeEnabled: Schema.Boolean, + tailscaleServePort: PortSchema, + otlpTracesUrl: Schema.optional(Schema.String), + otlpMetricsUrl: Schema.optional(Schema.String), +}); + +export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type; diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index f3e6926aa38..4bf84d8294e 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const EditorLaunchStyle = Schema.Literals(["direct-path", "goto", "line-column"]); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index aa34c339a39..fb52972d5aa 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -1,4 +1,5 @@ -import { Effect, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index a518e2e9acd..37da5863429 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; const FILESYSTEM_PATH_MAX_LENGTH = 512; diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index b4f7bc213ce..33eb0a8339f 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { VcsCreateWorktreeInput, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index b5fa114d789..0b155bf49b7 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; import { VcsDriverKind } from "./vcs.ts"; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 1a3647eb314..8402c82647d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,6 +1,7 @@ export * from "./baseSchemas.ts"; export * from "./auth.ts"; export * from "./environment.ts"; +export * from "./desktopBootstrap.ts"; export * from "./remoteAccess.ts"; export * from "./ipc.ts"; export * from "./terminal.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index eca3bb4e66b..abe8af5022f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -48,6 +48,7 @@ import type { TerminalWriteInput, } from "./terminal.ts"; import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts"; +import * as Schema from "effect/Schema"; import type { ClientOrchestrationCommand, OrchestrationGetFullThreadDiffInput, @@ -58,15 +59,11 @@ import type { OrchestrationSubscribeThreadInput, OrchestrationThreadStreamItem, } from "./orchestration.ts"; -import type { EnvironmentId } from "./baseSchemas.ts"; -import type { - AuthBearerBootstrapResult, - AuthSessionState, - AuthWebSocketTokenResult, -} from "./auth.ts"; -import type { AdvertisedEndpoint } from "./remoteAccess.ts"; +import { EnvironmentId } from "./baseSchemas.ts"; +import { AuthBearerBootstrapResult, AuthSessionState, AuthWebSocketTokenResult } from "./auth.ts"; +import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { EditorId } from "./editor.ts"; -import type { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts"; import type { SourceControlCloneRepositoryInput, @@ -86,6 +83,26 @@ export interface ContextMenuItem { children?: readonly ContextMenuItem[]; } +export interface ContextMenuItemSchemaType { + readonly id: string; + readonly label: string; + readonly destructive?: boolean; + readonly disabled?: boolean; + readonly children?: readonly ContextMenuItemSchemaType[]; +} + +export const ContextMenuItemSchema: Schema.Codec = Schema.Struct({ + id: Schema.String, + label: Schema.String, + destructive: Schema.optionalKey(Schema.Boolean), + disabled: Schema.optionalKey(Schema.Boolean), + children: Schema.optionalKey( + Schema.Array( + Schema.suspend((): Schema.Codec => ContextMenuItemSchema), + ), + ), +}); + export type DesktopUpdateStatus = | "disabled" | "idle" @@ -101,18 +118,45 @@ export type DesktopTheme = "light" | "dark" | "system"; export type DesktopUpdateChannel = "latest" | "nightly"; export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; +export const DesktopUpdateStatusSchema = Schema.Literals([ + "disabled", + "idle", + "checking", + "up-to-date", + "available", + "downloading", + "downloaded", + "error", +]); +export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other"]); +export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); +export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); +export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); + export interface DesktopAppBranding { baseName: string; stageLabel: DesktopAppStageLabel; displayName: string; } +export const DesktopAppBrandingSchema = Schema.Struct({ + baseName: Schema.String, + stageLabel: DesktopAppStageLabelSchema, + displayName: Schema.String, +}); + export interface DesktopRuntimeInfo { hostArch: DesktopRuntimeArch; appArch: DesktopRuntimeArch; runningUnderArm64Translation: boolean; } +export const DesktopRuntimeInfoSchema = Schema.Struct({ + hostArch: DesktopRuntimeArchSchema, + appArch: DesktopRuntimeArchSchema, + runningUnderArm64Translation: Schema.Boolean, +}); + export interface DesktopUpdateState { enabled: boolean; status: DesktopUpdateStatus; @@ -130,17 +174,45 @@ export interface DesktopUpdateState { canRetry: boolean; } +export const DesktopUpdateStateSchema = Schema.Struct({ + enabled: Schema.Boolean, + status: DesktopUpdateStatusSchema, + channel: DesktopUpdateChannelSchema, + currentVersion: Schema.String, + hostArch: DesktopRuntimeArchSchema, + appArch: DesktopRuntimeArchSchema, + runningUnderArm64Translation: Schema.Boolean, + availableVersion: Schema.NullOr(Schema.String), + downloadedVersion: Schema.NullOr(Schema.String), + downloadPercent: Schema.NullOr(Schema.Number), + checkedAt: Schema.NullOr(Schema.String), + message: Schema.NullOr(Schema.String), + errorContext: Schema.NullOr(Schema.Literals(["check", "download", "install"])), + canRetry: Schema.Boolean, +}); + export interface DesktopUpdateActionResult { accepted: boolean; completed: boolean; state: DesktopUpdateState; } +export const DesktopUpdateActionResultSchema = Schema.Struct({ + accepted: Schema.Boolean, + completed: Schema.Boolean, + state: DesktopUpdateStateSchema, +}); + export interface DesktopUpdateCheckResult { checked: boolean; state: DesktopUpdateState; } +export const DesktopUpdateCheckResultSchema = Schema.Struct({ + checked: Schema.Boolean, + state: DesktopUpdateStateSchema, +}); + export interface DesktopEnvironmentBootstrap { label: string; httpBaseUrl: string | null; @@ -148,19 +220,36 @@ export interface DesktopEnvironmentBootstrap { bootstrapToken?: string; } -export interface DesktopSshEnvironmentTarget { - alias: string; - hostname: string; - username: string | null; - port: number | null; -} +export const DesktopEnvironmentBootstrapSchema = Schema.Struct({ + label: Schema.String, + httpBaseUrl: Schema.NullOr(Schema.String), + wsBaseUrl: Schema.NullOr(Schema.String), + bootstrapToken: Schema.optionalKey(Schema.String), +}); + +export const DesktopSshEnvironmentTargetSchema = Schema.Struct({ + alias: Schema.String, + hostname: Schema.String, + username: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Number), +}); +export type DesktopSshEnvironmentTarget = typeof DesktopSshEnvironmentTargetSchema.Type; export type DesktopSshHostSource = "ssh-config" | "known-hosts"; +export const DesktopSshHostSourceSchema = Schema.Literals(["ssh-config", "known-hosts"]); export interface DesktopDiscoveredSshHost extends DesktopSshEnvironmentTarget { source: DesktopSshHostSource; } +export const DesktopDiscoveredSshHostSchema = Schema.Struct({ + alias: Schema.String, + hostname: Schema.String, + username: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Number), + source: DesktopSshHostSourceSchema, +}); + export interface DesktopSshEnvironmentBootstrap { target: DesktopSshEnvironmentTarget; httpBaseUrl: string; @@ -170,6 +259,15 @@ export interface DesktopSshEnvironmentBootstrap { remoteServerKind?: "external" | "managed"; } +export const DesktopSshEnvironmentBootstrapSchema = Schema.Struct({ + target: DesktopSshEnvironmentTargetSchema, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + pairingToken: Schema.NullOr(Schema.String), + remotePort: Schema.optionalKey(Schema.Number), + remoteServerKind: Schema.optionalKey(Schema.Literals(["external", "managed"])), +}); + export interface DesktopSshPasswordPromptRequest { requestId: string; destination: string; @@ -178,18 +276,72 @@ export interface DesktopSshPasswordPromptRequest { expiresAt: string; } -export interface PersistedSavedEnvironmentRecord { - environmentId: EnvironmentId; - label: string; - wsBaseUrl: string; - httpBaseUrl: string; - createdAt: string; - lastConnectedAt: string | null; - desktopSsh?: DesktopSshEnvironmentTarget; -} +export const DesktopSshPasswordPromptRequestSchema = Schema.Struct({ + requestId: Schema.String, + destination: Schema.String, + username: Schema.NullOr(Schema.String), + prompt: Schema.String, + expiresAt: Schema.String, +}); + +export const DesktopSshPasswordPromptCancelledType = "ssh-password-prompt-cancelled" as const; + +export const DesktopSshPasswordPromptCancelledResultSchema = Schema.Struct({ + type: Schema.Literal(DesktopSshPasswordPromptCancelledType), + message: Schema.String, +}); + +export const DesktopSshEnvironmentEnsureOptionsSchema = Schema.Struct({ + issuePairingToken: Schema.optionalKey(Schema.Boolean), +}); + +export const DesktopSshEnvironmentEnsureInputSchema = Schema.Struct({ + target: DesktopSshEnvironmentTargetSchema, + options: Schema.optionalKey(DesktopSshEnvironmentEnsureOptionsSchema), +}); + +export const DesktopSshEnvironmentEnsureResultSchema = Schema.Union([ + DesktopSshEnvironmentBootstrapSchema, + DesktopSshPasswordPromptCancelledResultSchema, +]); + +export const DesktopSshHttpBaseUrlInputSchema = Schema.Struct({ + httpBaseUrl: Schema.String, +}); + +export const DesktopSshBearerRequestInputSchema = Schema.Struct({ + httpBaseUrl: Schema.String, + bearerToken: Schema.String, +}); + +export const DesktopSshBearerBootstrapInputSchema = Schema.Struct({ + httpBaseUrl: Schema.String, + credential: Schema.String, +}); + +export const DesktopSshPasswordPromptResolutionInputSchema = Schema.Struct({ + requestId: Schema.String, + password: Schema.NullOr(Schema.String), +}); + +export const PersistedSavedEnvironmentRecordSchema = Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + wsBaseUrl: Schema.String, + httpBaseUrl: Schema.String, + createdAt: Schema.String, + lastConnectedAt: Schema.NullOr(Schema.String), + desktopSsh: Schema.optionalKey(DesktopSshEnvironmentTargetSchema), +}); +export type PersistedSavedEnvironmentRecord = typeof PersistedSavedEnvironmentRecordSchema.Type; export type DesktopServerExposureMode = "local-only" | "network-accessible"; +export const DesktopServerExposureModeSchema = Schema.Literals([ + "local-only", + "network-accessible", +]); + export interface DesktopServerExposureState { mode: DesktopServerExposureMode; endpointUrl: string | null; @@ -198,10 +350,22 @@ export interface DesktopServerExposureState { tailscaleServePort: number; } +export const DesktopServerExposureStateSchema = Schema.Struct({ + mode: DesktopServerExposureModeSchema, + endpointUrl: Schema.NullOr(Schema.String), + advertisedHost: Schema.NullOr(Schema.String), + tailscaleServeEnabled: Schema.Boolean, + tailscaleServePort: Schema.Number, +}); + export interface PickFolderOptions { initialPath?: string | null; } +export const PickFolderOptionsSchema = Schema.Struct({ + initialPath: Schema.optionalKey(Schema.NullOr(Schema.String)), +}); + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index f040697013b..2165597ac30 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -1,6 +1,6 @@ -import { Schema } from "effect"; import { assert, it } from "@effect/vitest"; -import { Effect } from "effect"; +import * as Schema from "effect/Schema"; +import * as Effect from "effect/Effect"; import { KeybindingsConfig, diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 01587c2a644..502e564fb82 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { TrimmedString } from "./baseSchemas.ts"; export const MAX_KEYBINDING_VALUE_LENGTH = 64; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index cd8ab45a4bd..85ee815818e 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,4 +1,6 @@ -import { Effect, Schema, SchemaTransformation } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ProviderDriverKind } from "./providerInstance.ts"; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 605d0375342..73758de0724 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { it } from "@effect/vitest"; -import { Effect, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { DEFAULT_PROVIDER_INTERACTION_MODE, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 047ef9e5cd9..44d840d1499 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,4 +1,9 @@ -import { Effect, Option, Schema, SchemaIssue, SchemaTransformation, Struct } from "effect"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; +import * as SchemaTransformation from "effect/SchemaTransformation"; +import * as Struct from "effect/Struct"; import { ProviderOptionSelections } from "./model.ts"; import { RepositoryIdentity } from "./environment.ts"; import { diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index d089951bc07..c99cff3133e 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 67318d0e33f..d3df41ba9f4 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { ProviderEvent, diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index e8ad017d7b0..94fb007a7bc 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ApprovalRequestId, diff --git a/packages/contracts/src/providerInstance.test.ts b/packages/contracts/src/providerInstance.test.ts index 9139001cf3b..1a2ffbb9954 100644 --- a/packages/contracts/src/providerInstance.test.ts +++ b/packages/contracts/src/providerInstance.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { ProviderDriverKind, diff --git a/packages/contracts/src/providerInstance.ts b/packages/contracts/src/providerInstance.ts index d5bb25f7729..2a9fc9ed0d1 100644 --- a/packages/contracts/src/providerInstance.ts +++ b/packages/contracts/src/providerInstance.ts @@ -33,7 +33,8 @@ * * @module providerInstance */ -import { Effect, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; const PROVIDER_SLUG_MAX_CHARS = 64; diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index 42c0117935b..587c1879454 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { ProviderRuntimeEvent } from "./providerRuntime.ts"; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index f5e4e7452a8..5032dc4eb41 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -1,4 +1,5 @@ -import { Effect, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { EventId, IsoDateTime, diff --git a/packages/contracts/src/remoteAccess.ts b/packages/contracts/src/remoteAccess.ts index 70a20d7aeb4..e3de3c29e12 100644 --- a/packages/contracts/src/remoteAccess.ts +++ b/packages/contracts/src/remoteAccess.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 1c3ddc93739..eb72b14f9e5 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index cae68e1f64b..a2ad0cbb383 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vitest"; import { ServerProvider } from "./server.ts"; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 15afea93ad9..0081c00ac7e 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,4 +1,5 @@ -import { Effect, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { ServerAuthDescriptor } from "./auth.ts"; import { diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index d2b73f567a7..8c292827927 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { ProviderInstanceId } from "./providerInstance.ts"; import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts"; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 29231e2f5f3..fa0a741114b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 0384b5d18da..a8764a68763 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { VcsDriverKind } from "./vcs.ts"; diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 514320f6d4d..2401e38d4e0 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vitest"; import { diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index c4d9972d8c3..b91e4cb89e0 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,4 +1,5 @@ -import { Effect, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const DEFAULT_TERMINAL_ID = "default"; diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index 2dd09cf04c6..25f6c04c6dc 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]); diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json index 92d639f99bd..bd559329ef3 100644 --- a/packages/contracts/tsconfig.json +++ b/packages/contracts/tsconfig.json @@ -4,10 +4,10 @@ "plugins": [ { "name": "@effect/language-service", - "namespaceImportPackages": ["@effect/platform-node"], + "namespaceImportPackages": ["@effect/platform-node", "effect"], "diagnosticSeverity": { "importFromBarrel": "error", - "anyUnknownInErrorContext": "warning", + "anyUnknownInErrorContext": "error", "instanceOfSchema": "warning", "deterministicKeys": "warning" } diff --git a/packages/effect-acp/scripts/generate.ts b/packages/effect-acp/scripts/generate.ts index f2837c4f859..0e345b2349b 100644 --- a/packages/effect-acp/scripts/generate.ts +++ b/packages/effect-acp/scripts/generate.ts @@ -3,7 +3,12 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator"; -import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; diff --git a/packages/effect-acp/tsconfig.json b/packages/effect-acp/tsconfig.json index 61162f9454a..b66569ba110 100644 --- a/packages/effect-acp/tsconfig.json +++ b/packages/effect-acp/tsconfig.json @@ -4,10 +4,10 @@ "plugins": [ { "name": "@effect/language-service", - "namespaceImportPackages": ["@effect/platform-node"], + "namespaceImportPackages": ["@effect/platform-node", "effect"], "diagnosticSeverity": { "importFromBarrel": "error", - "anyUnknownInErrorContext": "warning", + "anyUnknownInErrorContext": "error", "instanceOfSchema": "warning", "deterministicKeys": "warning" } diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index 70318c3aae5..b8066c9325e 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -3,7 +3,12 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator"; -import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { FetchHttpClient, HttpClient, diff --git a/packages/effect-codex-app-server/tsconfig.json b/packages/effect-codex-app-server/tsconfig.json index 61162f9454a..b66569ba110 100644 --- a/packages/effect-codex-app-server/tsconfig.json +++ b/packages/effect-codex-app-server/tsconfig.json @@ -4,10 +4,10 @@ "plugins": [ { "name": "@effect/language-service", - "namespaceImportPackages": ["@effect/platform-node"], + "namespaceImportPackages": ["@effect/platform-node", "effect"], "diagnosticSeverity": { "importFromBarrel": "error", - "anyUnknownInErrorContext": "warning", + "anyUnknownInErrorContext": "error", "instanceOfSchema": "warning", "deterministicKeys": "warning" } diff --git a/packages/shared/package.json b/packages/shared/package.json index d42c2039a2b..5e785efc4d7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -20,6 +20,10 @@ "types": "./src/logging.ts", "import": "./src/logging.ts" }, + "./observability": { + "types": "./src/observability.ts", + "import": "./src/observability.ts" + }, "./shell": { "types": "./src/shell.ts", "import": "./src/shell.ts" diff --git a/packages/shared/src/DrainableWorker.test.ts b/packages/shared/src/DrainableWorker.test.ts index 0033038d0c5..7fc1a5fc23d 100644 --- a/packages/shared/src/DrainableWorker.test.ts +++ b/packages/shared/src/DrainableWorker.test.ts @@ -1,6 +1,7 @@ import { it } from "@effect/vitest"; import { describe, expect } from "vitest"; -import { Deferred, Effect } from "effect"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; import { makeDrainableWorker } from "./DrainableWorker.ts"; diff --git a/packages/shared/src/DrainableWorker.ts b/packages/shared/src/DrainableWorker.ts index 55483f33e8c..de40ec5e36b 100644 --- a/packages/shared/src/DrainableWorker.ts +++ b/packages/shared/src/DrainableWorker.ts @@ -8,8 +8,10 @@ * * @module DrainableWorker */ -import type { Scope } from "effect"; -import { Effect, TxQueue, TxRef } from "effect"; +import * as Scope from "effect/Scope"; +import * as Effect from "effect/Effect"; +import * as TxQueue from "effect/TxQueue"; +import * as TxRef from "effect/TxRef"; export interface DrainableWorker { /** diff --git a/packages/shared/src/KeyedCoalescingWorker.test.ts b/packages/shared/src/KeyedCoalescingWorker.test.ts index 78c3a6b9102..a010d732310 100644 --- a/packages/shared/src/KeyedCoalescingWorker.test.ts +++ b/packages/shared/src/KeyedCoalescingWorker.test.ts @@ -1,6 +1,7 @@ import { it } from "@effect/vitest"; import { describe, expect } from "vitest"; -import { Deferred, Effect } from "effect"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; import { makeKeyedCoalescingWorker } from "./KeyedCoalescingWorker.ts"; @@ -73,7 +74,7 @@ describe("makeKeyedCoalescingWorker", () => { if (value === "first") { yield* Deferred.succeed(firstStarted, undefined).pipe(Effect.orDie); yield* Deferred.await(releaseFailure); - yield* Effect.fail("boom"); + return yield* Effect.fail("boom"); } if (value === "second") { diff --git a/packages/shared/src/KeyedCoalescingWorker.ts b/packages/shared/src/KeyedCoalescingWorker.ts index 567c1dac173..f4edebfafb3 100644 --- a/packages/shared/src/KeyedCoalescingWorker.ts +++ b/packages/shared/src/KeyedCoalescingWorker.ts @@ -7,8 +7,10 @@ * * @module KeyedCoalescingWorker */ -import type { Scope } from "effect"; -import { Effect, TxQueue, TxRef } from "effect"; +import * as Scope from "effect/Scope"; +import * as Effect from "effect/Effect"; +import * as TxQueue from "effect/TxQueue"; +import * as TxRef from "effect/TxRef"; export interface KeyedCoalescingWorker { readonly enqueue: (key: K, value: V) => Effect.Effect; diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index 19033a082b4..b8e74f1f1cc 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -1,11 +1,11 @@ -import * as Net from "node:net"; +import * as NodeNet from "node:net"; import { assert, describe, it } from "@effect/vitest"; -import { Effect } from "effect"; +import * as Effect from "effect/Effect"; -import { NetError, NetService } from "./Net.ts"; +import * as Net from "./Net.ts"; -const closeServer = (server: Net.Server) => +const closeServer = (server: NodeNet.Server) => Effect.sync(() => { try { server.close(); @@ -14,24 +14,24 @@ const closeServer = (server: Net.Server) => } }); -const getPort = (server: Net.Server): number => { +const getPort = (server: NodeNet.Server): number => { const address = server.address(); return typeof address === "object" && address !== null ? address.port : 0; }; -const openServer = (host?: string): Effect.Effect => - Effect.callback((resume) => { - const server = Net.createServer(); +const openServer = (host?: string): Effect.Effect => + Effect.callback((resume) => { + const server = NodeNet.createServer(); let settled = false; - const settle = (effect: Effect.Effect) => { + const settle = (effect: Effect.Effect) => { if (settled) return; settled = true; resume(effect); }; server.once("error", (cause) => { - settle(Effect.fail(new NetError({ message: "Failed to open test server", cause }))); + settle(Effect.fail(new Net.NetError({ message: "Failed to open test server", cause }))); }); if (host) { @@ -43,11 +43,11 @@ const openServer = (host?: string): Effect.Effect => return closeServer(server); }); -it.layer(NetService.layer)("NetService", (it) => { +it.layer(Net.NetService.layer)("NetService", (it) => { describe("Net helpers", () => { it.effect("reserveLoopbackPort returns a positive loopback port", () => Effect.gen(function* () { - const net = yield* NetService; + const net = yield* Net.NetService; const port = yield* net.reserveLoopbackPort(); assert.ok(port > 0); @@ -59,7 +59,7 @@ it.layer(NetService.layer)("NetService", (it) => { openServer("127.0.0.1"), (server) => Effect.gen(function* () { - const net = yield* NetService; + const net = yield* Net.NetService; const port = getPort(server); const available = yield* net.isPortAvailableOnLoopback(port); @@ -71,7 +71,7 @@ it.layer(NetService.layer)("NetService", (it) => { it.effect("findAvailablePort returns preferred when it is free", () => Effect.gen(function* () { - const net = yield* NetService; + const net = yield* Net.NetService; const preferred = yield* net.reserveLoopbackPort(); const resolved = yield* net.findAvailablePort(preferred); @@ -84,7 +84,7 @@ it.layer(NetService.layer)("NetService", (it) => { openServer(), (server) => Effect.gen(function* () { - const net = yield* NetService; + const net = yield* Net.NetService; const preferred = getPort(server); const resolved = yield* net.findAvailablePort(preferred); diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index d0a44cc5724..22e80dffb3d 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -1,6 +1,9 @@ -import * as Net from "node:net"; +import * as NodeNet from "node:net"; -import { Data, Effect, Layer, Context } from "effect"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Context from "effect/Context"; export class NetError extends Data.TaggedError("NetError")<{ readonly message: string; @@ -18,7 +21,7 @@ function isErrnoExceptionWithCode(cause: unknown): cause is { ); } -const closeServer = (server: Net.Server) => { +const closeServer = (server: NodeNet.Server) => { try { server.close(); } catch { @@ -28,7 +31,7 @@ const closeServer = (server: Net.Server) => { const tryReservePort = (port: number): Effect.Effect => Effect.callback((resume) => { - const server = Net.createServer(); + const server = NodeNet.createServer(); let settled = false; const settle = (effect: Effect.Effect) => { @@ -82,99 +85,103 @@ export interface NetServiceShape { readonly findAvailablePort: (preferred: number) => Effect.Effect; } -/** - * NetService - Service tag for startup networking helpers. - */ -export class NetService extends Context.Service()( - "@t3tools/shared/Net/NetService", -) { - static readonly layer = Layer.sync(NetService, () => { - /** - * Returns true when a TCP server can bind to {host, port}. - * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail - * loopback availability checks. - */ - const canListenOnHost = (port: number, host: string): Effect.Effect => - Effect.callback((resume) => { - const server = Net.createServer(); - let settled = false; - - const settle = (value: boolean) => { - if (settled) return; - settled = true; - resume(Effect.succeed(value)); - }; - - server.unref(); - - server.once("error", (cause) => { - if (isErrnoExceptionWithCode(cause) && cause.code === "EADDRNOTAVAIL") { - settle(true); - return; - } - settle(false); - }); +export const make = () => { + /** + * Returns true when a TCP server can bind to {host, port}. + * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail + * loopback availability checks. + */ + const canListenOnHost = (port: number, host: string): Effect.Effect => + Effect.callback((resume) => { + const server = NodeNet.createServer(); + let settled = false; + + const settle = (value: boolean) => { + if (settled) return; + settled = true; + resume(Effect.succeed(value)); + }; + + server.unref(); + + server.once("error", (cause) => { + if (isErrnoExceptionWithCode(cause) && cause.code === "EADDRNOTAVAIL") { + settle(true); + return; + } + settle(false); + }); - server.once("listening", () => { - server.close(() => { - settle(true); - }); + server.once("listening", () => { + server.close(() => { + settle(true); }); + }); - server.listen({ host, port }); + server.listen({ host, port }); - return Effect.sync(() => { - closeServer(server); - }); + return Effect.sync(() => { + closeServer(server); }); + }); - /** - * Reserve an ephemeral loopback port and release it immediately. - * Returns the reserved port number. - */ - const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect => - Effect.callback((resume) => { - const probe = Net.createServer(); - let settled = false; - - const settle = (effect: Effect.Effect) => { - if (settled) return; - settled = true; - resume(effect); - }; - - probe.once("error", (cause) => { - settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port", cause }))); - }); + /** + * Reserve an ephemeral loopback port and release it immediately. + * Returns the reserved port number. + */ + const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect => + Effect.callback((resume) => { + const probe = NodeNet.createServer(); + let settled = false; + + const settle = (effect: Effect.Effect) => { + if (settled) return; + settled = true; + resume(effect); + }; + + probe.once("error", (cause) => { + settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port", cause }))); + }); - probe.listen(0, host, () => { - const address = probe.address(); - const port = typeof address === "object" && address !== null ? address.port : 0; - probe.close(() => { - if (port > 0) { - settle(Effect.succeed(port)); - return; - } - settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port" }))); - }); + probe.listen(0, host, () => { + const address = probe.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + probe.close(() => { + if (port > 0) { + settle(Effect.succeed(port)); + return; + } + settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port" }))); }); + }); - return Effect.sync(() => { - closeServer(probe); - }); + return Effect.sync(() => { + closeServer(probe); }); + }); - return { - canListenOnHost, - isPortAvailableOnLoopback: (port) => - Effect.zipWith( - canListenOnHost(port, "127.0.0.1"), - canListenOnHost(port, "::1"), - (ipv4, ipv6) => ipv4 && ipv6, - ), - reserveLoopbackPort, - findAvailablePort: (preferred) => - Effect.catch(tryReservePort(preferred), () => tryReservePort(0)), - } satisfies NetServiceShape; - }); + return { + canListenOnHost, + isPortAvailableOnLoopback: (port) => + Effect.zipWith( + canListenOnHost(port, "127.0.0.1"), + canListenOnHost(port, "::1"), + (ipv4, ipv6) => ipv4 && ipv6, + ), + reserveLoopbackPort, + findAvailablePort: (preferred) => + Effect.catch(tryReservePort(preferred), () => tryReservePort(0)), + } satisfies NetServiceShape; +}; + +/** + * NetService - Service tag for startup networking helpers. + */ +export class NetService extends Context.Service()( + "@t3tools/shared/Net/NetService", +) { + static readonly layer = Layer.sync(NetService, make); } + +export const layer = NetService.layer; diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts new file mode 100644 index 00000000000..644b9cf0e14 --- /dev/null +++ b/packages/shared/src/observability.test.ts @@ -0,0 +1,314 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; + +import { + compactTraceAttributes, + makeLocalFileTracer, + makeTraceSink, + type TraceRecord, +} from "./observability.ts"; + +const TraceRecordLine = Schema.Struct({ + name: Schema.String, + spanId: Schema.String, + parentSpanId: Schema.optional(Schema.String), + attributes: Schema.Record(Schema.String, Schema.Unknown), + events: Schema.Array( + Schema.Struct({ + name: Schema.String, + attributes: Schema.Record(Schema.String, Schema.Unknown), + }), + ), + exit: Schema.optional( + Schema.Struct({ + _tag: Schema.String, + }), + ), +}); + +const decodeTraceRecordLine = Schema.decodeUnknownSync(Schema.fromJsonString(TraceRecordLine)); + +const makeRecord = (name: string, suffix = ""): TraceRecord => ({ + type: "effect-span", + name, + traceId: `trace-${name}-${suffix}`, + spanId: `span-${name}-${suffix}`, + sampled: true, + kind: "internal", + startTimeUnixNano: "1", + endTimeUnixNano: "2", + durationMs: 1, + attributes: { + payload: suffix, + }, + events: [], + links: [], + exit: { + _tag: "Success", + }, +}); + +const readTraceRecords = (tracePath: string) => + fs + .readFileSync(tracePath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => decodeTraceRecordLine(line)); + +const makeTestLayer = (tracePath: string) => + Layer.mergeAll( + Layer.effect( + Tracer.Tracer, + makeLocalFileTracer({ + filePath: tracePath, + maxBytes: 1024 * 1024, + maxFiles: 2, + batchWindowMs: 10_000, + }), + ), + Logger.layer([Logger.tracerLogger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Info"), + ); + +describe("observability", () => { + it("normalizes circular arrays, maps, and sets without recursing forever", () => { + const array: Array = ["alpha"]; + array.push(array); + + const map = new Map(); + map.set("self", map); + + const set = new Set(); + set.add(set); + + assert.deepStrictEqual( + compactTraceAttributes({ + array, + map, + set, + }), + { + array: ["alpha", "[Circular]"], + map: { self: "[Circular]" }, + set: ["[Circular]"], + }, + ); + }); + + it("normalizes invalid dates without throwing", () => { + assert.deepStrictEqual( + compactTraceAttributes({ + invalidDate: new Date("not-a-real-date"), + }), + { + invalidDate: "Invalid Date", + }, + ); + }); + + it.effect("flushes buffered trace records on close", () => + Effect.scoped( + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); + const tracePath = path.join(tempDir, "shared.trace.ndjson"); + + try { + const sink = yield* makeTraceSink({ + filePath: tracePath, + maxBytes: 1024, + maxFiles: 2, + batchWindowMs: 10_000, + }); + + sink.push(makeRecord("alpha")); + sink.push(makeRecord("beta")); + yield* sink.close(); + + const lines = readTraceRecords(tracePath); + + assert.equal(lines.length, 2); + assert.equal(lines[0]?.name, "alpha"); + assert.equal(lines[1]?.name, "beta"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ), + ); + + it.effect("rotates the trace file when the configured max size is exceeded", () => + Effect.scoped( + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); + const tracePath = path.join(tempDir, "shared.trace.ndjson"); + + try { + const sink = yield* makeTraceSink({ + filePath: tracePath, + maxBytes: 180, + maxFiles: 2, + batchWindowMs: 10_000, + }); + + for (let index = 0; index < 8; index += 1) { + sink.push(makeRecord("rotate", `${index}-${"x".repeat(48)}`)); + yield* sink.flush; + } + yield* sink.close(); + + const matchingFiles = fs + .readdirSync(tempDir) + .filter( + (entry) => + entry === "shared.trace.ndjson" || entry.startsWith("shared.trace.ndjson."), + ) + .toSorted(); + + assert.equal( + matchingFiles.some((entry) => entry === "shared.trace.ndjson.1"), + true, + ); + assert.equal( + matchingFiles.some((entry) => entry === "shared.trace.ndjson.3"), + false, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ), + ); + + it.effect("drops only the invalid trace record when serialization fails", () => + Effect.scoped( + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); + const tracePath = path.join(tempDir, "shared.trace.ndjson"); + + try { + const sink = yield* makeTraceSink({ + filePath: tracePath, + maxBytes: 1024, + maxFiles: 2, + batchWindowMs: 10_000, + }); + + const circular: Array = []; + circular.push(circular); + + sink.push(makeRecord("alpha")); + sink.push({ + ...makeRecord("invalid"), + attributes: { + circular, + }, + } as TraceRecord); + sink.push(makeRecord("beta")); + yield* sink.close(); + + const lines = readTraceRecords(tracePath); + + assert.deepStrictEqual( + lines.map((line) => line.name), + ["alpha", "beta"], + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ), + ); + + it.effect("writes nested spans to disk and captures log messages as span events", () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); + const tracePath = path.join(tempDir, "shared.trace.ndjson"); + + try { + yield* Effect.scoped( + Effect.gen(function* () { + const program = Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "demo.parent": true, + }); + yield* Effect.logInfo("parent event"); + yield* Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "demo.child": true, + }); + yield* Effect.logInfo("child event"); + }).pipe(Effect.withSpan("child-span")); + }).pipe(Effect.withSpan("parent-span")); + + yield* program.pipe(Effect.provide(makeTestLayer(tracePath))); + }), + ); + + const records = readTraceRecords(tracePath); + assert.equal(records.length, 2); + + const parent = records.find((record) => record.name === "parent-span"); + const child = records.find((record) => record.name === "child-span"); + + assert.notEqual(parent, undefined); + assert.notEqual(child, undefined); + if (!parent || !child) { + return; + } + + assert.equal(child.parentSpanId, parent.spanId); + assert.equal(parent.attributes["demo.parent"], true); + assert.equal(child.attributes["demo.child"], true); + assert.equal( + parent.events.some((event) => event.name === "parent event"), + true, + ); + assert.equal( + child.events.some((event) => event.name === "child event"), + true, + ); + assert.equal( + child.events.some((event) => event.attributes["effect.logLevel"] === "INFO"), + true, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("serializes interrupted spans with an interrupted exit status", () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); + const tracePath = path.join(tempDir, "shared.trace.ndjson"); + + try { + yield* Effect.scoped( + Effect.exit( + Effect.interrupt.pipe( + Effect.withSpan("interrupt-span"), + Effect.provide(makeTestLayer(tracePath)), + ), + ), + ); + + const records = readTraceRecords(tracePath); + assert.equal(records.length, 1); + assert.equal(records[0]?.name, "interrupt-span"); + assert.equal(records[0]?.exit?._tag, "Interrupted"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); +}); diff --git a/apps/server/src/observability/TraceRecord.ts b/packages/shared/src/observability.ts similarity index 53% rename from apps/server/src/observability/TraceRecord.ts rename to packages/shared/src/observability.ts index a9a598288b1..4a49545e393 100644 --- a/apps/server/src/observability/TraceRecord.ts +++ b/packages/shared/src/observability.ts @@ -1,15 +1,24 @@ -import { Cause, Exit, Option, Tracer } from "effect"; - -import { compactTraceAttributes } from "./Attributes.ts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import type * as Exit from "effect/Exit"; +import * as ExitRuntime from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Tracer from "effect/Tracer"; import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; -interface TraceRecordEvent { +import { RotatingFileSink } from "./logging.ts"; + +const FLUSH_BUFFER_THRESHOLD = 32; + +export type TraceAttributes = Readonly>; + +export interface TraceRecordEvent { readonly name: string; readonly timeUnixNano: string; readonly attributes: Readonly>; } -interface TraceRecordLink { +export interface TraceRecordLink { readonly traceId: string; readonly spanId: string; readonly attributes: Readonly>; @@ -46,7 +55,7 @@ export interface EffectTraceRecord extends BaseTraceRecord { }; } -interface OtlpTraceRecord extends BaseTraceRecord { +export interface OtlpTraceRecord extends BaseTraceRecord { readonly type: "otlp-span"; readonly resourceAttributes: Readonly>; readonly scope: Readonly<{ @@ -64,6 +73,25 @@ interface OtlpTraceRecord extends BaseTraceRecord { export type TraceRecord = EffectTraceRecord | OtlpTraceRecord; +export interface TraceSinkOptions { + readonly filePath: string; + readonly maxBytes: number; + readonly maxFiles: number; + readonly batchWindowMs: number; +} + +export interface TraceSink { + readonly filePath: string; + push: (record: TraceRecord) => void; + flush: Effect.Effect; + close: () => Effect.Effect; +} + +export interface LocalFileTracerOptions extends TraceSinkOptions { + readonly delegate?: Tracer.Tracer; + readonly sink?: TraceSink; +} + type OtlpSpan = OtlpTracer.ScopeSpan["spans"][number]; type OtlpSpanEvent = OtlpSpan["events"][number]; type OtlpSpanLink = OtlpSpan["links"][number]; @@ -84,8 +112,87 @@ interface SerializableSpan { >; } +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function markSeen(value: object, seen: WeakSet): boolean { + if (seen.has(value)) { + return true; + } + seen.add(value); + return false; +} + +function normalizeJsonValue(value: unknown, seen: WeakSet = new WeakSet()): unknown { + if ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value ?? null; + } + if (typeof value === "bigint") { + return value.toString(); + } + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString(); + } + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + ...(value.stack ? { stack: value.stack } : {}), + }; + } + if (Array.isArray(value)) { + if (markSeen(value, seen)) { + return "[Circular]"; + } + return value.map((entry) => normalizeJsonValue(entry, seen)); + } + if (value instanceof Map) { + if (markSeen(value, seen)) { + return "[Circular]"; + } + return Object.fromEntries( + Array.from(value.entries(), ([key, entryValue]) => [ + String(key), + normalizeJsonValue(entryValue, seen), + ]), + ); + } + if (value instanceof Set) { + if (markSeen(value, seen)) { + return "[Circular]"; + } + return Array.from(value.values(), (entry) => normalizeJsonValue(entry, seen)); + } + if (!isPlainObject(value)) { + return String(value); + } + if (markSeen(value, seen)) { + return "[Circular]"; + } + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [key, normalizeJsonValue(entryValue, seen)]), + ); +} + +export function compactTraceAttributes( + attributes: Readonly>, +): TraceAttributes { + return Object.fromEntries( + Object.entries(attributes) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, normalizeJsonValue(value)]), + ); +} + function formatTraceExit(exit: Exit.Exit): EffectTraceRecord["exit"] { - if (Exit.isSuccess(exit)) { + if (ExitRuntime.isSuccess(exit)) { return { _tag: "Success" }; } if (Cause.hasInterruptsOnly(exit.cause)) { @@ -130,6 +237,151 @@ export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { }; } +export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: TraceSinkOptions) { + const sink = new RotatingFileSink({ + filePath: options.filePath, + maxBytes: options.maxBytes, + maxFiles: options.maxFiles, + }); + + let buffer: Array = []; + + const flushUnsafe = () => { + if (buffer.length === 0) { + return; + } + + const chunk = buffer.join(""); + buffer = []; + + try { + sink.write(chunk); + } catch { + buffer.unshift(chunk); + } + }; + + const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false)); + + yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore)); + yield* Effect.forkScoped( + Effect.sleep(`${options.batchWindowMs} millis`).pipe(Effect.andThen(flush), Effect.forever), + ); + + return { + filePath: options.filePath, + push(record) { + try { + buffer.push(`${JSON.stringify(record)}\n`); + if (buffer.length >= FLUSH_BUFFER_THRESHOLD) { + flushUnsafe(); + } + } catch { + return; + } + }, + flush, + close: () => flush, + } satisfies TraceSink; +}); + +class LocalFileSpan implements Tracer.Span { + readonly _tag = "Span"; + readonly name: string; + readonly spanId: string; + readonly traceId: string; + readonly parent: Option.Option; + readonly annotations: Tracer.Span["annotations"]; + readonly links: Array; + readonly sampled: boolean; + readonly kind: Tracer.SpanKind; + + status: Tracer.SpanStatus; + attributes: Map; + events: Array<[name: string, startTime: bigint, attributes: Record]>; + private readonly delegate: Tracer.Span; + private readonly push: (record: EffectTraceRecord) => void; + + constructor( + options: Parameters[0], + delegate: Tracer.Span, + push: (record: EffectTraceRecord) => void, + ) { + this.delegate = delegate; + this.push = push; + this.name = delegate.name; + this.spanId = delegate.spanId; + this.traceId = delegate.traceId; + this.parent = options.parent; + this.annotations = options.annotations; + this.links = [...options.links]; + this.sampled = delegate.sampled; + this.kind = delegate.kind; + this.status = { + _tag: "Started", + startTime: options.startTime, + }; + this.attributes = new Map(); + this.events = []; + } + + end(endTime: bigint, exit: Exit.Exit): void { + this.status = { + _tag: "Ended", + startTime: this.status.startTime, + endTime, + exit, + }; + this.delegate.end(endTime, exit); + + if (this.sampled) { + this.push(spanToTraceRecord(this)); + } + } + + attribute(key: string, value: unknown): void { + this.attributes.set(key, value); + this.delegate.attribute(key, value); + } + + event(name: string, startTime: bigint, attributes?: Record): void { + const nextAttributes = attributes ?? {}; + this.events.push([name, startTime, nextAttributes]); + this.delegate.event(name, startTime, nextAttributes); + } + + addLinks(links: ReadonlyArray): void { + this.links.push(...links); + this.delegate.addLinks(links); + } +} + +export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* ( + options: LocalFileTracerOptions, +) { + const sink = + options.sink ?? + (yield* makeTraceSink({ + filePath: options.filePath, + maxBytes: options.maxBytes, + maxFiles: options.maxFiles, + batchWindowMs: options.batchWindowMs, + })); + + const delegate = + options.delegate ?? + Tracer.make({ + span: (spanOptions) => new Tracer.NativeSpan(spanOptions), + }); + + return Tracer.make({ + span(spanOptions) { + return new LocalFileSpan(spanOptions, delegate.span(spanOptions), sink.push); + }, + ...(delegate.context ? { context: delegate.context } : {}), + }); +}); + const SPAN_KIND_MAP: Record = { 1: "internal", 2: "server", diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 9e38b1f8b85..ada6b790656 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -1,14 +1,12 @@ -import { - Cause, - Effect, - Exit, - Option, - Result, - Schema, - SchemaGetter, - SchemaIssue, - SchemaTransformation, -} from "effect"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as SchemaGetter from "effect/SchemaGetter"; +import * as SchemaIssue from "effect/SchemaIssue"; +import * as SchemaTransformation from "effect/SchemaTransformation"; export const decodeJsonResult = >( schema: S, diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 655de4d92ad..672c09687fc 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,5 +1,5 @@ import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { deepMerge } from "./Struct.ts"; import { fromLenientJson } from "./schemaJson.ts"; import { createModelSelection } from "./model.ts"; diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index ecb88f837c1..9b833feac64 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1,4 +1,4 @@ -import * as OS from "node:os"; +import * as NodeOS from "node:os"; import { execFileSync } from "node:child_process"; import { accessSync, constants, statSync } from "node:fs"; import { extname, join } from "node:path"; @@ -32,7 +32,7 @@ function trimNonEmpty(value: string | null | undefined): string | undefined { function readUserLoginShell(): string | undefined { try { - return trimNonEmpty(OS.userInfo().shell); + return trimNonEmpty(NodeOS.userInfo().shell); } catch { return undefined; } diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 564a5990051..bd559329ef3 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -1,4 +1,18 @@ { "extends": "../../tsconfig.base.json", + "compilerOptions": { + "plugins": [ + { + "name": "@effect/language-service", + "namespaceImportPackages": ["@effect/platform-node", "effect"], + "diagnosticSeverity": { + "importFromBarrel": "error", + "anyUnknownInErrorContext": "error", + "instanceOfSchema": "warning", + "deterministicKeys": "warning" + } + } + ] + }, "include": ["src"] } diff --git a/packages/ssh/src/auth.test.ts b/packages/ssh/src/auth.test.ts index f9fe311450d..e59707207a2 100644 --- a/packages/ssh/src/auth.test.ts +++ b/packages/ssh/src/auth.test.ts @@ -1,6 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { Effect, FileSystem, Path } from "effect"; + +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { buildSshAskpassHelperDescriptor, diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index 7cb2de11ef5..13ddee9ca2d 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -1,4 +1,9 @@ -import { Context, Effect, FileSystem, Layer, Path, PlatformError } from "effect"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import { SshPasswordPromptError } from "./errors.ts"; diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index 22e93b69c43..ef7636fa89f 100644 --- a/packages/ssh/src/command.test.ts +++ b/packages/ssh/src/command.test.ts @@ -1,6 +1,12 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Duration, Effect, Fiber, Layer, Result, Sink, Stream } from "effect"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/unstable/process"; diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index cadba077602..dc8839378cd 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -1,7 +1,13 @@ import * as Crypto from "node:crypto"; import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts"; -import { Duration, Effect, FileSystem, Option, Path, Scope, Stream } from "effect"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts"; diff --git a/packages/ssh/src/config.test.ts b/packages/ssh/src/config.test.ts index 0e2f5472ddf..0446370337a 100644 --- a/packages/ssh/src/config.test.ts +++ b/packages/ssh/src/config.test.ts @@ -1,6 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { Effect, FileSystem, Path } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { discoverSshHosts, diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts index b326f66a468..a8d17946129 100644 --- a/packages/ssh/src/config.ts +++ b/packages/ssh/src/config.ts @@ -1,6 +1,9 @@ import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; -import { Effect, FileSystem, Path, PlatformError } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import { SshHostDiscoveryError } from "./errors.ts"; diff --git a/packages/ssh/src/errors.ts b/packages/ssh/src/errors.ts index b902ef82721..357aaef091d 100644 --- a/packages/ssh/src/errors.ts +++ b/packages/ssh/src/errors.ts @@ -1,4 +1,4 @@ -import { Data } from "effect"; +import * as Data from "effect/Data"; export class SshHostDiscoveryError extends Data.TaggedError("SshHostDiscoveryError")<{ readonly message: string; diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index f6c7598ef75..98908ac89e2 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -1,7 +1,13 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { NetService } from "@t3tools/shared/Net"; -import { Duration, Effect, Fiber, Layer, Result, Sink, Stream } from "effect"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 302341d071e..f4b26df0acd 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -4,22 +4,20 @@ import type { } from "@t3tools/contracts"; import { type NetError, NetService } from "@t3tools/shared/Net"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; -import { - Deferred, - Context, - Duration, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, - Ref, - Schema, - Scope, - Schedule, - Stream, -} from "effect"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +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 Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -64,7 +62,7 @@ export interface RemoteT3RunnerOptions { export interface SshEnvironmentManagerOptions { readonly resolveCliPackageSpec?: () => string; - readonly resolveCliRunner?: () => RemoteT3RunnerOptions; + readonly resolveCliRunner?: Effect.Effect; } interface SshTunnelEntry { @@ -1502,7 +1500,11 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma }); const packageSpec = options.resolveCliPackageSpec?.(); const runner = - options.resolveCliRunner?.() ?? (packageSpec === undefined ? undefined : { packageSpec }); + options.resolveCliRunner === undefined + ? packageSpec === undefined + ? undefined + : { packageSpec } + : yield* options.resolveCliRunner; yield* Effect.logDebug("ssh.environment.runner.resolved", { ...sshTargetLogFields(resolvedTarget), ...sshRunnerLogFields(runner), diff --git a/packages/ssh/tsconfig.json b/packages/ssh/tsconfig.json index 2f8e6d4df6d..bd559329ef3 100644 --- a/packages/ssh/tsconfig.json +++ b/packages/ssh/tsconfig.json @@ -4,9 +4,10 @@ "plugins": [ { "name": "@effect/language-service", + "namespaceImportPackages": ["@effect/platform-node", "effect"], "diagnosticSeverity": { "importFromBarrel": "error", - "anyUnknownInErrorContext": "warning", + "anyUnknownInErrorContext": "error", "instanceOfSchema": "warning", "deterministicKeys": "warning" } diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 149525a03f4..dd2b1772fd6 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -1,5 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; -import { Effect, Layer, Sink, Stream } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index e90299d3ac1..c40cd54fc44 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -1,4 +1,8 @@ -import { Data, Effect, Option, Schema, Stream } from "effect"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";