Unified · Split
| @@ -23,3 +23,5 @@ __screenshots__/ · 22 unmodified lines | ||
| 23 | 23 | .tanstack |
| 24 | 24 | squashfs-root/ |
| 25 | 25 | .vercel |
| 26 | +dist-electron/ | |
| 27 | +.electron-runtime/ | |
| @@ -347,4 +347,3 @@ Each implementation PR should run: · 346 unmodified lines | ||
| 347 | 347 | - `bun typecheck` |
| 348 | 348 | - focused tests for changed backend/web behavior |
| 349 | 349 | - backend tests for any server-side endpoint discovery or auth changes using `bun run test`, never `bun test` |
| 350 | − | |
| @@ -1,2 +0,0 @@ | ||
| 1 | −dist-electron/ | |
| 2 | −.electron-runtime/ | |
| @@ -17,10 +17,12 @@ · 16 unmodified lines | ||
| 17 | 17 | "dependencies": { |
| 18 | 18 | "@effect/platform-node": "catalog:", |
| 19 | 19 | "effect": "catalog:", |
| 20 | − "electron": "40.9.3", | |
| 20 | + "electron": "41.5.0", | |
| 21 | 21 | "electron-updater": "^6.6.2" |
| 22 | 22 | }, |
| 23 | 23 | "devDependencies": { |
| 24 | + "@effect/language-service": "catalog:", | |
| 25 | + "@effect/vitest": "catalog:", | |
| 24 | 26 | "@t3tools/client-runtime": "workspace:*", |
| 25 | 27 | "@t3tools/contracts": "workspace:*", |
| 26 | 28 | "@t3tools/shared": "workspace:*", |
| @@ -0,0 +1,240 @@ | ||
| 1 | +import * as Cause from "effect/Cause"; | |
| 2 | +import * as Data from "effect/Data"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Option from "effect/Option"; | |
| 5 | +import * as Random from "effect/Random"; | |
| 6 | +import * as Ref from "effect/Ref"; | |
| 7 | + | |
| 8 | +import * as NetService from "@t3tools/shared/Net"; | |
| 9 | +import * as ElectronApp from "../electron/ElectronApp.ts"; | |
| 10 | +import * as ElectronDialog from "../electron/ElectronDialog.ts"; | |
| 11 | +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; | |
| 12 | +import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; | |
| 13 | +import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; | |
| 14 | +import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; | |
| 15 | +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; | |
| 16 | +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | |
| 17 | +import * as DesktopLifecycle from "./DesktopLifecycle.ts"; | |
| 18 | +import * as DesktopObservability from "./DesktopObservability.ts"; | |
| 19 | +import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; | |
| 20 | +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | |
| 21 | +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; | |
| 22 | +import * as DesktopState from "./DesktopState.ts"; | |
| 23 | +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; | |
| 24 | + | |
| 25 | +const DEFAULT_DESKTOP_BACKEND_PORT = 3773; | |
| 26 | +const MAX_TCP_PORT = 65_535; | |
| 27 | +const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const; | |
| 28 | + | |
| 29 | +const makeDesktopRunId = Random.nextUUIDv4.pipe( | |
| 30 | + Effect.map((value) => value.replaceAll("-", "").slice(0, 12)), | |
| 31 | +); | |
| 32 | + | |
| 33 | +class DesktopBackendPortUnavailableError extends Data.TaggedError( | |
| 34 | + "DesktopBackendPortUnavailableError", | |
| 35 | +)<{ | |
| 36 | + readonly startPort: number; | |
| 37 | + readonly maxPort: number; | |
| 38 | + readonly hosts: readonly string[]; | |
| 39 | +}> { | |
| 40 | + override get message() { | |
| 41 | + return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`; | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +class DesktopDevelopmentBackendPortRequiredError extends Data.TaggedError( | |
| 46 | + "DesktopDevelopmentBackendPortRequiredError", | |
| 47 | +)<{}> { | |
| 48 | + override get message() { | |
| 49 | + return "T3CODE_PORT is required in desktop development."; | |
| 50 | + } | |
| 51 | +} | |
| 52 | + | |
| 53 | +const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = | |
| 54 | + DesktopObservability.makeComponentLogger("desktop-bootstrap"); | |
| 55 | + | |
| 56 | +const { logInfo: logStartupInfo, logError: logStartupError } = | |
| 57 | + DesktopObservability.makeComponentLogger("desktop-startup"); | |
| 58 | + | |
| 59 | +const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* ( | |
| 60 | + configuredPort: Option.Option<number>, | |
| 61 | +) { | |
| 62 | + if (Option.isSome(configuredPort)) { | |
| 63 | + return { | |
| 64 | + port: configuredPort.value, | |
| 65 | + selectedByScan: false, | |
| 66 | + } as const; | |
| 67 | + } | |
| 68 | + | |
| 69 | + const net = yield* NetService.NetService; | |
| 70 | + for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) { | |
| 71 | + let availableOnEveryHost = true; | |
| 72 | + | |
| 73 | + for (const host of DESKTOP_BACKEND_PORT_PROBE_HOSTS) { | |
| 74 | + if (!(yield* net.canListenOnHost(port, host))) { | |
| 75 | + availableOnEveryHost = false; | |
| 76 | + break; | |
| 77 | + } | |
| 78 | + } | |
| 79 | + | |
| 80 | + if (availableOnEveryHost) { | |
| 81 | + return { | |
| 82 | + port, | |
| 83 | + selectedByScan: true, | |
| 84 | + } as const; | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + return yield* new DesktopBackendPortUnavailableError({ | |
| 89 | + startPort: DEFAULT_DESKTOP_BACKEND_PORT, | |
| 90 | + maxPort: MAX_TCP_PORT, | |
| 91 | + hosts: DESKTOP_BACKEND_PORT_PROBE_HOSTS, | |
| 92 | + }); | |
| 93 | +}); | |
| 94 | + | |
| 95 | +const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupError")(function* ( | |
| 96 | + stage: string, | |
| 97 | + error: unknown, | |
| 98 | +): Effect.fn.Return< | |
| 99 | + void, | |
| 100 | + never, | |
| 101 | + | DesktopLifecycle.DesktopShutdown | |
| 102 | + | DesktopState.DesktopState | |
| 103 | + | ElectronApp.ElectronApp | |
| 104 | + | ElectronDialog.ElectronDialog | |
| 105 | +> { | |
| 106 | + const shutdown = yield* DesktopLifecycle.DesktopShutdown; | |
| 107 | + const state = yield* DesktopState.DesktopState; | |
| 108 | + const electronApp = yield* ElectronApp.ElectronApp; | |
| 109 | + const electronDialog = yield* ElectronDialog.ElectronDialog; | |
| 110 | + const message = error instanceof Error ? error.message : String(error); | |
| 111 | + const detail = | |
| 112 | + error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; | |
| 113 | + yield* logStartupError("fatal startup error", { | |
| 114 | + stage, | |
| 115 | + message, | |
| 116 | + ...(detail.length > 0 ? { detail } : {}), | |
| 117 | + }); | |
| 118 | + const wasQuitting = yield* Ref.getAndSet(state.quitting, true); | |
| 119 | + if (!wasQuitting) { | |
| 120 | + yield* electronDialog.showErrorBox( | |
| 121 | + "T3 Code failed to start", | |
| 122 | + `Stage: ${stage}\n${message}${detail}`, | |
| 123 | + ); | |
| 124 | + } | |
| 125 | + yield* shutdown.request; | |
| 126 | + yield* electronApp.quit; | |
| 127 | +}); | |
| 128 | + | |
| 129 | +const fatalStartupCause = <E>(stage: string, cause: Cause.Cause<E>) => | |
| 130 | + handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); | |
| 131 | + | |
| 132 | +const bootstrap = Effect.gen(function* () { | |
| 133 | + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 134 | + const state = yield* DesktopState.DesktopState; | |
| 135 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 136 | + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 137 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 138 | + yield* logBootstrapInfo("bootstrap start"); | |
| 139 | + | |
| 140 | + if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { | |
| 141 | + return yield* new DesktopDevelopmentBackendPortRequiredError(); | |
| 142 | + } | |
| 143 | + | |
| 144 | + const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); | |
| 145 | + const backendPort = backendPortSelection.port; | |
| 146 | + yield* logBootstrapInfo( | |
| 147 | + backendPortSelection.selectedByScan | |
| 148 | + ? "selected backend port via sequential scan" | |
| 149 | + : "using configured backend port", | |
| 150 | + { | |
| 151 | + port: backendPort, | |
| 152 | + ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}), | |
| 153 | + }, | |
| 154 | + ); | |
| 155 | + | |
| 156 | + const settings = yield* desktopSettings.get; | |
| 157 | + if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { | |
| 158 | + yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", { | |
| 159 | + mode: settings.serverExposureMode, | |
| 160 | + }); | |
| 161 | + } | |
| 162 | + const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); | |
| 163 | + const backendConfig = yield* serverExposure.backendConfig; | |
| 164 | + yield* logBootstrapInfo("bootstrap resolved backend endpoint", { | |
| 165 | + baseUrl: backendConfig.httpBaseUrl.href, | |
| 166 | + }); | |
| 167 | + if (serverExposureState.endpointUrl) { | |
| 168 | + yield* logBootstrapInfo("bootstrap enabled network access", { | |
| 169 | + endpointUrl: serverExposureState.endpointUrl, | |
| 170 | + }); | |
| 171 | + } else if (settings.serverExposureMode === "network-accessible") { | |
| 172 | + yield* logBootstrapWarning( | |
| 173 | + "bootstrap fell back to local-only because no advertised network host was available", | |
| 174 | + ); | |
| 175 | + } | |
| 176 | + | |
| 177 | + yield* installDesktopIpcHandlers; | |
| 178 | + yield* logBootstrapInfo("bootstrap ipc handlers registered"); | |
| 179 | + | |
| 180 | + if (!(yield* Ref.get(state.quitting))) { | |
| 181 | + yield* backendManager.start; | |
| 182 | + yield* logBootstrapInfo("bootstrap backend start requested"); | |
| 183 | + } | |
| 184 | +}).pipe(Effect.withSpan("desktop.bootstrap")); | |
| 185 | + | |
| 186 | +const startup = Effect.gen(function* () { | |
| 187 | + const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; | |
| 188 | + const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; | |
| 189 | + const electronApp = yield* ElectronApp.ElectronApp; | |
| 190 | + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; | |
| 191 | + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; | |
| 192 | + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; | |
| 193 | + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 194 | + const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 195 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 196 | + | |
| 197 | + yield* shellEnvironment.installIntoProcess; | |
| 198 | + const userDataPath = yield* appIdentity.resolveUserDataPath; | |
| 199 | + yield* electronApp.setPath("userData", userDataPath); | |
| 200 | + yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); | |
| 201 | + yield* desktopSettings.load; | |
| 202 | + | |
| 203 | + if (environment.platform === "linux") { | |
| 204 | + yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); | |
| 205 | + } | |
| 206 | + | |
| 207 | + yield* appIdentity.configure; | |
| 208 | + yield* lifecycle.register; | |
| 209 | + | |
| 210 | + yield* electronApp.whenReady.pipe( | |
| 211 | + Effect.withSpan("desktop.electron.whenReady"), | |
| 212 | + Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), | |
| 213 | + ); | |
| 214 | + yield* logStartupInfo("app ready"); | |
| 215 | + yield* appIdentity.configure; | |
| 216 | + yield* applicationMenu.configure; | |
| 217 | + yield* electronProtocol.registerDesktopFileProtocol; | |
| 218 | + yield* updates.configure; | |
| 219 | + yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); | |
| 220 | +}).pipe(Effect.withSpan("desktop.startup")); | |
| 221 | + | |
| 222 | +const scopedProgram = Effect.scoped( | |
| 223 | + Effect.gen(function* () { | |
| 224 | + const runId = yield* makeDesktopRunId; | |
| 225 | + yield* Effect.annotateLogsScoped({ scope: "desktop", runId }); | |
| 226 | + yield* Effect.annotateCurrentSpan({ scope: "desktop", runId }); | |
| 227 | + | |
| 228 | + const shutdown = yield* DesktopLifecycle.DesktopShutdown; | |
| 229 | + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 230 | + | |
| 231 | + yield* Effect.addFinalizer(() => | |
| 232 | + backendManager.stop().pipe(Effect.ensuring(shutdown.markComplete)), | |
| 233 | + ); | |
| 234 | + | |
| 235 | + yield* startup; | |
| 236 | + yield* shutdown.awaitRequest; | |
| 237 | + }), | |
| 238 | +); | |
| 239 | + | |
| 240 | +export const program = scopedProgram.pipe(Effect.withSpan("desktop.app")); | |
| @@ -0,0 +1,176 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, describe, it } from "@effect/vitest"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as FileSystem from "effect/FileSystem"; | |
| 5 | +import * as Layer from "effect/Layer"; | |
| 6 | +import * as Option from "effect/Option"; | |
| 7 | + | |
| 8 | +import type * as Electron from "electron"; | |
| 9 | + | |
| 10 | +import * as ElectronApp from "../electron/ElectronApp.ts"; | |
| 11 | +import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; | |
| 12 | +import * as DesktopAssets from "./DesktopAssets.ts"; | |
| 13 | +import * as DesktopConfig from "./DesktopConfig.ts"; | |
| 14 | +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | |
| 15 | + | |
| 16 | +const defaultEnvironmentInput = { | |
| 17 | + dirname: "/repo/apps/desktop/dist-electron", | |
| 18 | + homeDirectory: "/Users/alice", | |
| 19 | + platform: "darwin", | |
| 20 | + processArch: "arm64", | |
| 21 | + appVersion: "1.2.3", | |
| 22 | + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", | |
| 23 | + isPackaged: true, | |
| 24 | + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", | |
| 25 | + runningUnderArm64Translation: false, | |
| 26 | +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; | |
| 27 | + | |
| 28 | +type TestEnvironmentInput = Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> & { | |
| 29 | + readonly env?: Record<string, string | undefined>; | |
| 30 | +}; | |
| 31 | + | |
| 32 | +interface ElectronAppCalls { | |
| 33 | + readonly setAboutPanelOptions: Array<Electron.AboutPanelOptionsOptions>; | |
| 34 | + readonly setDockIcon: string[]; | |
| 35 | + readonly setName: string[]; | |
| 36 | +} | |
| 37 | + | |
| 38 | +const makeElectronAppLayer = (calls: ElectronAppCalls) => | |
| 39 | + Layer.succeed(ElectronApp.ElectronApp, { | |
| 40 | + metadata: Effect.die("unexpected metadata read"), | |
| 41 | + name: Effect.succeed("T3 Code"), | |
| 42 | + whenReady: Effect.void, | |
| 43 | + quit: Effect.void, | |
| 44 | + exit: () => Effect.void, | |
| 45 | + relaunch: () => Effect.void, | |
| 46 | + setPath: () => Effect.void, | |
| 47 | + setName: (name) => | |
| 48 | + Effect.sync(() => { | |
| 49 | + calls.setName.push(name); | |
| 50 | + }), | |
| 51 | + setAboutPanelOptions: (options) => | |
| 52 | + Effect.sync(() => { | |
| 53 | + calls.setAboutPanelOptions.push(options); | |
| 54 | + }), | |
| 55 | + setAppUserModelId: () => Effect.void, | |
| 56 | + setDesktopName: () => Effect.void, | |
| 57 | + setDockIcon: (iconPath) => | |
| 58 | + Effect.sync(() => { | |
| 59 | + calls.setDockIcon.push(iconPath); | |
| 60 | + }), | |
| 61 | + appendCommandLineSwitch: () => Effect.void, | |
| 62 | + on: () => Effect.void, | |
| 63 | + } satisfies ElectronApp.ElectronAppShape); | |
| 64 | + | |
| 65 | +const makeAssetsLayer = (png: Option.Option<string>) => | |
| 66 | + Layer.succeed(DesktopAssets.DesktopAssets, { | |
| 67 | + iconPaths: Effect.succeed({ | |
| 68 | + ico: Option.none(), | |
| 69 | + icns: Option.none(), | |
| 70 | + png, | |
| 71 | + }), | |
| 72 | + resolveResourcePath: () => Effect.succeed(Option.none()), | |
| 73 | + } satisfies DesktopAssets.DesktopAssetsShape); | |
| 74 | + | |
| 75 | +const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => { | |
| 76 | + const { env, ...environmentOverrides } = overrides; | |
| 77 | + return DesktopEnvironment.layer({ | |
| 78 | + ...defaultEnvironmentInput, | |
| 79 | + ...environmentOverrides, | |
| 80 | + }).pipe( | |
| 81 | + Layer.provide( | |
| 82 | + Layer.mergeAll( | |
| 83 | + NodeServices.layer, | |
| 84 | + DesktopConfig.layerTest({ | |
| 85 | + ...env, | |
| 86 | + }), | |
| 87 | + ), | |
| 88 | + ), | |
| 89 | + ); | |
| 90 | +}; | |
| 91 | + | |
| 92 | +const withIdentity = <A, E, R>( | |
| 93 | + effect: Effect.Effect< | |
| 94 | + A, | |
| 95 | + E, | |
| 96 | + | R | |
| 97 | + | DesktopAppIdentity.DesktopAppIdentity | |
| 98 | + | DesktopEnvironment.DesktopEnvironment | |
| 99 | + | FileSystem.FileSystem | |
| 100 | + >, | |
| 101 | + input: { | |
| 102 | + readonly calls?: ElectronAppCalls; | |
| 103 | + readonly environment?: TestEnvironmentInput; | |
| 104 | + readonly legacyPathExists?: boolean; | |
| 105 | + readonly packageJson?: string; | |
| 106 | + readonly pngIconPath?: Option.Option<string>; | |
| 107 | + } = {}, | |
| 108 | +) => { | |
| 109 | + const calls: ElectronAppCalls = input.calls ?? { | |
| 110 | + setAboutPanelOptions: [], | |
| 111 | + setDockIcon: [], | |
| 112 | + setName: [], | |
| 113 | + }; | |
| 114 | + | |
| 115 | + return effect.pipe( | |
| 116 | + Effect.provide( | |
| 117 | + DesktopAppIdentity.layer.pipe( | |
| 118 | + Layer.provideMerge( | |
| 119 | + FileSystem.layerNoop({ | |
| 120 | + exists: (path) => | |
| 121 | + Effect.succeed(input.legacyPathExists === true && path.includes("T3 Code (Alpha)")), | |
| 122 | + readFileString: () => | |
| 123 | + Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'), | |
| 124 | + }), | |
| 125 | + ), | |
| 126 | + Layer.provideMerge(makeAssetsLayer(input.pngIconPath ?? Option.none())), | |
| 127 | + Layer.provideMerge(makeElectronAppLayer(calls)), | |
| 128 | + Layer.provideMerge(makeEnvironmentLayer(input.environment)), | |
| 129 | + ), | |
| 130 | + ), | |
| 131 | + ); | |
| 132 | +}; | |
| 133 | + | |
| 134 | +describe("DesktopAppIdentity", () => { | |
| 135 | + it.effect("keeps using the legacy userData path when it already exists", () => | |
| 136 | + withIdentity( | |
| 137 | + Effect.gen(function* () { | |
| 138 | + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; | |
| 139 | + const userDataPath = yield* identity.resolveUserDataPath; | |
| 140 | + | |
| 141 | + assert.equal(userDataPath, "/Users/alice/Library/Application Support/T3 Code (Alpha)"); | |
| 142 | + }), | |
| 143 | + { legacyPathExists: true }, | |
| 144 | + ), | |
| 145 | + ); | |
| 146 | + | |
| 147 | + it.effect("configures app identity from the environment commit override", () => { | |
| 148 | + const calls: ElectronAppCalls = { | |
| 149 | + setAboutPanelOptions: [], | |
| 150 | + setDockIcon: [], | |
| 151 | + setName: [], | |
| 152 | + }; | |
| 153 | + | |
| 154 | + return withIdentity( | |
| 155 | + Effect.gen(function* () { | |
| 156 | + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; | |
| 157 | + yield* identity.configure; | |
| 158 | + | |
| 159 | + assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]); | |
| 160 | + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)"); | |
| 161 | + assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); | |
| 162 | + assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); | |
| 163 | + assert.deepEqual(calls.setDockIcon, ["/icon.png"]); | |
| 164 | + }), | |
| 165 | + { | |
| 166 | + calls, | |
| 167 | + environment: { | |
| 168 | + env: { | |
| 169 | + T3CODE_COMMIT_HASH: "0123456789abcdef", | |
| 170 | + }, | |
| 171 | + }, | |
| 172 | + pngIconPath: Option.some("/icon.png"), | |
| 173 | + }, | |
| 174 | + ); | |
| 175 | + }); | |
| 176 | +}); | |
| @@ -0,0 +1,127 @@ | ||
| 1 | +import * as Context from "effect/Context"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as FileSystem from "effect/FileSystem"; | |
| 4 | +import * as Layer from "effect/Layer"; | |
| 5 | +import * as Option from "effect/Option"; | |
| 6 | +import * as Ref from "effect/Ref"; | |
| 7 | +import * as Schema from "effect/Schema"; | |
| 8 | + | |
| 9 | +import * as ElectronApp from "../electron/ElectronApp.ts"; | |
| 10 | +import * as DesktopAssets from "./DesktopAssets.ts"; | |
| 11 | +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | |
| 12 | + | |
| 13 | +const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; | |
| 14 | +const COMMIT_HASH_DISPLAY_LENGTH = 12; | |
| 15 | + | |
| 16 | +const AppPackageMetadata = Schema.Struct({ | |
| 17 | + t3codeCommitHash: Schema.optional(Schema.String), | |
| 18 | +}); | |
| 19 | + | |
| 20 | +export interface DesktopAppIdentityShape { | |
| 21 | + readonly resolveUserDataPath: Effect.Effect<string>; | |
| 22 | + readonly configure: Effect.Effect<void>; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export class DesktopAppIdentity extends Context.Service< | |
| 26 | + DesktopAppIdentity, | |
| 27 | + DesktopAppIdentityShape | |
| 28 | +>()("t3/desktop/AppIdentity") {} | |
| 29 | + | |
| 30 | +const normalizeCommitHash = (value: string): Option.Option<string> => { | |
| 31 | + const trimmed = value.trim(); | |
| 32 | + return COMMIT_HASH_PATTERN.test(trimmed) | |
| 33 | + ? Option.some(trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase()) | |
| 34 | + : Option.none(); | |
| 35 | +}; | |
| 36 | + | |
| 37 | +const make = Effect.gen(function* () { | |
| 38 | + const assets = yield* DesktopAssets.DesktopAssets; | |
| 39 | + const electronApp = yield* ElectronApp.ElectronApp; | |
| 40 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 41 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 42 | + const commitHashCache = yield* Ref.make<Option.Option<Option.Option<string>>>(Option.none()); | |
| 43 | + | |
| 44 | + const resolveEmbeddedCommitHash = Effect.gen(function* () { | |
| 45 | + const packageJsonPath = environment.path.join(environment.appRoot, "package.json"); | |
| 46 | + const raw = yield* fileSystem.readFileString(packageJsonPath).pipe(Effect.option); | |
| 47 | + return yield* Option.match(raw, { | |
| 48 | + onNone: () => Effect.succeed(Option.none<string>()), | |
| 49 | + onSome: (value) => | |
| 50 | + Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata))(value).pipe( | |
| 51 | + Effect.map((parsed) => | |
| 52 | + Option.fromNullishOr(parsed.t3codeCommitHash).pipe(Option.flatMap(normalizeCommitHash)), | |
| 53 | + ), | |
| 54 | + Effect.catch(() => Effect.succeed(Option.none<string>())), | |
| 55 | + ), | |
| 56 | + }); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + const resolveAboutCommitHash = Effect.gen(function* () { | |
| 60 | + const cached = yield* Ref.get(commitHashCache); | |
| 61 | + if (Option.isSome(cached)) { | |
| 62 | + return cached.value; | |
| 63 | + } | |
| 64 | + | |
| 65 | + const override = Option.flatMap(environment.commitHashOverride, normalizeCommitHash); | |
| 66 | + if (Option.isSome(override)) { | |
| 67 | + yield* Ref.set(commitHashCache, Option.some(override)); | |
| 68 | + return override; | |
| 69 | + } | |
| 70 | + | |
| 71 | + if (!environment.isPackaged) { | |
| 72 | + const empty = Option.none<string>(); | |
| 73 | + yield* Ref.set(commitHashCache, Option.some(empty)); | |
| 74 | + return empty; | |
| 75 | + } | |
| 76 | + | |
| 77 | + const commitHash = yield* resolveEmbeddedCommitHash; | |
| 78 | + yield* Ref.set(commitHashCache, Option.some(commitHash)); | |
| 79 | + return commitHash; | |
| 80 | + }); | |
| 81 | + | |
| 82 | + const resolveUserDataPath = Effect.gen(function* () { | |
| 83 | + const legacyPath = environment.path.join( | |
| 84 | + environment.appDataDirectory, | |
| 85 | + environment.legacyUserDataDirName, | |
| 86 | + ); | |
| 87 | + const legacyPathExists = yield* fileSystem | |
| 88 | + .exists(legacyPath) | |
| 89 | + .pipe(Effect.orElseSucceed(() => false)); | |
| 90 | + return legacyPathExists | |
| 91 | + ? legacyPath | |
| 92 | + : environment.path.join(environment.appDataDirectory, environment.userDataDirName); | |
| 93 | + }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); | |
| 94 | + | |
| 95 | + const configure = Effect.gen(function* () { | |
| 96 | + const commitHash = yield* resolveAboutCommitHash; | |
| 97 | + yield* electronApp.setName(environment.displayName); | |
| 98 | + yield* electronApp.setAboutPanelOptions({ | |
| 99 | + applicationName: environment.displayName, | |
| 100 | + applicationVersion: environment.appVersion, | |
| 101 | + version: Option.getOrElse(commitHash, () => "unknown"), | |
| 102 | + }); | |
| 103 | + | |
| 104 | + if (environment.platform === "win32") { | |
| 105 | + yield* electronApp.setAppUserModelId(environment.appUserModelId); | |
| 106 | + } | |
| 107 | + | |
| 108 | + if (environment.platform === "linux") { | |
| 109 | + yield* electronApp.setDesktopName(environment.linuxDesktopEntryName); | |
| 110 | + } | |
| 111 | + | |
| 112 | + if (environment.platform === "darwin") { | |
| 113 | + const iconPaths = yield* assets.iconPaths; | |
| 114 | + yield* Option.match(iconPaths.png, { | |
| 115 | + onNone: () => Effect.void, | |
| 116 | + onSome: electronApp.setDockIcon, | |
| 117 | + }); | |
| 118 | + } | |
| 119 | + }).pipe(Effect.withSpan("desktop.appIdentity.configure")); | |
| 120 | + | |
| 121 | + return DesktopAppIdentity.of({ | |
| 122 | + resolveUserDataPath, | |
| 123 | + configure, | |
| 124 | + }); | |
| 125 | +}); | |
| 126 | + | |
| 127 | +export const layer = Layer.effect(DesktopAppIdentity, make); | |
| @@ -0,0 +1,85 @@ | ||
| 1 | +import * as Context from "effect/Context"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as FileSystem from "effect/FileSystem"; | |
| 4 | +import * as Layer from "effect/Layer"; | |
| 5 | +import * as Option from "effect/Option"; | |
| 6 | + | |
| 7 | +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | |
| 8 | + | |
| 9 | +export interface DesktopIconPaths { | |
| 10 | + readonly ico: Option.Option<string>; | |
| 11 | + readonly icns: Option.Option<string>; | |
| 12 | + readonly png: Option.Option<string>; | |
| 13 | +} | |
| 14 | + | |
| 15 | +export interface DesktopAssetsShape { | |
| 16 | + readonly iconPaths: Effect.Effect<DesktopIconPaths>; | |
| 17 | + readonly resolveResourcePath: (fileName: string) => Effect.Effect<Option.Option<string>>; | |
| 18 | +} | |
| 19 | + | |
| 20 | +export class DesktopAssets extends Context.Service<DesktopAssets, DesktopAssetsShape>()( | |
| 21 | + "t3/desktop/Assets", | |
| 22 | +) {} | |
| 23 | + | |
| 24 | +const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(function* ( | |
| 25 | + fileName: string, | |
| 26 | +): Effect.fn.Return< | |
| 27 | + Option.Option<string>, | |
| 28 | + never, | |
| 29 | + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment | |
| 30 | +> { | |
| 31 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 32 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 33 | + const candidates = environment.resolveResourcePathCandidates(fileName); | |
| 34 | + for (const candidate of candidates) { | |
| 35 | + const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); | |
| 36 | + if (exists) { | |
| 37 | + return Option.some(candidate); | |
| 38 | + } | |
| 39 | + } | |
| 40 | + return Option.none<string>(); | |
| 41 | +}); | |
| 42 | + | |
| 43 | +const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( | |
| 44 | + ext: keyof DesktopIconPaths, | |
| 45 | +): Effect.fn.Return< | |
| 46 | + Option.Option<string>, | |
| 47 | + never, | |
| 48 | + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment | |
| 49 | +> { | |
| 50 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 51 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 52 | + if (environment.isDevelopment && process.platform === "darwin" && ext === "png") { | |
| 53 | + const developmentDockIconPath = environment.developmentDockIconPath; | |
| 54 | + const developmentDockIconExists = yield* fileSystem | |
| 55 | + .exists(developmentDockIconPath) | |
| 56 | + .pipe(Effect.orElseSucceed(() => false)); | |
| 57 | + if (developmentDockIconExists) { | |
| 58 | + return Option.some(developmentDockIconPath); | |
| 59 | + } | |
| 60 | + } | |
| 61 | + | |
| 62 | + return yield* resolveResourcePath(`icon.${ext}`); | |
| 63 | +}); | |
| 64 | + | |
| 65 | +const make = Effect.gen(function* () { | |
| 66 | + const context = yield* Effect.context< | |
| 67 | + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment | |
| 68 | + >(); | |
| 69 | + const [ico, icns, png] = yield* Effect.all( | |
| 70 | + [resolveIconPath("ico"), resolveIconPath("icns"), resolveIconPath("png")] as const, | |
| 71 | + { concurrency: "unbounded" }, | |
| 72 | + ); | |
| 73 | + const iconPaths = { ico, icns, png } satisfies DesktopIconPaths; | |
| 74 | + | |
| 75 | + return DesktopAssets.of({ | |
| 76 | + iconPaths: Effect.succeed(iconPaths), | |
| 77 | + resolveResourcePath: Effect.fn("desktop.assets.resolveResourcePath.scoped")( | |
| 78 | + function* (fileName) { | |
| 79 | + return yield* resolveResourcePath(fileName).pipe(Effect.provide(context)); | |
| 80 | + }, | |
| 81 | + ), | |
| 82 | + }); | |
| 83 | +}); | |
| 84 | + | |
| 85 | +export const layer = Layer.effect(DesktopAssets, make); | |
| @@ -0,0 +1,58 @@ | ||
| 1 | +import * as Config from "effect/Config"; | |
| 2 | +import * as ConfigProvider from "effect/ConfigProvider"; | |
| 3 | +import * as Option from "effect/Option"; | |
| 4 | + | |
| 5 | +const trimNonEmptyOption = (value: string): Option.Option<string> => { | |
| 6 | + const trimmed = value.trim(); | |
| 7 | + return trimmed.length > 0 ? Option.some(trimmed) : Option.none(); | |
| 8 | +}; | |
| 9 | + | |
| 10 | +const trimmedString = (name: string) => | |
| 11 | + Config.string(name).pipe(Config.option, Config.map(Option.flatMap(trimNonEmptyOption))); | |
| 12 | + | |
| 13 | +const optionalBoolean = (name: string) => | |
| 14 | + Config.boolean(name).pipe(Config.option, Config.map(Option.getOrElse(() => false))); | |
| 15 | + | |
| 16 | +const commaSeparatedStrings = (name: string) => | |
| 17 | + trimmedString(name).pipe( | |
| 18 | + Config.map( | |
| 19 | + Option.match({ | |
| 20 | + onNone: () => [], | |
| 21 | + onSome: (value) => | |
| 22 | + value | |
| 23 | + .split(",") | |
| 24 | + .map((entry) => entry.trim()) | |
| 25 | + .filter((entry) => entry.length > 0), | |
| 26 | + }), | |
| 27 | + ), | |
| 28 | + ); | |
| 29 | + | |
| 30 | +const compactEnv = (env: Readonly<Record<string, string | undefined>>): Record<string, string> => | |
| 31 | + Object.fromEntries( | |
| 32 | + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), | |
| 33 | + ); | |
| 34 | + | |
| 35 | +export const DesktopConfig = Config.all({ | |
| 36 | + appDataDirectory: trimmedString("APPDATA"), | |
| 37 | + xdgConfigHome: trimmedString("XDG_CONFIG_HOME"), | |
| 38 | + t3Home: trimmedString("T3CODE_HOME"), | |
| 39 | + devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option), | |
| 40 | + devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"), | |
| 41 | + configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option), | |
| 42 | + commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"), | |
| 43 | + desktopLanHostOverride: trimmedString("T3CODE_DESKTOP_LAN_HOST"), | |
| 44 | + desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"), | |
| 45 | + otlpTracesUrl: trimmedString("T3CODE_OTLP_TRACES_URL"), | |
| 46 | + otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( | |
| 47 | + Config.withDefault(10_000), | |
| 48 | + ), | |
| 49 | + appImagePath: trimmedString("APPIMAGE"), | |
| 50 | + disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), | |
| 51 | + mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), | |
| 52 | + mockUpdateServerPort: Config.port("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe( | |
| 53 | + Config.withDefault(3000), | |
| 54 | + ), | |
| 55 | +}); | |
| 56 | + | |
| 57 | +export const layerTest = (env: Readonly<Record<string, string | undefined>>) => | |
| 58 | + ConfigProvider.layer(ConfigProvider.fromEnv({ env: compactEnv(env) })); | |
| @@ -0,0 +1,117 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, describe, it } from "@effect/vitest"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Layer from "effect/Layer"; | |
| 5 | +import * as Option from "effect/Option"; | |
| 6 | + | |
| 7 | +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | |
| 8 | +import * as DesktopConfig from "./DesktopConfig.ts"; | |
| 9 | + | |
| 10 | +const defaultInput = { | |
| 11 | + dirname: "/repo/apps/desktop/dist-electron", | |
| 12 | + homeDirectory: "/Users/alice", | |
| 13 | + platform: "darwin", | |
| 14 | + processArch: "arm64", | |
| 15 | + appVersion: "0.0.22", | |
| 16 | + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", | |
| 17 | + isPackaged: false, | |
| 18 | + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", | |
| 19 | + runningUnderArm64Translation: false, | |
| 20 | +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; | |
| 21 | + | |
| 22 | +const makeEnvironmentLayer = ( | |
| 23 | + overrides: Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> = {}, | |
| 24 | + env: Record<string, string | undefined> = {}, | |
| 25 | +) => | |
| 26 | + DesktopEnvironment.layer({ | |
| 27 | + ...defaultInput, | |
| 28 | + ...overrides, | |
| 29 | + }).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest(env)))); | |
| 30 | + | |
| 31 | +const makeEnvironment = ( | |
| 32 | + overrides: Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> = {}, | |
| 33 | + env: Record<string, string | undefined> = {}, | |
| 34 | +) => | |
| 35 | + Effect.gen(function* () { | |
| 36 | + return yield* DesktopEnvironment.DesktopEnvironment; | |
| 37 | + }).pipe(Effect.provide(makeEnvironmentLayer(overrides, env))); | |
| 38 | + | |
| 39 | +describe("DesktopEnvironment", () => { | |
| 40 | + it.effect("derives state paths and development identity inside Effect", () => | |
| 41 | + Effect.gen(function* () { | |
| 42 | + const environment = yield* makeEnvironment( | |
| 43 | + {}, | |
| 44 | + { | |
| 45 | + T3CODE_HOME: " /tmp/t3 ", | |
| 46 | + T3CODE_COMMIT_HASH: " 0123456789abcdef ", | |
| 47 | + T3CODE_PORT: "4949", | |
| 48 | + VITE_DEV_SERVER_URL: "http://localhost:5173", | |
| 49 | + T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", | |
| 50 | + T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", | |
| 51 | + T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500", | |
| 52 | + }, | |
| 53 | + ); | |
| 54 | + | |
| 55 | + assert.equal(environment.isDevelopment, true); | |
| 56 | + assert.equal(environment.appDataDirectory, "/Users/alice/Library/Application Support"); | |
| 57 | + assert.equal(environment.baseDir, "/tmp/t3"); | |
| 58 | + assert.equal(environment.stateDir, "/tmp/t3/dev"); | |
| 59 | + assert.equal(environment.desktopSettingsPath, "/tmp/t3/dev/desktop-settings.json"); | |
| 60 | + assert.equal(environment.clientSettingsPath, "/tmp/t3/dev/client-settings.json"); | |
| 61 | + assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json"); | |
| 62 | + assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json"); | |
| 63 | + assert.equal(environment.logDir, "/tmp/t3/dev/logs"); | |
| 64 | + assert.equal(environment.rootDir, "/repo"); | |
| 65 | + assert.equal(environment.appRoot, "/repo"); | |
| 66 | + assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); | |
| 67 | + assert.equal(environment.backendCwd, "/repo"); | |
| 68 | + assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); | |
| 69 | + assert.equal(environment.linuxWmClass, "t3code-dev"); | |
| 70 | + assert.deepEqual( | |
| 71 | + Option.map(environment.devServerUrl, (url) => url.href), | |
| 72 | + Option.some("http://localhost:5173/"), | |
| 73 | + ); | |
| 74 | + assert.deepEqual(environment.devRemoteT3ServerEntryPath, Option.some("/remote/server.mjs")); | |
| 75 | + assert.deepEqual(environment.configuredBackendPort, Option.some(4949)); | |
| 76 | + assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); | |
| 77 | + assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces")); | |
| 78 | + assert.equal(environment.otlpExportIntervalMs, 2500); | |
| 79 | + }), | |
| 80 | + ); | |
| 81 | + | |
| 82 | + it.effect("derives production state paths under userdata", () => | |
| 83 | + Effect.gen(function* () { | |
| 84 | + const environment = yield* makeEnvironment( | |
| 85 | + {}, | |
| 86 | + { | |
| 87 | + T3CODE_HOME: "/tmp/t3", | |
| 88 | + }, | |
| 89 | + ); | |
| 90 | + | |
| 91 | + assert.equal(environment.isDevelopment, false); | |
| 92 | + assert.equal(environment.stateDir, "/tmp/t3/userdata"); | |
| 93 | + assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); | |
| 94 | + assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); | |
| 95 | + }), | |
| 96 | + ); | |
| 97 | + | |
| 98 | + it.effect("resolves picker defaults without nullish sentinels", () => | |
| 99 | + Effect.gen(function* () { | |
| 100 | + const environment = yield* makeEnvironment(); | |
| 101 | + | |
| 102 | + assert.deepEqual(environment.resolvePickFolderDefaultPath(null), Option.none()); | |
| 103 | + assert.deepEqual( | |
| 104 | + environment.resolvePickFolderDefaultPath({ initialPath: " " }), | |
| 105 | + Option.none(), | |
| 106 | + ); | |
| 107 | + assert.deepEqual( | |
| 108 | + environment.resolvePickFolderDefaultPath({ initialPath: "~" }), | |
| 109 | + Option.some("/Users/alice"), | |
| 110 | + ); | |
| 111 | + assert.deepEqual( | |
| 112 | + environment.resolvePickFolderDefaultPath({ initialPath: "~/project" }), | |
| 113 | + Option.some("/Users/alice/project"), | |
| 114 | + ); | |
| 115 | + }), | |
| 116 | + ); | |
| 117 | +}); | |
| @@ -0,0 +1,249 @@ | ||
| 1 | +import type { | |
| 2 | + DesktopAppBranding, | |
| 3 | + DesktopAppStageLabel, | |
| 4 | + DesktopRuntimeArch, | |
| 5 | + DesktopRuntimeInfo, | |
| 6 | +} from "@t3tools/contracts"; | |
| 7 | +import * as Config from "effect/Config"; | |
| 8 | +import * as Context from "effect/Context"; | |
| 9 | +import * as Effect from "effect/Effect"; | |
| 10 | +import * as Layer from "effect/Layer"; | |
| 11 | +import * as Option from "effect/Option"; | |
| 12 | +import * as Path from "effect/Path"; | |
| 13 | + | |
| 14 | +import { | |
| 15 | + type DesktopSettings, | |
| 16 | + resolveDefaultDesktopSettings, | |
| 17 | +} from "../settings/DesktopAppSettings.ts"; | |
| 18 | +import * as DesktopConfig from "./DesktopConfig.ts"; | |
| 19 | +import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; | |
| 20 | + | |
| 21 | +export interface MakeDesktopEnvironmentInput { | |
| 22 | + readonly dirname: string; | |
| 23 | + readonly homeDirectory: string; | |
| 24 | + readonly platform: NodeJS.Platform; | |
| 25 | + readonly processArch: string; | |
| 26 | + readonly appVersion: string; | |
| 27 | + readonly appPath: string; | |
| 28 | + readonly isPackaged: boolean; | |
| 29 | + readonly resourcesPath: string; | |
| 30 | + readonly runningUnderArm64Translation: boolean; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export interface DesktopEnvironmentShape { | |
| 34 | + readonly path: Path.Path; | |
| 35 | + readonly dirname: string; | |
| 36 | + readonly platform: NodeJS.Platform; | |
| 37 | + readonly processArch: string; | |
| 38 | + readonly isPackaged: boolean; | |
| 39 | + readonly isDevelopment: boolean; | |
| 40 | + readonly appVersion: string; | |
| 41 | + readonly appPath: string; | |
| 42 | + readonly resourcesPath: string; | |
| 43 | + readonly homeDirectory: string; | |
| 44 | + readonly appDataDirectory: string; | |
| 45 | + readonly baseDir: string; | |
| 46 | + readonly stateDir: string; | |
| 47 | + readonly desktopSettingsPath: string; | |
| 48 | + readonly clientSettingsPath: string; | |
| 49 | + readonly savedEnvironmentRegistryPath: string; | |
| 50 | + readonly serverSettingsPath: string; | |
| 51 | + readonly logDir: string; | |
| 52 | + readonly rootDir: string; | |
| 53 | + readonly appRoot: string; | |
| 54 | + readonly backendEntryPath: string; | |
| 55 | + readonly backendCwd: string; | |
| 56 | + readonly preloadPath: string; | |
| 57 | + readonly appUpdateYmlPath: string; | |
| 58 | + readonly devServerUrl: Option.Option<URL>; | |
| 59 | + readonly devRemoteT3ServerEntryPath: Option.Option<string>; | |
| 60 | + readonly configuredBackendPort: Option.Option<number>; | |
| 61 | + readonly commitHashOverride: Option.Option<string>; | |
| 62 | + readonly otlpTracesUrl: Option.Option<string>; | |
| 63 | + readonly otlpExportIntervalMs: number; | |
| 64 | + readonly branding: DesktopAppBranding; | |
| 65 | + readonly displayName: string; | |
| 66 | + readonly appUserModelId: string; | |
| 67 | + readonly linuxDesktopEntryName: string; | |
| 68 | + readonly linuxWmClass: string; | |
| 69 | + readonly userDataDirName: string; | |
| 70 | + readonly legacyUserDataDirName: string; | |
| 71 | + readonly defaultDesktopSettings: DesktopSettings; | |
| 72 | + readonly runtimeInfo: DesktopRuntimeInfo; | |
| 73 | + readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option<string>; | |
| 74 | + readonly resolveResourcePathCandidates: (fileName: string) => readonly string[]; | |
| 75 | + readonly developmentDockIconPath: string; | |
| 76 | +} | |
| 77 | + | |
| 78 | +export class DesktopEnvironment extends Context.Service< | |
| 79 | + DesktopEnvironment, | |
| 80 | + DesktopEnvironmentShape | |
| 81 | +>()("t3/desktop/Environment") {} | |
| 82 | + | |
| 83 | +const APP_BASE_NAME = "T3 Code"; | |
| 84 | + | |
| 85 | +function resolveDesktopAppStageLabel(input: { | |
| 86 | + readonly isDevelopment: boolean; | |
| 87 | + readonly appVersion: string; | |
| 88 | +}): DesktopAppStageLabel { | |
| 89 | + if (input.isDevelopment) { | |
| 90 | + return "Dev"; | |
| 91 | + } | |
| 92 | + | |
| 93 | + return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; | |
| 94 | +} | |
| 95 | + | |
| 96 | +function resolveDesktopAppBranding(input: { | |
| 97 | + readonly isDevelopment: boolean; | |
| 98 | + readonly appVersion: string; | |
| 99 | +}): DesktopAppBranding { | |
| 100 | + const stageLabel = resolveDesktopAppStageLabel(input); | |
| 101 | + return { | |
| 102 | + baseName: APP_BASE_NAME, | |
| 103 | + stageLabel, | |
| 104 | + displayName: `${APP_BASE_NAME} (${stageLabel})`, | |
| 105 | + }; | |
| 106 | +} | |
| 107 | + | |
| 108 | +function normalizeDesktopArch(arch: string): DesktopRuntimeArch { | |
| 109 | + if (arch === "arm64") return "arm64"; | |
| 110 | + if (arch === "x64") return "x64"; | |
| 111 | + return "other"; | |
| 112 | +} | |
| 113 | + | |
| 114 | +function resolveDesktopRuntimeInfo(input: { | |
| 115 | + readonly platform: NodeJS.Platform; | |
| 116 | + readonly processArch: string; | |
| 117 | + readonly runningUnderArm64Translation: boolean; | |
| 118 | +}): DesktopRuntimeInfo { | |
| 119 | + const appArch = normalizeDesktopArch(input.processArch); | |
| 120 | + | |
| 121 | + if (input.platform !== "darwin") { | |
| 122 | + return { | |
| 123 | + hostArch: appArch, | |
| 124 | + appArch, | |
| 125 | + runningUnderArm64Translation: false, | |
| 126 | + }; | |
| 127 | + } | |
| 128 | + | |
| 129 | + const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch; | |
| 130 | + | |
| 131 | + return { | |
| 132 | + hostArch, | |
| 133 | + appArch, | |
| 134 | + runningUnderArm64Translation: input.runningUnderArm64Translation, | |
| 135 | + }; | |
| 136 | +} | |
| 137 | + | |
| 138 | +const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( | |
| 139 | + input: MakeDesktopEnvironmentInput, | |
| 140 | +): Effect.fn.Return<DesktopEnvironmentShape, Config.ConfigError, Path.Path> { | |
| 141 | + const path = yield* Path.Path; | |
| 142 | + const config = yield* DesktopConfig.DesktopConfig; | |
| 143 | + const homeDirectory = input.homeDirectory; | |
| 144 | + const devServerUrl = config.devServerUrl; | |
| 145 | + const isDevelopment = Option.isSome(devServerUrl); | |
| 146 | + const appDataDirectory = | |
| 147 | + input.platform === "win32" | |
| 148 | + ? Option.getOrElse(config.appDataDirectory, () => | |
| 149 | + path.join(homeDirectory, "AppData", "Roaming"), | |
| 150 | + ) | |
| 151 | + : input.platform === "darwin" | |
| 152 | + ? path.join(homeDirectory, "Library", "Application Support") | |
| 153 | + : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config")); | |
| 154 | + const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3")); | |
| 155 | + const rootDir = path.resolve(input.dirname, "../../.."); | |
| 156 | + const appRoot = input.isPackaged ? input.appPath : rootDir; | |
| 157 | + const branding = resolveDesktopAppBranding({ | |
| 158 | + isDevelopment, | |
| 159 | + appVersion: input.appVersion, | |
| 160 | + }); | |
| 161 | + const displayName = branding.displayName; | |
| 162 | + const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata"); | |
| 163 | + const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; | |
| 164 | + const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | |
| 165 | + const resourcesPath = input.resourcesPath; | |
| 166 | + | |
| 167 | + return DesktopEnvironment.of({ | |
| 168 | + path, | |
| 169 | + dirname: input.dirname, | |
| 170 | + platform: input.platform, | |
| 171 | + processArch: input.processArch, | |
| 172 | + isPackaged: input.isPackaged, | |
| 173 | + isDevelopment, | |
| 174 | + appVersion: input.appVersion, | |
| 175 | + appPath: input.appPath, | |
| 176 | + resourcesPath, | |
| 177 | + homeDirectory, | |
| 178 | + appDataDirectory, | |
| 179 | + baseDir, | |
| 180 | + stateDir, | |
| 181 | + desktopSettingsPath: path.join(stateDir, "desktop-settings.json"), | |
| 182 | + clientSettingsPath: path.join(stateDir, "client-settings.json"), | |
| 183 | + savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), | |
| 184 | + serverSettingsPath: path.join(stateDir, "settings.json"), | |
| 185 | + logDir: path.join(stateDir, "logs"), | |
| 186 | + rootDir, | |
| 187 | + appRoot, | |
| 188 | + backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), | |
| 189 | + backendCwd: input.isPackaged ? homeDirectory : appRoot, | |
| 190 | + preloadPath: path.join(input.dirname, "preload.cjs"), | |
| 191 | + appUpdateYmlPath: input.isPackaged | |
| 192 | + ? path.join(resourcesPath, "app-update.yml") | |
| 193 | + : path.join(input.appPath, "dev-app-update.yml"), | |
| 194 | + devServerUrl, | |
| 195 | + devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath, | |
| 196 | + configuredBackendPort: config.configuredBackendPort, | |
| 197 | + commitHashOverride: config.commitHashOverride, | |
| 198 | + otlpTracesUrl: config.otlpTracesUrl, | |
| 199 | + otlpExportIntervalMs: config.otlpExportIntervalMs, | |
| 200 | + branding, | |
| 201 | + displayName, | |
| 202 | + appUserModelId: isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", | |
| 203 | + linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", | |
| 204 | + linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", | |
| 205 | + userDataDirName, | |
| 206 | + legacyUserDataDirName, | |
| 207 | + defaultDesktopSettings: resolveDefaultDesktopSettings(input.appVersion), | |
| 208 | + runtimeInfo: resolveDesktopRuntimeInfo({ | |
| 209 | + platform: input.platform, | |
| 210 | + processArch: input.processArch, | |
| 211 | + runningUnderArm64Translation: input.runningUnderArm64Translation, | |
| 212 | + }), | |
| 213 | + resolvePickFolderDefaultPath: (rawOptions) => { | |
| 214 | + if (typeof rawOptions !== "object" || rawOptions === null) { | |
| 215 | + return Option.none(); | |
| 216 | + } | |
| 217 | + | |
| 218 | + const { initialPath } = rawOptions as { initialPath?: unknown }; | |
| 219 | + if (typeof initialPath !== "string") { | |
| 220 | + return Option.none(); | |
| 221 | + } | |
| 222 | + | |
| 223 | + const trimmedPath = initialPath.trim(); | |
| 224 | + if (trimmedPath.length === 0) { | |
| 225 | + return Option.none(); | |
| 226 | + } | |
| 227 | + | |
| 228 | + if (trimmedPath === "~") { | |
| 229 | + return Option.some(homeDirectory); | |
| 230 | + } | |
| 231 | + | |
| 232 | + if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) { | |
| 233 | + return Option.some(path.join(homeDirectory, trimmedPath.slice(2))); | |
| 234 | + } | |
| 235 | + | |
| 236 | + return Option.some(path.resolve(trimmedPath)); | |
| 237 | + }, | |
| 238 | + resolveResourcePathCandidates: (fileName) => [ | |
| 239 | + path.join(input.dirname, "../resources", fileName), | |
| 240 | + path.join(input.dirname, "../prod-resources", fileName), | |
| 241 | + path.join(resourcesPath, "resources", fileName), | |
| 242 | + path.join(resourcesPath, fileName), | |
| 243 | + ], | |
| 244 | + developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"), | |
| 245 | + }); | |
| 246 | +}); | |
| 247 | + | |
| 248 | +export const layer = (input: MakeDesktopEnvironmentInput) => | |
| 249 | + Layer.effect(DesktopEnvironment, makeDesktopEnvironment(input)); | |
| @@ -0,0 +1,233 @@ | ||
| 1 | +import * as Cause from "effect/Cause"; | |
| 2 | +import * as Context from "effect/Context"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Deferred from "effect/Deferred"; | |
| 5 | +import * as Layer from "effect/Layer"; | |
| 6 | +import * as Ref from "effect/Ref"; | |
| 7 | +import * as Scope from "effect/Scope"; | |
| 8 | + | |
| 9 | +import type * as Electron from "electron"; | |
| 10 | + | |
| 11 | +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | |
| 12 | +import * as DesktopObservability from "./DesktopObservability.ts"; | |
| 13 | +import * as ElectronApp from "../electron/ElectronApp.ts"; | |
| 14 | +import * as ElectronTheme from "../electron/ElectronTheme.ts"; | |
| 15 | +import * as DesktopState from "./DesktopState.ts"; | |
| 16 | +import * as DesktopWindow from "../window/DesktopWindow.ts"; | |
| 17 | + | |
| 18 | +export interface DesktopShutdownShape { | |
| 19 | + readonly request: Effect.Effect<void>; | |
| 20 | + readonly awaitRequest: Effect.Effect<void>; | |
| 21 | + readonly markComplete: Effect.Effect<void>; | |
| 22 | + readonly awaitComplete: Effect.Effect<void>; | |
| 23 | + readonly isComplete: Effect.Effect<boolean>; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export class DesktopShutdown extends Context.Service<DesktopShutdown, DesktopShutdownShape>()( | |
| 27 | + "t3/desktop/Shutdown", | |
| 28 | +) {} | |
| 29 | + | |
| 30 | +const makeShutdown = Effect.gen(function* () { | |
| 31 | + const requested = yield* Deferred.make<void>(); | |
| 32 | + const completed = yield* Deferred.make<void>(); | |
| 33 | + const completedRef = yield* Ref.make(false); | |
| 34 | + | |
| 35 | + return DesktopShutdown.of({ | |
| 36 | + request: Deferred.succeed(requested, undefined).pipe(Effect.asVoid), | |
| 37 | + awaitRequest: Deferred.await(requested), | |
| 38 | + markComplete: Ref.set(completedRef, true).pipe( | |
| 39 | + Effect.andThen(Deferred.succeed(completed, undefined)), | |
| 40 | + Effect.asVoid, | |
| 41 | + ), | |
| 42 | + awaitComplete: Deferred.await(completed), | |
| 43 | + isComplete: Ref.get(completedRef), | |
| 44 | + }); | |
| 45 | +}); | |
| 46 | + | |
| 47 | +export const layerShutdown = Layer.effect(DesktopShutdown, makeShutdown); | |
| 48 | + | |
| 49 | +export type DesktopLifecycleRuntimeServices = | |
| 50 | + | DesktopEnvironment.DesktopEnvironment | |
| 51 | + | DesktopShutdown | |
| 52 | + | DesktopState.DesktopState | |
| 53 | + | DesktopWindow.DesktopWindow | |
| 54 | + | ElectronApp.ElectronApp | |
| 55 | + | ElectronTheme.ElectronTheme; | |
| 56 | + | |
| 57 | +export interface DesktopLifecycleShape { | |
| 58 | + readonly relaunch: ( | |
| 59 | + reason: string, | |
| 60 | + ) => Effect.Effect<void, never, DesktopLifecycleRuntimeServices>; | |
| 61 | + readonly register: Effect.Effect<void, never, Scope.Scope | DesktopLifecycleRuntimeServices>; | |
| 62 | +} | |
| 63 | + | |
| 64 | +export class DesktopLifecycle extends Context.Service<DesktopLifecycle, DesktopLifecycleShape>()( | |
| 65 | + "t3/desktop/Lifecycle", | |
| 66 | +) {} | |
| 67 | + | |
| 68 | +const { logInfo: logLifecycleInfo, logError: logLifecycleError } = | |
| 69 | + DesktopObservability.makeComponentLogger("desktop-lifecycle"); | |
| 70 | + | |
| 71 | +function addScopedListener<Args extends ReadonlyArray<unknown>>( | |
| 72 | + target: unknown, | |
| 73 | + eventName: string, | |
| 74 | + listener: (...args: Args) => void, | |
| 75 | +): Effect.Effect<void, never, Scope.Scope> { | |
| 76 | + const eventTarget = target as { | |
| 77 | + on: (eventName: string, listener: (...args: Array<unknown>) => void) => unknown; | |
| 78 | + removeListener: (eventName: string, listener: (...args: Array<unknown>) => void) => unknown; | |
| 79 | + }; | |
| 80 | + const untypedListener = listener as unknown as (...args: Array<unknown>) => void; | |
| 81 | + return Effect.acquireRelease( | |
| 82 | + Effect.sync(() => { | |
| 83 | + eventTarget.on(eventName, untypedListener); | |
| 84 | + }), | |
| 85 | + () => | |
| 86 | + Effect.sync(() => { | |
| 87 | + eventTarget.removeListener(eventName, untypedListener); | |
| 88 | + }), | |
| 89 | + ).pipe(Effect.asVoid); | |
| 90 | +} | |
| 91 | + | |
| 92 | +const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( | |
| 93 | + function* (): Effect.fn.Return<void, never, DesktopShutdown> { | |
| 94 | + const shutdown = yield* DesktopShutdown; | |
| 95 | + yield* shutdown.request; | |
| 96 | + yield* shutdown.awaitComplete; | |
| 97 | + }, | |
| 98 | +); | |
| 99 | + | |
| 100 | +function handleBeforeQuit( | |
| 101 | + event: Electron.Event, | |
| 102 | + runEffect: <A, E>(effect: Effect.Effect<A, E, DesktopLifecycleRuntimeServices>) => Promise<A>, | |
| 103 | + allowQuit: () => boolean, | |
| 104 | + markQuitAllowed: () => void, | |
| 105 | +): void { | |
| 106 | + if (allowQuit()) { | |
| 107 | + void runEffect( | |
| 108 | + Effect.gen(function* () { | |
| 109 | + const state = yield* DesktopState.DesktopState; | |
| 110 | + yield* Ref.set(state.quitting, true); | |
| 111 | + yield* logLifecycleInfo("before-quit received"); | |
| 112 | + }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), | |
| 113 | + ); | |
| 114 | + return; | |
| 115 | + } | |
| 116 | + | |
| 117 | + event.preventDefault(); | |
| 118 | + void runEffect( | |
| 119 | + Effect.gen(function* () { | |
| 120 | + const state = yield* DesktopState.DesktopState; | |
| 121 | + yield* Ref.set(state.quitting, true); | |
| 122 | + yield* logLifecycleInfo("before-quit received"); | |
| 123 | + yield* requestDesktopShutdownAndWait(); | |
| 124 | + }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), | |
| 125 | + ).finally(() => { | |
| 126 | + markQuitAllowed(); | |
| 127 | + void runEffect( | |
| 128 | + Effect.gen(function* () { | |
| 129 | + const electronApp = yield* ElectronApp.ElectronApp; | |
| 130 | + yield* electronApp.quit; | |
| 131 | + }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")), | |
| 132 | + ); | |
| 133 | + }); | |
| 134 | +} | |
| 135 | + | |
| 136 | +function quitFromSignal( | |
| 137 | + signal: "SIGINT" | "SIGTERM", | |
| 138 | + runEffect: <A, E>(effect: Effect.Effect<A, E, DesktopLifecycleRuntimeServices>) => Promise<A>, | |
| 139 | +): void { | |
| 140 | + void runEffect( | |
| 141 | + Effect.gen(function* () { | |
| 142 | + yield* Effect.annotateCurrentSpan({ signal }); | |
| 143 | + const electronApp = yield* ElectronApp.ElectronApp; | |
| 144 | + const state = yield* DesktopState.DesktopState; | |
| 145 | + const wasQuitting = yield* Ref.getAndSet(state.quitting, true); | |
| 146 | + if (wasQuitting) return; | |
| 147 | + yield* logLifecycleInfo("process signal received", { signal }); | |
| 148 | + yield* requestDesktopShutdownAndWait(); | |
| 149 | + yield* electronApp.quit; | |
| 150 | + }).pipe(Effect.withSpan("desktop.lifecycle.processSignal")), | |
| 151 | + ); | |
| 152 | +} | |
| 153 | + | |
| 154 | +export const layer = Layer.succeed( | |
| 155 | + DesktopLifecycle, | |
| 156 | + DesktopLifecycle.of({ | |
| 157 | + relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { | |
| 158 | + const electronApp = yield* ElectronApp.ElectronApp; | |
| 159 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 160 | + const state = yield* DesktopState.DesktopState; | |
| 161 | + yield* logLifecycleInfo("desktop relaunch requested", { reason }); | |
| 162 | + yield* Effect.gen(function* () { | |
| 163 | + yield* Effect.yieldNow; | |
| 164 | + yield* Ref.set(state.quitting, true); | |
| 165 | + yield* requestDesktopShutdownAndWait(); | |
| 166 | + if (environment.isDevelopment) { | |
| 167 | + yield* electronApp.exit(75); | |
| 168 | + return; | |
| 169 | + } | |
| 170 | + yield* electronApp.relaunch({ | |
| 171 | + execPath: process.execPath, | |
| 172 | + args: process.argv.slice(1), | |
| 173 | + }); | |
| 174 | + yield* electronApp.exit(0); | |
| 175 | + }).pipe( | |
| 176 | + Effect.catchCause((cause) => | |
| 177 | + logLifecycleError("desktop relaunch failed", { | |
| 178 | + cause: Cause.pretty(cause), | |
| 179 | + }), | |
| 180 | + ), | |
| 181 | + Effect.forkDetach, | |
| 182 | + Effect.asVoid, | |
| 183 | + ); | |
| 184 | + }), | |
| 185 | + register: Effect.gen(function* () { | |
| 186 | + const desktopWindow = yield* DesktopWindow.DesktopWindow; | |
| 187 | + const electronApp = yield* ElectronApp.ElectronApp; | |
| 188 | + const electronTheme = yield* ElectronTheme.ElectronTheme; | |
| 189 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 190 | + const context = yield* Effect.context<DesktopLifecycleRuntimeServices>(); | |
| 191 | + const runEffect = Effect.runPromiseWith(context); | |
| 192 | + let quitAllowed = false; | |
| 193 | + yield* electronTheme.onUpdated(() => { | |
| 194 | + void runEffect( | |
| 195 | + desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), | |
| 196 | + ); | |
| 197 | + }); | |
| 198 | + yield* electronApp.on("before-quit", (event: Electron.Event) => { | |
| 199 | + handleBeforeQuit( | |
| 200 | + event, | |
| 201 | + runEffect, | |
| 202 | + () => quitAllowed, | |
| 203 | + () => { | |
| 204 | + quitAllowed = true; | |
| 205 | + }, | |
| 206 | + ); | |
| 207 | + }); | |
| 208 | + yield* electronApp.on("activate", () => { | |
| 209 | + void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate"))); | |
| 210 | + }); | |
| 211 | + yield* electronApp.on("window-all-closed", () => { | |
| 212 | + void runEffect( | |
| 213 | + Effect.gen(function* () { | |
| 214 | + const app = yield* ElectronApp.ElectronApp; | |
| 215 | + const state = yield* DesktopState.DesktopState; | |
| 216 | + if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { | |
| 217 | + yield* app.quit; | |
| 218 | + } | |
| 219 | + }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), | |
| 220 | + ); | |
| 221 | + }); | |
| 222 | + | |
| 223 | + if (environment.platform !== "win32") { | |
| 224 | + yield* addScopedListener(process, "SIGINT", () => { | |
| 225 | + quitFromSignal("SIGINT", runEffect); | |
| 226 | + }); | |
| 227 | + yield* addScopedListener(process, "SIGTERM", () => { | |
| 228 | + quitFromSignal("SIGTERM", runEffect); | |
| 229 | + }); | |
| 230 | + } | |
| 231 | + }).pipe(Effect.withSpan("desktop.lifecycle.register")), | |
| 232 | + }), | |
| 233 | +); | |
| @@ -0,0 +1,162 @@ | ||
| 1 | +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; | |
| 2 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | +import { assert, describe, it } from "@effect/vitest"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 5 | +import * as FileSystem from "effect/FileSystem"; | |
| 6 | +import * as Layer from "effect/Layer"; | |
| 7 | +import * as Schema from "effect/Schema"; | |
| 8 | + | |
| 9 | +import * as DesktopConfig from "./DesktopConfig.ts"; | |
| 10 | +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | |
| 11 | +import * as DesktopObservability from "./DesktopObservability.ts"; | |
| 12 | + | |
| 13 | +const DesktopBackendChildLogRecord = Schema.Struct({ | |
| 14 | + message: Schema.String, | |
| 15 | + level: Schema.Literals(["INFO", "ERROR"]), | |
| 16 | + timestamp: Schema.String, | |
| 17 | + annotations: Schema.Record(Schema.String, Schema.Unknown), | |
| 18 | + spans: Schema.Record(Schema.String, Schema.Unknown), | |
| 19 | + fiberId: Schema.String, | |
| 20 | +}); | |
| 21 | + | |
| 22 | +const decodeDesktopBackendChildLogRecord = Schema.decodeEffect( | |
| 23 | + Schema.fromJsonString(DesktopBackendChildLogRecord), | |
| 24 | +); | |
| 25 | + | |
| 26 | +const TraceRecordLine = Schema.Struct({ | |
| 27 | + name: Schema.String, | |
| 28 | + attributes: Schema.Record(Schema.String, Schema.Unknown), | |
| 29 | + events: Schema.Array( | |
| 30 | + Schema.Struct({ | |
| 31 | + name: Schema.String, | |
| 32 | + attributes: Schema.Record(Schema.String, Schema.Unknown), | |
| 33 | + }), | |
| 34 | + ), | |
| 35 | +}); | |
| 36 | + | |
| 37 | +const decodeTraceRecordLine = Schema.decodeUnknownSync(Schema.fromJsonString(TraceRecordLine)); | |
| 38 | + | |
| 39 | +const environmentInput = (baseDir: string) => | |
| 40 | + ({ | |
| 41 | + dirname: "/repo/apps/desktop/dist-electron", | |
| 42 | + homeDirectory: baseDir, | |
| 43 | + platform: "darwin", | |
| 44 | + processArch: "arm64", | |
| 45 | + appVersion: "1.2.3", | |
| 46 | + appPath: "/repo", | |
| 47 | + isPackaged: false, | |
| 48 | + resourcesPath: "/repo/resources", | |
| 49 | + runningUnderArm64Translation: false, | |
| 50 | + }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; | |
| 51 | + | |
| 52 | +const makeEnvironmentLayer = (baseDir: string) => | |
| 53 | + DesktopEnvironment.layer(environmentInput(baseDir)).pipe( | |
| 54 | + Layer.provide( | |
| 55 | + Layer.mergeAll( | |
| 56 | + NodeServices.layer, | |
| 57 | + DesktopConfig.layerTest({ | |
| 58 | + T3CODE_HOME: baseDir, | |
| 59 | + VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", | |
| 60 | + }), | |
| 61 | + ), | |
| 62 | + ), | |
| 63 | + ); | |
| 64 | + | |
| 65 | +describe("DesktopObservability", () => { | |
| 66 | + it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () => | |
| 67 | + Effect.gen(function* () { | |
| 68 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 69 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 70 | + prefix: "t3-desktop-observability-test-", | |
| 71 | + }); | |
| 72 | + const environmentLayer = makeEnvironmentLayer(baseDir); | |
| 73 | + const tracePath = yield* Effect.gen(function* () { | |
| 74 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 75 | + return environment.path.join(environment.logDir, "desktop.trace.ndjson"); | |
| 76 | + }).pipe(Effect.provide(environmentLayer)); | |
| 77 | + const logPath = yield* Effect.gen(function* () { | |
| 78 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 79 | + return environment.path.join(environment.logDir, "desktop-main.log"); | |
| 80 | + }).pipe(Effect.provide(environmentLayer)); | |
| 81 | + | |
| 82 | + yield* Effect.scoped( | |
| 83 | + Effect.gen(function* () { | |
| 84 | + yield* Effect.annotateCurrentSpan({ "desktop.test": true }); | |
| 85 | + yield* Effect.logInfo("desktop trace event"); | |
| 86 | + }).pipe( | |
| 87 | + Effect.withSpan("desktop-observability-test"), | |
| 88 | + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), | |
| 89 | + ), | |
| 90 | + ); | |
| 91 | + | |
| 92 | + const records = (yield* fileSystem.readFileString(tracePath)) | |
| 93 | + .trim() | |
| 94 | + .split("\n") | |
| 95 | + .filter((line) => line.length > 0) | |
| 96 | + .map((line) => decodeTraceRecordLine(line)); | |
| 97 | + const record = records.find((entry) => entry.name === "desktop-observability-test"); | |
| 98 | + | |
| 99 | + assert.notEqual(record, undefined); | |
| 100 | + if (!record) { | |
| 101 | + return; | |
| 102 | + } | |
| 103 | + assert.equal(record.attributes["desktop.test"], true); | |
| 104 | + assert.equal( | |
| 105 | + record.events.some((event) => event.name === "desktop trace event"), | |
| 106 | + true, | |
| 107 | + ); | |
| 108 | + assert.isFalse(yield* fileSystem.exists(logPath)); | |
| 109 | + }).pipe( | |
| 110 | + Effect.scoped, | |
| 111 | + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), | |
| 112 | + ), | |
| 113 | + ); | |
| 114 | + | |
| 115 | + it.effect("persists backend child output as structured JSON records in development", () => | |
| 116 | + Effect.gen(function* () { | |
| 117 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 118 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 119 | + prefix: "t3-desktop-backend-output-log-test-", | |
| 120 | + }); | |
| 121 | + const environmentLayer = makeEnvironmentLayer(baseDir); | |
| 122 | + const logPath = yield* Effect.gen(function* () { | |
| 123 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 124 | + return environment.path.join(environment.logDir, "server-child.log"); | |
| 125 | + }).pipe(Effect.provide(environmentLayer)); | |
| 126 | + | |
| 127 | + yield* Effect.gen(function* () { | |
| 128 | + const outputLog = yield* DesktopObservability.DesktopBackendOutputLog; | |
| 129 | + yield* outputLog.writeSessionBoundary({ | |
| 130 | + phase: "START", | |
| 131 | + details: "pid=123 port=3773 cwd=/repo", | |
| 132 | + }); | |
| 133 | + yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("hello server\n")); | |
| 134 | + }).pipe( | |
| 135 | + Effect.annotateLogs({ runId: "test-run" }), | |
| 136 | + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), | |
| 137 | + ); | |
| 138 | + | |
| 139 | + const log = yield* fileSystem.readFileString(logPath); | |
| 140 | + const lines = log.trimEnd().split("\n"); | |
| 141 | + const boundary = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? ""); | |
| 142 | + const output = yield* decodeDesktopBackendChildLogRecord(lines[1] ?? ""); | |
| 143 | + | |
| 144 | + assert.equal(boundary.message, "backend child process session start"); | |
| 145 | + assert.equal(boundary.level, "INFO"); | |
| 146 | + assert.equal(boundary.annotations.component, "desktop-backend-child"); | |
| 147 | + assert.equal(boundary.annotations.runId, "test-run"); | |
| 148 | + assert.equal(boundary.annotations.phase, "START"); | |
| 149 | + assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo"); | |
| 150 | + | |
| 151 | + assert.equal(output.message, "backend child process output"); | |
| 152 | + assert.equal(output.level, "INFO"); | |
| 153 | + assert.equal(output.annotations.component, "desktop-backend-child"); | |
| 154 | + assert.equal(output.annotations.runId, "test-run"); | |
| 155 | + assert.equal(output.annotations.stream, "stdout"); | |
| 156 | + assert.equal(output.annotations.text, "hello server\n"); | |
| 157 | + }).pipe( | |
| 158 | + Effect.scoped, | |
| 159 | + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), | |
| 160 | + ), | |
| 161 | + ); | |
| 162 | +}); | |
| @@ -0,0 +1,395 @@ | ||
| 1 | +import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; | |
| 2 | +import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; | |
| 3 | +import * as Context from "effect/Context"; | |
| 4 | +import * as Data from "effect/Data"; | |
| 5 | +import * as DateTime from "effect/DateTime"; | |
| 6 | +import * as Effect from "effect/Effect"; | |
| 7 | +import * as FileSystem from "effect/FileSystem"; | |
| 8 | +import * as Layer from "effect/Layer"; | |
| 9 | +import * as Logger from "effect/Logger"; | |
| 10 | +import * as Option from "effect/Option"; | |
| 11 | +import * as Path from "effect/Path"; | |
| 12 | +import * as PlatformError from "effect/PlatformError"; | |
| 13 | +import * as References from "effect/References"; | |
| 14 | +import * as Ref from "effect/Ref"; | |
| 15 | +import * as Schema from "effect/Schema"; | |
| 16 | +import * as Semaphore from "effect/Semaphore"; | |
| 17 | +import * as Tracer from "effect/Tracer"; | |
| 18 | +import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | |
| 19 | + | |
| 20 | +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | |
| 21 | + | |
| 22 | +const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; | |
| 23 | +const DESKTOP_LOG_FILE_MAX_FILES = 10; | |
| 24 | +const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; | |
| 25 | +const DESKTOP_TRACE_BATCH_WINDOW_MS = 200; | |
| 26 | + | |
| 27 | +export interface RotatingLogFileWriter { | |
| 28 | + readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>; | |
| 29 | + readonly writeText: (chunk: string) => Effect.Effect<void>; | |
| 30 | +} | |
| 31 | + | |
| 32 | +export interface DesktopBackendOutputLogShape { | |
| 33 | + readonly writeSessionBoundary: (input: { | |
| 34 | + readonly phase: "START" | "END"; | |
| 35 | + readonly details: string; | |
| 36 | + }) => Effect.Effect<void>; | |
| 37 | + readonly writeOutputChunk: ( | |
| 38 | + streamName: "stdout" | "stderr", | |
| 39 | + chunk: Uint8Array, | |
| 40 | + ) => Effect.Effect<void>; | |
| 41 | +} | |
| 42 | + | |
| 43 | +export class DesktopBackendOutputLog extends Context.Service< | |
| 44 | + DesktopBackendOutputLog, | |
| 45 | + DesktopBackendOutputLogShape | |
| 46 | +>()("t3/desktop/BackendOutputLog") {} | |
| 47 | + | |
| 48 | +const textEncoder = new TextEncoder(); | |
| 49 | +const textDecoder = new TextDecoder(); | |
| 50 | + | |
| 51 | +export type DesktopLogAnnotations = Record<string, unknown>; | |
| 52 | + | |
| 53 | +export interface DesktopComponentLogger { | |
| 54 | + readonly annotate: <A, E, R>( | |
| 55 | + effect: Effect.Effect<A, E, R>, | |
| 56 | + annotations?: DesktopLogAnnotations, | |
| 57 | + ) => Effect.Effect<A, E, R>; | |
| 58 | + readonly logDebug: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect<void>; | |
| 59 | + readonly logInfo: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect<void>; | |
| 60 | + readonly logWarning: ( | |
| 61 | + message: string, | |
| 62 | + annotations?: DesktopLogAnnotations, | |
| 63 | + ) => Effect.Effect<void>; | |
| 64 | + readonly logError: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect<void>; | |
| 65 | +} | |
| 66 | + | |
| 67 | +export function makeComponentLogger(component: string): DesktopComponentLogger { | |
| 68 | + const annotate: DesktopComponentLogger["annotate"] = (effect, annotations) => | |
| 69 | + effect.pipe( | |
| 70 | + Effect.annotateLogs({ | |
| 71 | + component, | |
| 72 | + ...annotations, | |
| 73 | + }), | |
| 74 | + ); | |
| 75 | + | |
| 76 | + return { | |
| 77 | + annotate, | |
| 78 | + logDebug: (message, annotations) => annotate(Effect.logDebug(message), annotations), | |
| 79 | + logInfo: (message, annotations) => annotate(Effect.logInfo(message), annotations), | |
| 80 | + logWarning: (message, annotations) => annotate(Effect.logWarning(message), annotations), | |
| 81 | + logError: (message, annotations) => annotate(Effect.logError(message), annotations), | |
| 82 | + }; | |
| 83 | +} | |
| 84 | + | |
| 85 | +class DesktopLogFileWriterConfigurationError extends Data.TaggedError( | |
| 86 | + "DesktopLogFileWriterConfigurationError", | |
| 87 | +)<{ | |
| 88 | + readonly option: "maxBytes" | "maxFiles"; | |
| 89 | + readonly value: number; | |
| 90 | +}> { | |
| 91 | + override get message() { | |
| 92 | + return `${this.option} must be >= 1 (received ${this.value})`; | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +type DesktopLogFileWriterError = | |
| 97 | + | DesktopLogFileWriterConfigurationError | |
| 98 | + | PlatformError.PlatformError; | |
| 99 | + | |
| 100 | +const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); | |
| 101 | + | |
| 102 | +const DesktopBackendChildLogRecord = Schema.Struct({ | |
| 103 | + message: Schema.String, | |
| 104 | + level: Schema.Literals(["INFO", "ERROR"]), | |
| 105 | + timestamp: Schema.String, | |
| 106 | + annotations: Schema.Record(Schema.String, Schema.Unknown), | |
| 107 | + spans: Schema.Record(Schema.String, Schema.Unknown), | |
| 108 | + fiberId: Schema.String, | |
| 109 | +}); | |
| 110 | + | |
| 111 | +const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( | |
| 112 | + Schema.fromJsonString(DesktopBackendChildLogRecord), | |
| 113 | +); | |
| 114 | + | |
| 115 | +const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { | |
| 116 | + writeSessionBoundary: () => Effect.void, | |
| 117 | + writeOutputChunk: () => Effect.void, | |
| 118 | +}; | |
| 119 | + | |
| 120 | +const currentDesktopRunId = Effect.gen(function* () { | |
| 121 | + const annotations = yield* References.CurrentLogAnnotations; | |
| 122 | + const runId = annotations.runId; | |
| 123 | + return typeof runId === "string" && runId.length > 0 ? runId : "unknown"; | |
| 124 | +}); | |
| 125 | + | |
| 126 | +const refreshFileSize = ( | |
| 127 | + fileSystem: FileSystem.FileSystem, | |
| 128 | + filePath: string, | |
| 129 | +): Effect.Effect<number, never> => | |
| 130 | + fileSystem.stat(filePath).pipe( | |
| 131 | + Effect.map((stat) => Number(stat.size)), | |
| 132 | + Effect.orElseSucceed(() => 0), | |
| 133 | + ); | |
| 134 | + | |
| 135 | +const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { | |
| 136 | + readonly filePath: string; | |
| 137 | + readonly maxBytes?: number; | |
| 138 | + readonly maxFiles?: number; | |
| 139 | +}): Effect.fn.Return< | |
| 140 | + RotatingLogFileWriter, | |
| 141 | + DesktopLogFileWriterError, | |
| 142 | + FileSystem.FileSystem | Path.Path | |
| 143 | +> { | |
| 144 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 145 | + const path = yield* Path.Path; | |
| 146 | + const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES; | |
| 147 | + const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES; | |
| 148 | + const directory = path.dirname(input.filePath); | |
| 149 | + const baseName = path.basename(input.filePath); | |
| 150 | + | |
| 151 | + if (maxBytes < 1) { | |
| 152 | + return yield* new DesktopLogFileWriterConfigurationError({ | |
| 153 | + option: "maxBytes", | |
| 154 | + value: maxBytes, | |
| 155 | + }); | |
| 156 | + } | |
| 157 | + if (maxFiles < 1) { | |
| 158 | + return yield* new DesktopLogFileWriterConfigurationError({ | |
| 159 | + option: "maxFiles", | |
| 160 | + value: maxFiles, | |
| 161 | + }); | |
| 162 | + } | |
| 163 | + | |
| 164 | + yield* fileSystem.makeDirectory(directory, { recursive: true }); | |
| 165 | + | |
| 166 | + const withSuffix = (index: number) => `${input.filePath}.${index}`; | |
| 167 | + const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); | |
| 168 | + const mutex = yield* Semaphore.make(1); | |
| 169 | + | |
| 170 | + const pruneOverflowBackups = Effect.gen(function* () { | |
| 171 | + const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); | |
| 172 | + for (const entry of entries) { | |
| 173 | + if (!entry.startsWith(`${baseName}.`)) continue; | |
| 174 | + const suffix = Number(entry.slice(baseName.length + 1)); | |
| 175 | + if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; | |
| 176 | + yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); | |
| 177 | + } | |
| 178 | + }); | |
| 179 | + | |
| 180 | + const rotate = Effect.gen(function* () { | |
| 181 | + yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore); | |
| 182 | + for (let index = maxFiles - 1; index >= 1; index -= 1) { | |
| 183 | + const source = withSuffix(index); | |
| 184 | + const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false)); | |
| 185 | + if (sourceExists) { | |
| 186 | + yield* fileSystem.rename(source, withSuffix(index + 1)); | |
| 187 | + } | |
| 188 | + } | |
| 189 | + const currentExists = yield* fileSystem | |
| 190 | + .exists(input.filePath) | |
| 191 | + .pipe(Effect.orElseSucceed(() => false)); | |
| 192 | + if (currentExists) { | |
| 193 | + yield* fileSystem.rename(input.filePath, withSuffix(1)); | |
| 194 | + } | |
| 195 | + yield* Ref.set(currentSize, 0); | |
| 196 | + }).pipe( | |
| 197 | + Effect.catch(() => | |
| 198 | + refreshFileSize(fileSystem, input.filePath).pipe( | |
| 199 | + Effect.flatMap((size) => Ref.set(currentSize, size)), | |
| 200 | + ), | |
| 201 | + ), | |
| 202 | + ); | |
| 203 | + | |
| 204 | + const writeBytes = (chunk: Uint8Array): Effect.Effect<void> => { | |
| 205 | + if (chunk.byteLength === 0) return Effect.void; | |
| 206 | + | |
| 207 | + return mutex.withPermits(1)( | |
| 208 | + Effect.gen(function* () { | |
| 209 | + const beforeSize = yield* Ref.get(currentSize); | |
| 210 | + if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) { | |
| 211 | + yield* rotate; | |
| 212 | + } | |
| 213 | + | |
| 214 | + yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" }); | |
| 215 | + const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength; | |
| 216 | + yield* Ref.set(currentSize, afterSize); | |
| 217 | + | |
| 218 | + if (afterSize > maxBytes) { | |
| 219 | + yield* rotate; | |
| 220 | + } | |
| 221 | + }).pipe( | |
| 222 | + Effect.catch(() => | |
| 223 | + refreshFileSize(fileSystem, input.filePath).pipe( | |
| 224 | + Effect.flatMap((size) => Ref.set(currentSize, size)), | |
| 225 | + ), | |
| 226 | + ), | |
| 227 | + ), | |
| 228 | + ); | |
| 229 | + }; | |
| 230 | + | |
| 231 | + yield* pruneOverflowBackups; | |
| 232 | + | |
| 233 | + return { | |
| 234 | + writeBytes, | |
| 235 | + writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), | |
| 236 | + } satisfies RotatingLogFileWriter; | |
| 237 | +}); | |
| 238 | + | |
| 239 | +const readPersistedOtlpTracesUrl: Effect.Effect< | |
| 240 | + Option.Option<string>, | |
| 241 | + never, | |
| 242 | + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment | |
| 243 | +> = Effect.gen(function* () { | |
| 244 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 245 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 246 | + const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); | |
| 247 | + if (Option.isNone(raw)) { | |
| 248 | + return Option.none(); | |
| 249 | + } | |
| 250 | + | |
| 251 | + const parsed = parsePersistedServerObservabilitySettings(raw.value); | |
| 252 | + return Option.fromNullishOr(parsed.otlpTracesUrl); | |
| 253 | +}); | |
| 254 | + | |
| 255 | +const resolveOtlpTracesUrl = Effect.gen(function* () { | |
| 256 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 257 | + if (Option.isSome(environment.otlpTracesUrl)) { | |
| 258 | + return environment.otlpTracesUrl; | |
| 259 | + } | |
| 260 | + return yield* readPersistedOtlpTracesUrl; | |
| 261 | +}); | |
| 262 | + | |
| 263 | +const writeDevelopmentConsoleOutput = ( | |
| 264 | + streamName: "stdout" | "stderr", | |
| 265 | + chunk: Uint8Array, | |
| 266 | +): Effect.Effect<void> => | |
| 267 | + Effect.sync(() => { | |
| 268 | + const output = streamName === "stderr" ? process.stderr : process.stdout; | |
| 269 | + output.write(chunk); | |
| 270 | + }).pipe(Effect.ignore); | |
| 271 | + | |
| 272 | +const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( | |
| 273 | + function* ( | |
| 274 | + logFile: RotatingLogFileWriter, | |
| 275 | + input: { | |
| 276 | + readonly message: string; | |
| 277 | + readonly level: "INFO" | "ERROR"; | |
| 278 | + readonly annotations: Record<string, unknown>; | |
| 279 | + }, | |
| 280 | + ): Effect.fn.Return<void> { | |
| 281 | + return yield* Effect.gen(function* () { | |
| 282 | + const timestamp = DateTime.formatIso(yield* DateTime.now); | |
| 283 | + const encoded = yield* encodeDesktopBackendChildLogRecord({ | |
| 284 | + message: input.message, | |
| 285 | + level: input.level, | |
| 286 | + timestamp, | |
| 287 | + annotations: input.annotations, | |
| 288 | + spans: {}, | |
| 289 | + fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, | |
| 290 | + }); | |
| 291 | + yield* logFile.writeText(`${encoded}\n`); | |
| 292 | + }).pipe(Effect.ignore({ log: true })); | |
| 293 | + }, | |
| 294 | +); | |
| 295 | + | |
| 296 | +const backendOutputLogLayer = Layer.effect( | |
| 297 | + DesktopBackendOutputLog, | |
| 298 | + Effect.gen(function* () { | |
| 299 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 300 | + | |
| 301 | + const writer = yield* makeRotatingLogFileWriter({ | |
| 302 | + filePath: environment.path.join(environment.logDir, "server-child.log"), | |
| 303 | + }).pipe(Effect.option); | |
| 304 | + | |
| 305 | + return Option.match(writer, { | |
| 306 | + onNone: () => DesktopBackendOutputLogNoop, | |
| 307 | + onSome: (logFile) => | |
| 308 | + ({ | |
| 309 | + writeSessionBoundary: Effect.fn( | |
| 310 | + "desktop.observability.backendOutput.writeSessionBoundary", | |
| 311 | + )(function* ({ phase, details }) { | |
| 312 | + const runId = yield* currentDesktopRunId; | |
| 313 | + yield* writeBackendChildLogRecord(logFile, { | |
| 314 | + message: `backend child process session ${phase.toLowerCase()}`, | |
| 315 | + level: "INFO", | |
| 316 | + annotations: { | |
| 317 | + component: "desktop-backend-child", | |
| 318 | + runId, | |
| 319 | + phase, | |
| 320 | + details: sanitizeLogValue(details), | |
| 321 | + }, | |
| 322 | + }); | |
| 323 | + }), | |
| 324 | + writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( | |
| 325 | + function* (streamName, chunk) { | |
| 326 | + if (environment.isDevelopment) { | |
| 327 | + yield* writeDevelopmentConsoleOutput(streamName, chunk); | |
| 328 | + } | |
| 329 | + const runId = yield* currentDesktopRunId; | |
| 330 | + yield* writeBackendChildLogRecord(logFile, { | |
| 331 | + message: "backend child process output", | |
| 332 | + level: streamName === "stderr" ? "ERROR" : "INFO", | |
| 333 | + annotations: { | |
| 334 | + component: "desktop-backend-child", | |
| 335 | + runId, | |
| 336 | + stream: streamName, | |
| 337 | + text: textDecoder.decode(chunk), | |
| 338 | + }, | |
| 339 | + }); | |
| 340 | + }, | |
| 341 | + ), | |
| 342 | + }) satisfies DesktopBackendOutputLogShape, | |
| 343 | + }); | |
| 344 | + }), | |
| 345 | +); | |
| 346 | + | |
| 347 | +const desktopLoggerLayer = Layer.mergeAll( | |
| 348 | + Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }), | |
| 349 | + Layer.succeed(References.MinimumLogLevel, "Info"), | |
| 350 | +); | |
| 351 | + | |
| 352 | +const tracerLayer = Layer.unwrap( | |
| 353 | + Effect.gen(function* () { | |
| 354 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 355 | + const otlpTracesUrl = yield* resolveOtlpTracesUrl; | |
| 356 | + const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson"); | |
| 357 | + const sink = yield* makeTraceSink({ | |
| 358 | + filePath: tracePath, | |
| 359 | + maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, | |
| 360 | + maxFiles: DESKTOP_LOG_FILE_MAX_FILES, | |
| 361 | + batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, | |
| 362 | + }); | |
| 363 | + const delegate = Option.isNone(otlpTracesUrl) | |
| 364 | + ? undefined | |
| 365 | + : yield* OtlpTracer.make({ | |
| 366 | + url: otlpTracesUrl.value, | |
| 367 | + exportInterval: `${environment.otlpExportIntervalMs} millis`, | |
| 368 | + resource: { | |
| 369 | + serviceName: "desktop", | |
| 370 | + attributes: { | |
| 371 | + "service.runtime": "desktop", | |
| 372 | + "service.mode": environment.isDevelopment ? "development" : "packaged", | |
| 373 | + }, | |
| 374 | + }, | |
| 375 | + }); | |
| 376 | + const tracer = yield* makeLocalFileTracer({ | |
| 377 | + filePath: tracePath, | |
| 378 | + maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, | |
| 379 | + maxFiles: DESKTOP_LOG_FILE_MAX_FILES, | |
| 380 | + batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, | |
| 381 | + sink, | |
| 382 | + ...(delegate ? { delegate } : {}), | |
| 383 | + }); | |
| 384 | + | |
| 385 | + return Layer.succeed(Tracer.Tracer, tracer); | |
| 386 | + }), | |
| 387 | +).pipe(Layer.provideMerge(OtlpSerialization.layerJson)); | |
| 388 | + | |
| 389 | +export const layer = Layer.mergeAll( | |
| 390 | + backendOutputLogLayer, | |
| 391 | + desktopLoggerLayer, | |
| 392 | + tracerLayer, | |
| 393 | + Layer.succeed(Tracer.MinimumTraceLevel, "Info"), | |
| 394 | + Layer.succeed(References.TracerTimingEnabled, true), | |
| 395 | +); | |
| @@ -0,0 +1,21 @@ | ||
| 1 | +import * as Context from "effect/Context"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Layer from "effect/Layer"; | |
| 4 | +import * as Ref from "effect/Ref"; | |
| 5 | + | |
| 6 | +export interface DesktopStateShape { | |
| 7 | + readonly backendReady: Ref.Ref<boolean>; | |
| 8 | + readonly quitting: Ref.Ref<boolean>; | |
| 9 | +} | |
| 10 | + | |
| 11 | +export class DesktopState extends Context.Service<DesktopState, DesktopStateShape>()( | |
| 12 | + "t3/desktop/State", | |
| 13 | +) {} | |
| 14 | + | |
| 15 | +export const layer = Layer.effect( | |
| 16 | + DesktopState, | |
| 17 | + Effect.all({ | |
| 18 | + backendReady: Ref.make(false), | |
| 19 | + quitting: Ref.make(false), | |
| 20 | + }), | |
| 21 | +); | |
| @@ -1,47 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "vitest"; | |
| 2 | − | |
| 3 | −import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding.ts"; | |
| 4 | − | |
| 5 | −describe("resolveDesktopAppStageLabel", () => { | |
| 6 | − it("uses Dev in desktop development", () => { | |
| 7 | − expect( | |
| 8 | − resolveDesktopAppStageLabel({ | |
| 9 | − isDevelopment: true, | |
| 10 | − appVersion: "0.0.17-nightly.20260414.1", | |
| 11 | − }), | |
| 12 | − ).toBe("Dev"); | |
| 13 | − }); | |
| 14 | − | |
| 15 | − it("uses Nightly for packaged nightly builds", () => { | |
| 16 | − expect( | |
| 17 | − resolveDesktopAppStageLabel({ | |
| 18 | − isDevelopment: false, | |
| 19 | − appVersion: "0.0.17-nightly.20260414.1", | |
| 20 | − }), | |
| 21 | − ).toBe("Nightly"); | |
| 22 | − }); | |
| 23 | − | |
| 24 | − it("uses Alpha for packaged stable builds", () => { | |
| 25 | − expect( | |
| 26 | − resolveDesktopAppStageLabel({ | |
| 27 | − isDevelopment: false, | |
| 28 | − appVersion: "0.0.17", | |
| 29 | − }), | |
| 30 | − ).toBe("Alpha"); | |
| 31 | − }); | |
| 32 | −}); | |
| 33 | − | |
| 34 | −describe("resolveDesktopAppBranding", () => { | |
| 35 | − it("returns a complete desktop branding payload", () => { | |
| 36 | − expect( | |
| 37 | − resolveDesktopAppBranding({ | |
| 38 | − isDevelopment: false, | |
| 39 | − appVersion: "0.0.17-nightly.20260414.1", | |
| 40 | − }), | |
| 41 | − ).toEqual({ | |
| 42 | − baseName: "T3 Code", | |
| 43 | − stageLabel: "Nightly", | |
| 44 | − displayName: "T3 Code (Nightly)", | |
| 45 | − }); | |
| 46 | − }); | |
| 47 | −}); | |
| @@ -1,28 +0,0 @@ | ||
| 1 | −import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contracts"; | |
| 2 | − | |
| 3 | −import { isNightlyDesktopVersion } from "./updateChannels.ts"; | |
| 4 | − | |
| 5 | −const APP_BASE_NAME = "T3 Code"; | |
| 6 | − | |
| 7 | −export function resolveDesktopAppStageLabel(input: { | |
| 8 | − readonly isDevelopment: boolean; | |
| 9 | − readonly appVersion: string; | |
| 10 | −}): DesktopAppStageLabel { | |
| 11 | − if (input.isDevelopment) { | |
| 12 | − return "Dev"; | |
| 13 | − } | |
| 14 | − | |
| 15 | − return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; | |
| 16 | −} | |
| 17 | − | |
| 18 | −export function resolveDesktopAppBranding(input: { | |
| 19 | − readonly isDevelopment: boolean; | |
| 20 | − readonly appVersion: string; | |
| 21 | −}): DesktopAppBranding { | |
| 22 | − const stageLabel = resolveDesktopAppStageLabel(input); | |
| 23 | − return { | |
| 24 | − baseName: APP_BASE_NAME, | |
| 25 | − stageLabel, | |
| 26 | − displayName: `${APP_BASE_NAME} (${stageLabel})`, | |
| 27 | − }; | |
| 28 | −} | |
| @@ -0,0 +1,195 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, describe, it } from "@effect/vitest"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as FileSystem from "effect/FileSystem"; | |
| 5 | +import * as Layer from "effect/Layer"; | |
| 6 | +import * as Schema from "effect/Schema"; | |
| 7 | + | |
| 8 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 9 | +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | |
| 10 | +import * as DesktopConfig from "../app/DesktopConfig.ts"; | |
| 11 | +import * as DesktopServerExposure from "./DesktopServerExposure.ts"; | |
| 12 | + | |
| 13 | +const PersistedServerObservabilitySettingsDocument = Schema.Struct({ | |
| 14 | + observability: Schema.Struct({ | |
| 15 | + otlpTracesUrl: Schema.String, | |
| 16 | + otlpMetricsUrl: Schema.String, | |
| 17 | + }), | |
| 18 | +}); | |
| 19 | + | |
| 20 | +const encodePersistedServerObservabilitySettingsDocument = Schema.encodeEffect( | |
| 21 | + Schema.fromJsonString(PersistedServerObservabilitySettingsDocument), | |
| 22 | +); | |
| 23 | + | |
| 24 | +const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { | |
| 25 | + getState: Effect.die("unexpected getState"), | |
| 26 | + backendConfig: Effect.succeed({ | |
| 27 | + port: 4888, | |
| 28 | + bindHost: "0.0.0.0", | |
| 29 | + httpBaseUrl: new URL("http://127.0.0.1:4888"), | |
| 30 | + tailscaleServeEnabled: true, | |
| 31 | + tailscaleServePort: 8443, | |
| 32 | + }), | |
| 33 | + configureFromSettings: () => Effect.die("unexpected configureFromSettings"), | |
| 34 | + setMode: () => Effect.die("unexpected setMode"), | |
| 35 | + setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), | |
| 36 | + getAdvertisedEndpoints: Effect.succeed([]), | |
| 37 | +} satisfies DesktopServerExposure.DesktopServerExposureShape); | |
| 38 | + | |
| 39 | +function makeEnvironmentLayer( | |
| 40 | + baseDir: string, | |
| 41 | + options?: { | |
| 42 | + readonly isPackaged?: boolean; | |
| 43 | + readonly devServerUrl?: string; | |
| 44 | + }, | |
| 45 | +) { | |
| 46 | + return DesktopEnvironment.layer({ | |
| 47 | + dirname: "/repo/apps/desktop/src", | |
| 48 | + homeDirectory: baseDir, | |
| 49 | + platform: "darwin", | |
| 50 | + processArch: "x64", | |
| 51 | + appVersion: "1.2.3", | |
| 52 | + appPath: "/repo", | |
| 53 | + isPackaged: options?.isPackaged ?? true, | |
| 54 | + resourcesPath: "/missing/resources", | |
| 55 | + runningUnderArm64Translation: false, | |
| 56 | + }).pipe( | |
| 57 | + Layer.provide( | |
| 58 | + Layer.mergeAll( | |
| 59 | + NodeServices.layer, | |
| 60 | + DesktopConfig.layerTest({ | |
| 61 | + T3CODE_HOME: baseDir, | |
| 62 | + T3CODE_PORT: "9999", | |
| 63 | + T3CODE_MODE: "desktop", | |
| 64 | + T3CODE_DESKTOP_LAN_HOST: "192.168.1.50", | |
| 65 | + VITE_DEV_SERVER_URL: options?.devServerUrl, | |
| 66 | + }), | |
| 67 | + ), | |
| 68 | + ), | |
| 69 | + ); | |
| 70 | +} | |
| 71 | + | |
| 72 | +const withHarness = <A, E, R>( | |
| 73 | + effect: Effect.Effect< | |
| 74 | + A, | |
| 75 | + E, | |
| 76 | + | R | |
| 77 | + | DesktopEnvironment.DesktopEnvironment | |
| 78 | + | FileSystem.FileSystem | |
| 79 | + | DesktopBackendConfiguration.DesktopBackendConfiguration | |
| 80 | + >, | |
| 81 | +) => | |
| 82 | + Effect.gen(function* () { | |
| 83 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 84 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 85 | + prefix: "t3-desktop-backend-config-test-", | |
| 86 | + }); | |
| 87 | + | |
| 88 | + return yield* effect.pipe( | |
| 89 | + Effect.provide( | |
| 90 | + DesktopBackendConfiguration.layer.pipe( | |
| 91 | + Layer.provideMerge(serverExposureLayer), | |
| 92 | + Layer.provideMerge(makeEnvironmentLayer(baseDir)), | |
| 93 | + ), | |
| 94 | + ), | |
| 95 | + ); | |
| 96 | + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); | |
| 97 | + | |
| 98 | +describe("DesktopBackendConfiguration", () => { | |
| 99 | + it.effect("resolves backend start config with a stable scoped bootstrap token", () => | |
| 100 | + withHarness( | |
| 101 | + Effect.gen(function* () { | |
| 102 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 103 | + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 104 | + | |
| 105 | + const first = yield* configuration.resolve; | |
| 106 | + const second = yield* configuration.resolve; | |
| 107 | + | |
| 108 | + assert.equal(first.executablePath, process.execPath); | |
| 109 | + assert.equal(first.entryPath, environment.backendEntryPath); | |
| 110 | + assert.equal(first.cwd, environment.backendCwd); | |
| 111 | + assert.equal(first.captureOutput, true); | |
| 112 | + assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1"); | |
| 113 | + assert.isUndefined(first.env.T3CODE_PORT); | |
| 114 | + assert.isUndefined(first.env.T3CODE_MODE); | |
| 115 | + assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST); | |
| 116 | + | |
| 117 | + assert.equal(first.bootstrap.mode, "desktop"); | |
| 118 | + assert.equal(first.bootstrap.noBrowser, true); | |
| 119 | + assert.equal(first.bootstrap.port, 4888); | |
| 120 | + assert.equal(first.bootstrap.host, "0.0.0.0"); | |
| 121 | + assert.equal(first.bootstrap.t3Home, environment.baseDir); | |
| 122 | + assert.equal(first.bootstrap.tailscaleServeEnabled, true); | |
| 123 | + assert.equal(first.bootstrap.tailscaleServePort, 8443); | |
| 124 | + assert.match(first.bootstrap.desktopBootstrapToken, /^[0-9a-f]{48}$/i); | |
| 125 | + assert.equal(second.bootstrap.desktopBootstrapToken, first.bootstrap.desktopBootstrapToken); | |
| 126 | + }), | |
| 127 | + ), | |
| 128 | + ); | |
| 129 | + | |
| 130 | + it.effect("includes persisted backend observability endpoints when present", () => | |
| 131 | + withHarness( | |
| 132 | + Effect.gen(function* () { | |
| 133 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 134 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 135 | + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 136 | + | |
| 137 | + yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), { | |
| 138 | + recursive: true, | |
| 139 | + }); | |
| 140 | + yield* fileSystem.writeFileString( | |
| 141 | + environment.serverSettingsPath, | |
| 142 | + yield* encodePersistedServerObservabilitySettingsDocument({ | |
| 143 | + observability: { | |
| 144 | + otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", | |
| 145 | + otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", | |
| 146 | + }, | |
| 147 | + }), | |
| 148 | + ); | |
| 149 | + | |
| 150 | + const config = yield* configuration.resolve; | |
| 151 | + assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); | |
| 152 | + assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); | |
| 153 | + }), | |
| 154 | + ), | |
| 155 | + ); | |
| 156 | + | |
| 157 | + it.effect("omits backend observability endpoints when settings are missing", () => | |
| 158 | + withHarness( | |
| 159 | + Effect.gen(function* () { | |
| 160 | + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 161 | + const config = yield* configuration.resolve; | |
| 162 | + | |
| 163 | + assert.isUndefined(config.bootstrap.otlpTracesUrl); | |
| 164 | + assert.isUndefined(config.bootstrap.otlpMetricsUrl); | |
| 165 | + }), | |
| 166 | + ), | |
| 167 | + ); | |
| 168 | + | |
| 169 | + it.effect("captures backend output in development so child process logs can be persisted", () => | |
| 170 | + Effect.gen(function* () { | |
| 171 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 172 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 173 | + prefix: "t3-desktop-backend-config-test-", | |
| 174 | + }); | |
| 175 | + | |
| 176 | + yield* Effect.gen(function* () { | |
| 177 | + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 178 | + const config = yield* configuration.resolve; | |
| 179 | + assert.equal(config.captureOutput, true); | |
| 180 | + }).pipe( | |
| 181 | + Effect.provide( | |
| 182 | + DesktopBackendConfiguration.layer.pipe( | |
| 183 | + Layer.provideMerge(serverExposureLayer), | |
| 184 | + Layer.provideMerge( | |
| 185 | + makeEnvironmentLayer(baseDir, { | |
| 186 | + isPackaged: false, | |
| 187 | + devServerUrl: "http://127.0.0.1:5733", | |
| 188 | + }), | |
| 189 | + ), | |
| 190 | + ), | |
| 191 | + ), | |
| 192 | + ); | |
| 193 | + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 194 | + ); | |
| 195 | +}); | |
| @@ -0,0 +1,170 @@ | ||
| 1 | +import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; | |
| 2 | +import * as Context from "effect/Context"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as FileSystem from "effect/FileSystem"; | |
| 5 | +import * as Layer from "effect/Layer"; | |
| 6 | +import * as Option from "effect/Option"; | |
| 7 | +import * as Random from "effect/Random"; | |
| 8 | +import * as Ref from "effect/Ref"; | |
| 9 | + | |
| 10 | +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; | |
| 11 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 12 | +import * as DesktopObservability from "../app/DesktopObservability.ts"; | |
| 13 | +import * as DesktopServerExposure from "./DesktopServerExposure.ts"; | |
| 14 | + | |
| 15 | +export interface DesktopBackendConfigurationShape { | |
| 16 | + readonly resolve: Effect.Effect<DesktopBackendManager.DesktopBackendStartConfig>; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export class DesktopBackendConfiguration extends Context.Service< | |
| 20 | + DesktopBackendConfiguration, | |
| 21 | + DesktopBackendConfigurationShape | |
| 22 | +>()("t3/desktop/BackendConfiguration") {} | |
| 23 | + | |
| 24 | +interface BackendObservabilitySettings { | |
| 25 | + readonly otlpTracesUrl: Option.Option<string>; | |
| 26 | + readonly otlpMetricsUrl: Option.Option<string>; | |
| 27 | +} | |
| 28 | + | |
| 29 | +const emptyBackendObservabilitySettings: BackendObservabilitySettings = { | |
| 30 | + otlpTracesUrl: Option.none(), | |
| 31 | + otlpMetricsUrl: Option.none(), | |
| 32 | +}; | |
| 33 | + | |
| 34 | +const DESKTOP_BACKEND_ENV_NAMES = [ | |
| 35 | + "T3CODE_PORT", | |
| 36 | + "T3CODE_MODE", | |
| 37 | + "T3CODE_NO_BROWSER", | |
| 38 | + "T3CODE_HOST", | |
| 39 | + "T3CODE_DESKTOP_WS_URL", | |
| 40 | + "T3CODE_DESKTOP_LAN_ACCESS", | |
| 41 | + "T3CODE_DESKTOP_LAN_HOST", | |
| 42 | + "T3CODE_DESKTOP_HTTPS_ENDPOINTS", | |
| 43 | + "T3CODE_TAILSCALE_SERVE", | |
| 44 | + "T3CODE_TAILSCALE_SERVE_PORT", | |
| 45 | +] as const; | |
| 46 | + | |
| 47 | +const backendChildEnvPatch = (): Record<string, string | undefined> => | |
| 48 | + Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); | |
| 49 | + | |
| 50 | +const { logWarning: logBackendConfigurationWarning } = DesktopObservability.makeComponentLogger( | |
| 51 | + "desktop-backend-configuration", | |
| 52 | +); | |
| 53 | + | |
| 54 | +const readPersistedBackendObservabilitySettings: Effect.Effect< | |
| 55 | + BackendObservabilitySettings, | |
| 56 | + never, | |
| 57 | + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment | |
| 58 | +> = Effect.gen(function* () { | |
| 59 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 60 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 61 | + const exists = yield* fileSystem | |
| 62 | + .exists(environment.serverSettingsPath) | |
| 63 | + .pipe(Effect.orElseSucceed(() => false)); | |
| 64 | + if (!exists) { | |
| 65 | + return emptyBackendObservabilitySettings; | |
| 66 | + } | |
| 67 | + | |
| 68 | + const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); | |
| 69 | + if (Option.isNone(raw)) { | |
| 70 | + yield* logBackendConfigurationWarning( | |
| 71 | + "failed to read persisted backend observability settings", | |
| 72 | + ); | |
| 73 | + return emptyBackendObservabilitySettings; | |
| 74 | + } | |
| 75 | + | |
| 76 | + const parsed = parsePersistedServerObservabilitySettings(raw.value); | |
| 77 | + return { | |
| 78 | + otlpTracesUrl: Option.fromNullishOr(parsed.otlpTracesUrl), | |
| 79 | + otlpMetricsUrl: Option.fromNullishOr(parsed.otlpMetricsUrl), | |
| 80 | + }; | |
| 81 | +}); | |
| 82 | + | |
| 83 | +const getOrCreateBootstrapToken = Effect.fn("desktop.backendConfiguration.bootstrapToken")( | |
| 84 | + function* (tokenRef: Ref.Ref<Option.Option<string>>) { | |
| 85 | + const existing = yield* Ref.get(tokenRef); | |
| 86 | + if (Option.isSome(existing)) { | |
| 87 | + return existing.value; | |
| 88 | + } | |
| 89 | + | |
| 90 | + let token = ""; | |
| 91 | + while (token.length < 48) { | |
| 92 | + token += (yield* Random.nextUUIDv4).replace(/-/g, ""); | |
| 93 | + } | |
| 94 | + token = token.slice(0, 48); | |
| 95 | + yield* Ref.set(tokenRef, Option.some(token)); | |
| 96 | + return token; | |
| 97 | + }, | |
| 98 | +); | |
| 99 | + | |
| 100 | +const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolveStartConfig")( | |
| 101 | + function* (input: { | |
| 102 | + readonly bootstrapToken: string; | |
| 103 | + readonly observabilitySettings: BackendObservabilitySettings; | |
| 104 | + }): Effect.fn.Return< | |
| 105 | + DesktopBackendManager.DesktopBackendStartConfig, | |
| 106 | + never, | |
| 107 | + DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure | |
| 108 | + > { | |
| 109 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 110 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 111 | + const backendExposure = yield* serverExposure.backendConfig; | |
| 112 | + | |
| 113 | + return { | |
| 114 | + executablePath: process.execPath, | |
| 115 | + entryPath: environment.backendEntryPath, | |
| 116 | + cwd: environment.backendCwd, | |
| 117 | + env: { | |
| 118 | + ...backendChildEnvPatch(), | |
| 119 | + ELECTRON_RUN_AS_NODE: "1", | |
| 120 | + }, | |
| 121 | + bootstrap: { | |
| 122 | + mode: "desktop", | |
| 123 | + noBrowser: true, | |
| 124 | + port: backendExposure.port, | |
| 125 | + t3Home: environment.baseDir, | |
| 126 | + host: backendExposure.bindHost, | |
| 127 | + desktopBootstrapToken: input.bootstrapToken, | |
| 128 | + tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, | |
| 129 | + tailscaleServePort: backendExposure.tailscaleServePort, | |
| 130 | + ...Option.match(input.observabilitySettings.otlpTracesUrl, { | |
| 131 | + onNone: () => ({}), | |
| 132 | + onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), | |
| 133 | + }), | |
| 134 | + ...Option.match(input.observabilitySettings.otlpMetricsUrl, { | |
| 135 | + onNone: () => ({}), | |
| 136 | + onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), | |
| 137 | + }), | |
| 138 | + }, | |
| 139 | + httpBaseUrl: backendExposure.httpBaseUrl, | |
| 140 | + captureOutput: true, | |
| 141 | + }; | |
| 142 | + }, | |
| 143 | +); | |
| 144 | + | |
| 145 | +export const layer = Layer.effect( | |
| 146 | + DesktopBackendConfiguration, | |
| 147 | + Effect.gen(function* () { | |
| 148 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 149 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 150 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 151 | + const tokenRef = yield* Ref.make(Option.none<string>()); | |
| 152 | + | |
| 153 | + return DesktopBackendConfiguration.of({ | |
| 154 | + resolve: Effect.gen(function* () { | |
| 155 | + const bootstrapToken = yield* getOrCreateBootstrapToken(tokenRef); | |
| 156 | + const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( | |
| 157 | + Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 158 | + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | |
| 159 | + ); | |
| 160 | + return yield* resolveBackendStartConfig({ | |
| 161 | + bootstrapToken, | |
| 162 | + observabilitySettings, | |
| 163 | + }).pipe( | |
| 164 | + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | |
| 165 | + Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), | |
| 166 | + ); | |
| 167 | + }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")), | |
| 168 | + }); | |
| 169 | + }), | |
| 170 | +); | |
| @@ -0,0 +1,490 @@ | ||
| 1 | +import { | |
| 2 | + DesktopBackendBootstrap, | |
| 3 | + type DesktopBackendBootstrap as DesktopBackendBootstrapValue, | |
| 4 | +} from "@t3tools/contracts"; | |
| 5 | +import { assert, describe, it } from "@effect/vitest"; | |
| 6 | +import * as Deferred from "effect/Deferred"; | |
| 7 | +import * as Duration from "effect/Duration"; | |
| 8 | +import * as Effect from "effect/Effect"; | |
| 9 | +import * as FileSystem from "effect/FileSystem"; | |
| 10 | +import * as Layer from "effect/Layer"; | |
| 11 | +import * as Option from "effect/Option"; | |
| 12 | +import * as Queue from "effect/Queue"; | |
| 13 | +import * as Ref from "effect/Ref"; | |
| 14 | +import * as Schema from "effect/Schema"; | |
| 15 | +import * as Sink from "effect/Sink"; | |
| 16 | +import * as Scope from "effect/Scope"; | |
| 17 | +import * as Stream from "effect/Stream"; | |
| 18 | +import { TestClock } from "effect/testing"; | |
| 19 | +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | |
| 20 | +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 21 | + | |
| 22 | +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; | |
| 23 | +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | |
| 24 | +import * as DesktopObservability from "../app/DesktopObservability.ts"; | |
| 25 | +import * as DesktopState from "../app/DesktopState.ts"; | |
| 26 | +import * as DesktopWindow from "../window/DesktopWindow.ts"; | |
| 27 | + | |
| 28 | +const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { | |
| 29 | + executablePath: "/electron", | |
| 30 | + entryPath: "/server/bin.mjs", | |
| 31 | + cwd: "/server", | |
| 32 | + env: { ELECTRON_RUN_AS_NODE: "1" }, | |
| 33 | + bootstrap: { | |
| 34 | + mode: "desktop", | |
| 35 | + noBrowser: true, | |
| 36 | + port: 3773, | |
| 37 | + t3Home: "/tmp/t3", | |
| 38 | + host: "127.0.0.1", | |
| 39 | + desktopBootstrapToken: "token", | |
| 40 | + tailscaleServeEnabled: false, | |
| 41 | + tailscaleServePort: 443, | |
| 42 | + }, | |
| 43 | + httpBaseUrl: new URL("http://127.0.0.1:3773"), | |
| 44 | + captureOutput: true, | |
| 45 | +}; | |
| 46 | + | |
| 47 | +const configWithObservability: DesktopBackendBootstrapValue = { | |
| 48 | + ...baseConfig.bootstrap, | |
| 49 | + tailscaleServeEnabled: true, | |
| 50 | + otlpTracesUrl: "http://127.0.0.1:4318/v1/traces", | |
| 51 | +}; | |
| 52 | + | |
| 53 | +function makeProcess(options?: { | |
| 54 | + readonly stdout?: Stream.Stream<Uint8Array>; | |
| 55 | + readonly stderr?: Stream.Stream<Uint8Array>; | |
| 56 | + readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>; | |
| 57 | + readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; | |
| 58 | +}): ChildProcessSpawner.ChildProcessHandle { | |
| 59 | + return ChildProcessSpawner.makeHandle({ | |
| 60 | + pid: ChildProcessSpawner.ProcessId(123), | |
| 61 | + stdout: options?.stdout ?? Stream.empty, | |
| 62 | + stderr: options?.stderr ?? Stream.empty, | |
| 63 | + all: Stream.merge(options?.stdout ?? Stream.empty, options?.stderr ?? Stream.empty), | |
| 64 | + exitCode: options?.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)), | |
| 65 | + isRunning: Effect.succeed(false), | |
| 66 | + kill: options?.kill ?? (() => Effect.void), | |
| 67 | + stdin: Sink.drain, | |
| 68 | + getInputFd: () => Sink.drain, | |
| 69 | + getOutputFd: () => Stream.empty, | |
| 70 | + unref: Effect.succeed(Effect.void), | |
| 71 | + }); | |
| 72 | +} | |
| 73 | + | |
| 74 | +function responseForRequest( | |
| 75 | + request: HttpClientRequest.HttpClientRequest, | |
| 76 | + status: number, | |
| 77 | +): HttpClientResponse.HttpClientResponse { | |
| 78 | + return HttpClientResponse.fromWeb(request, new Response(null, { status })); | |
| 79 | +} | |
| 80 | + | |
| 81 | +function httpClientLayer( | |
| 82 | + handler: ( | |
| 83 | + request: HttpClientRequest.HttpClientRequest, | |
| 84 | + ) => Effect.Effect<HttpClientResponse.HttpClientResponse>, | |
| 85 | +) { | |
| 86 | + return Layer.succeed( | |
| 87 | + HttpClient.HttpClient, | |
| 88 | + HttpClient.make((request) => handler(request)), | |
| 89 | + ); | |
| 90 | +} | |
| 91 | + | |
| 92 | +const healthyHttpClientLayer = httpClientLayer((request) => | |
| 93 | + Effect.succeed(responseForRequest(request, 200)), | |
| 94 | +); | |
| 95 | + | |
| 96 | +function decodeBootstrap(raw: string) { | |
| 97 | + return Schema.decodeEffect(Schema.fromJsonString(DesktopBackendBootstrap))(raw); | |
| 98 | +} | |
| 99 | + | |
| 100 | +function makeManagerLayer(input: { | |
| 101 | + readonly spawnerLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>; | |
| 102 | + readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; | |
| 103 | + readonly backendOutputLog?: Partial<DesktopObservability.DesktopBackendOutputLogShape>; | |
| 104 | + readonly desktopState?: DesktopState.DesktopStateShape; | |
| 105 | + readonly desktopWindow?: Partial<DesktopWindow.DesktopWindowShape>; | |
| 106 | + readonly config?: DesktopBackendManager.DesktopBackendStartConfig; | |
| 107 | +}) { | |
| 108 | + return DesktopBackendManager.layer.pipe( | |
| 109 | + Layer.provide( | |
| 110 | + Layer.mergeAll( | |
| 111 | + FileSystem.layerNoop({ | |
| 112 | + exists: () => Effect.succeed(true), | |
| 113 | + }), | |
| 114 | + Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { | |
| 115 | + resolve: Effect.succeed(input.config ?? baseConfig), | |
| 116 | + }), | |
| 117 | + input.spawnerLayer, | |
| 118 | + input.httpClientLayer ?? healthyHttpClientLayer, | |
| 119 | + input.desktopState | |
| 120 | + ? Layer.succeed(DesktopState.DesktopState, input.desktopState) | |
| 121 | + : DesktopState.layer, | |
| 122 | + Layer.succeed(DesktopObservability.DesktopBackendOutputLog, { | |
| 123 | + writeSessionBoundary: () => Effect.void, | |
| 124 | + writeOutputChunk: () => Effect.void, | |
| 125 | + ...input.backendOutputLog, | |
| 126 | + } satisfies DesktopObservability.DesktopBackendOutputLogShape), | |
| 127 | + Layer.succeed(DesktopWindow.DesktopWindow, { | |
| 128 | + createMain: Effect.die("unexpected createMain"), | |
| 129 | + ensureMain: Effect.die("unexpected ensureMain"), | |
| 130 | + revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), | |
| 131 | + activate: Effect.void, | |
| 132 | + createMainIfBackendReady: Effect.void, | |
| 133 | + handleBackendReady: Effect.void, | |
| 134 | + dispatchMenuAction: () => Effect.void, | |
| 135 | + syncAppearance: Effect.void, | |
| 136 | + ...input.desktopWindow, | |
| 137 | + } satisfies DesktopWindow.DesktopWindowShape), | |
| 138 | + ), | |
| 139 | + ), | |
| 140 | + ); | |
| 141 | +} | |
| 142 | + | |
| 143 | +describe("DesktopBackendManager", () => { | |
| 144 | + it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => | |
| 145 | + Effect.gen(function* () { | |
| 146 | + let spawnedCommand: ChildProcess.Command | undefined; | |
| 147 | + let bootstrapJson = ""; | |
| 148 | + let readyCount = 0; | |
| 149 | + const ready = yield* Deferred.make<void>(); | |
| 150 | + const exited = yield* Queue.unbounded<void>(); | |
| 151 | + | |
| 152 | + const spawnerLayer = Layer.succeed( | |
| 153 | + ChildProcessSpawner.ChildProcessSpawner, | |
| 154 | + ChildProcessSpawner.make((command) => | |
| 155 | + Effect.gen(function* () { | |
| 156 | + spawnedCommand = command; | |
| 157 | + if (command._tag === "StandardCommand") { | |
| 158 | + const fd3 = command.options.additionalFds?.fd3; | |
| 159 | + if (fd3?.type === "input" && fd3.stream) { | |
| 160 | + bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); | |
| 161 | + } | |
| 162 | + } | |
| 163 | + | |
| 164 | + return makeProcess({ | |
| 165 | + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 166 | + }); | |
| 167 | + }), | |
| 168 | + ), | |
| 169 | + ); | |
| 170 | + | |
| 171 | + const managerLayer = makeManagerLayer({ | |
| 172 | + config: { | |
| 173 | + ...baseConfig, | |
| 174 | + bootstrap: configWithObservability, | |
| 175 | + }, | |
| 176 | + spawnerLayer, | |
| 177 | + desktopWindow: { | |
| 178 | + handleBackendReady: Effect.sync(() => { | |
| 179 | + readyCount += 1; | |
| 180 | + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), | |
| 181 | + }, | |
| 182 | + backendOutputLog: { | |
| 183 | + writeSessionBoundary: ({ phase }) => | |
| 184 | + phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, | |
| 185 | + }, | |
| 186 | + }); | |
| 187 | + | |
| 188 | + yield* Effect.gen(function* () { | |
| 189 | + const manager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 190 | + yield* manager.start; | |
| 191 | + yield* Queue.take(exited); | |
| 192 | + | |
| 193 | + assert.equal(readyCount, 1); | |
| 194 | + assert.isDefined(spawnedCommand); | |
| 195 | + if (spawnedCommand._tag !== "StandardCommand") { | |
| 196 | + throw new Error("Expected backend to spawn a standard command."); | |
| 197 | + } | |
| 198 | + | |
| 199 | + assert.equal(spawnedCommand.command, "/electron"); | |
| 200 | + assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]); | |
| 201 | + assert.equal(spawnedCommand.options.cwd, "/server"); | |
| 202 | + assert.equal(spawnedCommand.options.extendEnv, true); | |
| 203 | + assert.equal(spawnedCommand.options.stdout, "pipe"); | |
| 204 | + assert.equal(spawnedCommand.options.stderr, "pipe"); | |
| 205 | + assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); | |
| 206 | + assert.isDefined(spawnedCommand.options.forceKillAfter); | |
| 207 | + assert.equal( | |
| 208 | + Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), | |
| 209 | + 2_000, | |
| 210 | + ); | |
| 211 | + | |
| 212 | + assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); | |
| 213 | + }).pipe(Effect.provide(managerLayer)); | |
| 214 | + }), | |
| 215 | + ); | |
| 216 | + | |
| 217 | + it.effect("retries HTTP readiness before reporting the backend ready", () => | |
| 218 | + Effect.gen(function* () { | |
| 219 | + const requestUrls: Array<string> = []; | |
| 220 | + const statuses = [503, 200]; | |
| 221 | + let readyCount = 0; | |
| 222 | + const firstRequest = yield* Deferred.make<void>(); | |
| 223 | + const ready = yield* Deferred.make<void>(); | |
| 224 | + const exited = yield* Queue.unbounded<void>(); | |
| 225 | + | |
| 226 | + const spawnerLayer = Layer.succeed( | |
| 227 | + ChildProcessSpawner.ChildProcessSpawner, | |
| 228 | + ChildProcessSpawner.make(() => | |
| 229 | + Effect.succeed( | |
| 230 | + makeProcess({ | |
| 231 | + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 232 | + }), | |
| 233 | + ), | |
| 234 | + ), | |
| 235 | + ); | |
| 236 | + | |
| 237 | + const managerLayer = makeManagerLayer({ | |
| 238 | + spawnerLayer, | |
| 239 | + httpClientLayer: httpClientLayer((request) => | |
| 240 | + Effect.gen(function* () { | |
| 241 | + const status = statuses.shift(); | |
| 242 | + assert.isDefined(status); | |
| 243 | + requestUrls.push(request.url); | |
| 244 | + yield* Deferred.succeed(firstRequest, void 0); | |
| 245 | + return responseForRequest(request, status); | |
| 246 | + }), | |
| 247 | + ), | |
| 248 | + desktopWindow: { | |
| 249 | + handleBackendReady: Effect.sync(() => { | |
| 250 | + readyCount += 1; | |
| 251 | + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), | |
| 252 | + }, | |
| 253 | + backendOutputLog: { | |
| 254 | + writeSessionBoundary: ({ phase }) => | |
| 255 | + phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, | |
| 256 | + }, | |
| 257 | + }); | |
| 258 | + | |
| 259 | + yield* Effect.gen(function* () { | |
| 260 | + const manager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 261 | + yield* manager.start; | |
| 262 | + yield* Deferred.await(firstRequest); | |
| 263 | + | |
| 264 | + assert.equal(readyCount, 0); | |
| 265 | + assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]); | |
| 266 | + | |
| 267 | + yield* TestClock.adjust(Duration.millis(100)); | |
| 268 | + yield* Queue.take(exited); | |
| 269 | + | |
| 270 | + assert.equal(readyCount, 1); | |
| 271 | + assert.deepEqual(requestUrls, [ | |
| 272 | + "http://127.0.0.1:3773/.well-known/t3/environment", | |
| 273 | + "http://127.0.0.1:3773/.well-known/t3/environment", | |
| 274 | + ]); | |
| 275 | + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); | |
| 276 | + }), | |
| 277 | + ); | |
| 278 | + | |
| 279 | + it.effect("starts the configured backend and closes the scoped process on stop", () => | |
| 280 | + Effect.gen(function* () { | |
| 281 | + let startCount = 0; | |
| 282 | + let closedCount = 0; | |
| 283 | + const closed = yield* Deferred.make<void>(); | |
| 284 | + const startedPids = yield* Queue.unbounded<number>(); | |
| 285 | + const ready = yield* Deferred.make<void>(); | |
| 286 | + const backendReady = yield* Ref.make(false); | |
| 287 | + const quitting = yield* Ref.make(false); | |
| 288 | + | |
| 289 | + const spawnerLayer = Layer.succeed( | |
| 290 | + ChildProcessSpawner.ChildProcessSpawner, | |
| 291 | + ChildProcessSpawner.make(() => | |
| 292 | + Effect.gen(function* () { | |
| 293 | + const scope = yield* Scope.Scope; | |
| 294 | + startCount += 1; | |
| 295 | + yield* Queue.offer(startedPids, 123); | |
| 296 | + const close = Effect.sync(() => { | |
| 297 | + closedCount += 1; | |
| 298 | + }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid); | |
| 299 | + | |
| 300 | + yield* Scope.addFinalizer(scope, close); | |
| 301 | + | |
| 302 | + return makeProcess({ | |
| 303 | + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 304 | + kill: () => close, | |
| 305 | + }); | |
| 306 | + }), | |
| 307 | + ), | |
| 308 | + ); | |
| 309 | + | |
| 310 | + const managerLayer = makeManagerLayer({ | |
| 311 | + spawnerLayer, | |
| 312 | + desktopState: { | |
| 313 | + backendReady, | |
| 314 | + quitting, | |
| 315 | + }, | |
| 316 | + desktopWindow: { | |
| 317 | + handleBackendReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid), | |
| 318 | + }, | |
| 319 | + }); | |
| 320 | + | |
| 321 | + yield* Effect.gen(function* () { | |
| 322 | + const manager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 323 | + assert.isTrue(Option.isNone(yield* manager.currentConfig)); | |
| 324 | + | |
| 325 | + yield* manager.start; | |
| 326 | + assert.equal(yield* Queue.take(startedPids), 123); | |
| 327 | + yield* Deferred.await(ready); | |
| 328 | + assert.isTrue(yield* Ref.get(backendReady)); | |
| 329 | + assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig)); | |
| 330 | + | |
| 331 | + const runningSnapshot = yield* manager.snapshot; | |
| 332 | + assert.equal(runningSnapshot.ready, true); | |
| 333 | + assert.deepEqual(runningSnapshot.activePid, Option.some(123)); | |
| 334 | + | |
| 335 | + yield* manager.stop(); | |
| 336 | + assert.equal(startCount, 1); | |
| 337 | + assert.equal(closedCount, 1); | |
| 338 | + | |
| 339 | + const stoppedSnapshot = yield* manager.snapshot; | |
| 340 | + assert.isFalse(yield* Ref.get(backendReady)); | |
| 341 | + assert.equal(stoppedSnapshot.desiredRunning, false); | |
| 342 | + assert.equal(stoppedSnapshot.ready, false); | |
| 343 | + assert.equal(Option.isNone(stoppedSnapshot.activePid), true); | |
| 344 | + }).pipe(Effect.provide(managerLayer)); | |
| 345 | + }), | |
| 346 | + ); | |
| 347 | + | |
| 348 | + it.effect("restarts an unexpectedly exited backend with the Effect clock", () => | |
| 349 | + Effect.gen(function* () { | |
| 350 | + const starts = yield* Queue.unbounded<number>(); | |
| 351 | + let startCount = 0; | |
| 352 | + | |
| 353 | + const spawnerLayer = Layer.succeed( | |
| 354 | + ChildProcessSpawner.ChildProcessSpawner, | |
| 355 | + ChildProcessSpawner.make(() => | |
| 356 | + Effect.sync(() => { | |
| 357 | + startCount += 1; | |
| 358 | + return makeProcess({ | |
| 359 | + exitCode: Queue.offer(starts, startCount).pipe( | |
| 360 | + Effect.as(ChildProcessSpawner.ExitCode(1)), | |
| 361 | + ), | |
| 362 | + }); | |
| 363 | + }), | |
| 364 | + ), | |
| 365 | + ); | |
| 366 | + | |
| 367 | + const managerLayer = makeManagerLayer({ | |
| 368 | + spawnerLayer, | |
| 369 | + httpClientLayer: httpClientLayer(() => Effect.never), | |
| 370 | + }); | |
| 371 | + | |
| 372 | + yield* Effect.gen(function* () { | |
| 373 | + const manager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 374 | + yield* manager.start; | |
| 375 | + | |
| 376 | + assert.equal(yield* Queue.take(starts), 1); | |
| 377 | + | |
| 378 | + yield* TestClock.adjust(Duration.millis(499)); | |
| 379 | + assert.equal(yield* Queue.size(starts), 0); | |
| 380 | + yield* TestClock.adjust(Duration.millis(1)); | |
| 381 | + assert.equal(yield* Queue.take(starts), 2); | |
| 382 | + | |
| 383 | + yield* TestClock.adjust(Duration.millis(999)); | |
| 384 | + assert.equal(yield* Queue.size(starts), 0); | |
| 385 | + yield* TestClock.adjust(Duration.millis(1)); | |
| 386 | + assert.equal(yield* Queue.take(starts), 3); | |
| 387 | + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); | |
| 388 | + }), | |
| 389 | + ); | |
| 390 | + | |
| 391 | + it.effect("cancels a scheduled restart when start is requested manually", () => | |
| 392 | + Effect.gen(function* () { | |
| 393 | + const starts = yield* Queue.unbounded<number>(); | |
| 394 | + const secondClosed = yield* Deferred.make<void>(); | |
| 395 | + let startCount = 0; | |
| 396 | + | |
| 397 | + const spawnerLayer = Layer.succeed( | |
| 398 | + ChildProcessSpawner.ChildProcessSpawner, | |
| 399 | + ChildProcessSpawner.make(() => | |
| 400 | + Effect.gen(function* () { | |
| 401 | + startCount += 1; | |
| 402 | + yield* Queue.offer(starts, startCount); | |
| 403 | + | |
| 404 | + if (startCount === 1) { | |
| 405 | + return makeProcess({ | |
| 406 | + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), | |
| 407 | + }); | |
| 408 | + } | |
| 409 | + | |
| 410 | + const scope = yield* Scope.Scope; | |
| 411 | + const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid); | |
| 412 | + yield* Scope.addFinalizer(scope, close); | |
| 413 | + return makeProcess({ | |
| 414 | + exitCode: Deferred.await(secondClosed).pipe( | |
| 415 | + Effect.as(ChildProcessSpawner.ExitCode(0)), | |
| 416 | + ), | |
| 417 | + kill: () => close, | |
| 418 | + }); | |
| 419 | + }), | |
| 420 | + ), | |
| 421 | + ); | |
| 422 | + | |
| 423 | + const managerLayer = makeManagerLayer({ | |
| 424 | + spawnerLayer, | |
| 425 | + httpClientLayer: httpClientLayer(() => Effect.never), | |
| 426 | + }); | |
| 427 | + | |
| 428 | + yield* Effect.gen(function* () { | |
| 429 | + const manager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 430 | + yield* manager.start; | |
| 431 | + | |
| 432 | + assert.equal(yield* Queue.take(starts), 1); | |
| 433 | + | |
| 434 | + yield* manager.start; | |
| 435 | + assert.equal(yield* Queue.take(starts), 2); | |
| 436 | + | |
| 437 | + yield* manager.stop(); | |
| 438 | + yield* TestClock.adjust(Duration.millis(500)); | |
| 439 | + | |
| 440 | + assert.equal(yield* Queue.size(starts), 0); | |
| 441 | + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); | |
| 442 | + }), | |
| 443 | + ); | |
| 444 | + | |
| 445 | + it.effect("does not restart after stop cancels a scheduled restart", () => | |
| 446 | + Effect.gen(function* () { | |
| 447 | + const starts = yield* Queue.unbounded<number>(); | |
| 448 | + let startCount = 0; | |
| 449 | + | |
| 450 | + const spawnerLayer = Layer.succeed( | |
| 451 | + ChildProcessSpawner.ChildProcessSpawner, | |
| 452 | + ChildProcessSpawner.make(() => | |
| 453 | + Effect.sync(() => { | |
| 454 | + startCount += 1; | |
| 455 | + return makeProcess({ | |
| 456 | + exitCode: Queue.offer(starts, startCount).pipe( | |
| 457 | + Effect.as(ChildProcessSpawner.ExitCode(1)), | |
| 458 | + ), | |
| 459 | + }); | |
| 460 | + }), | |
| 461 | + ), | |
| 462 | + ); | |
| 463 | + | |
| 464 | + const managerLayer = makeManagerLayer({ | |
| 465 | + spawnerLayer, | |
| 466 | + httpClientLayer: httpClientLayer(() => Effect.never), | |
| 467 | + }); | |
| 468 | + | |
| 469 | + yield* Effect.gen(function* () { | |
| 470 | + const manager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 471 | + yield* manager.start; | |
| 472 | + assert.equal(yield* Queue.take(starts), 1); | |
| 473 | + | |
| 474 | + let restartScheduled = false; | |
| 475 | + while (!restartScheduled) { | |
| 476 | + restartScheduled = (yield* manager.snapshot).restartScheduled; | |
| 477 | + if (!restartScheduled) { | |
| 478 | + yield* Effect.yieldNow; | |
| 479 | + } | |
| 480 | + } | |
| 481 | + | |
| 482 | + yield* manager.stop(); | |
| 483 | + yield* TestClock.adjust(Duration.millis(500)); | |
| 484 | + | |
| 485 | + assert.equal(yield* Queue.size(starts), 0); | |
| 486 | + assert.equal((yield* manager.snapshot).desiredRunning, false); | |
| 487 | + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); | |
| 488 | + }), | |
| 489 | + ); | |
| 490 | +}); | |
| @@ -0,0 +1,596 @@ | ||
| 1 | +import * as Cause from "effect/Cause"; | |
| 2 | +import * as Context from "effect/Context"; | |
| 3 | +import * as Data from "effect/Data"; | |
| 4 | +import * as Duration from "effect/Duration"; | |
| 5 | +import * as Effect from "effect/Effect"; | |
| 6 | +import * as Exit from "effect/Exit"; | |
| 7 | +import * as Fiber from "effect/Fiber"; | |
| 8 | +import * as FileSystem from "effect/FileSystem"; | |
| 9 | +import * as Layer from "effect/Layer"; | |
| 10 | +import * as Option from "effect/Option"; | |
| 11 | +import * as PlatformError from "effect/PlatformError"; | |
| 12 | +import * as Ref from "effect/Ref"; | |
| 13 | +import * as Result from "effect/Result"; | |
| 14 | +import * as Schedule from "effect/Schedule"; | |
| 15 | +import * as Schema from "effect/Schema"; | |
| 16 | +import * as Semaphore from "effect/Semaphore"; | |
| 17 | +import * as Scope from "effect/Scope"; | |
| 18 | +import * as Stream from "effect/Stream"; | |
| 19 | +import { HttpClient } from "effect/unstable/http"; | |
| 20 | +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 21 | + | |
| 22 | +import { | |
| 23 | + DesktopBackendBootstrap, | |
| 24 | + type DesktopBackendBootstrap as DesktopBackendBootstrapValue, | |
| 25 | +} from "@t3tools/contracts"; | |
| 26 | + | |
| 27 | +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | |
| 28 | +import * as DesktopObservability from "../app/DesktopObservability.ts"; | |
| 29 | +import * as DesktopState from "../app/DesktopState.ts"; | |
| 30 | +import * as DesktopWindow from "../window/DesktopWindow.ts"; | |
| 31 | + | |
| 32 | +const INITIAL_RESTART_DELAY = Duration.millis(500); | |
| 33 | +const MAX_RESTART_DELAY = Duration.seconds(10); | |
| 34 | +const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); | |
| 35 | +const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); | |
| 36 | +const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); | |
| 37 | +const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2); | |
| 38 | +const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; | |
| 39 | + | |
| 40 | +type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient; | |
| 41 | + | |
| 42 | +type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; | |
| 43 | + | |
| 44 | +export type BackendProcessOutputStream = "stdout" | "stderr"; | |
| 45 | + | |
| 46 | +export interface DesktopBackendStartConfig { | |
| 47 | + readonly executablePath: string; | |
| 48 | + readonly entryPath: string; | |
| 49 | + readonly cwd: string; | |
| 50 | + readonly env: Record<string, string | undefined>; | |
| 51 | + readonly bootstrap: DesktopBackendBootstrapValue; | |
| 52 | + readonly httpBaseUrl: URL; | |
| 53 | + readonly captureOutput: boolean; | |
| 54 | +} | |
| 55 | + | |
| 56 | +interface BackendProcessExit { | |
| 57 | + readonly code: Option.Option<number>; | |
| 58 | + readonly reason: string; | |
| 59 | + readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>; | |
| 60 | +} | |
| 61 | + | |
| 62 | +export class BackendTimeoutError extends Data.TaggedError("BackendTimeoutError")<{ | |
| 63 | + readonly url: URL; | |
| 64 | +}> { | |
| 65 | + override get message() { | |
| 66 | + return `Timed out waiting for backend readiness at ${this.url.href}.`; | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +class BackendProcessBootstrapEncodeError extends Data.TaggedError( | |
| 71 | + "BackendProcessBootstrapEncodeError", | |
| 72 | +)<{ | |
| 73 | + readonly cause: Schema.SchemaError; | |
| 74 | +}> { | |
| 75 | + override get message() { | |
| 76 | + return `Failed to encode desktop backend bootstrap payload: ${this.cause.message}`; | |
| 77 | + } | |
| 78 | +} | |
| 79 | + | |
| 80 | +class BackendProcessSpawnError extends Data.TaggedError("BackendProcessSpawnError")<{ | |
| 81 | + readonly cause: PlatformError.PlatformError; | |
| 82 | +}> { | |
| 83 | + override get message() { | |
| 84 | + return `Failed to spawn desktop backend process: ${this.cause.message}`; | |
| 85 | + } | |
| 86 | +} | |
| 87 | + | |
| 88 | +type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; | |
| 89 | + | |
| 90 | +interface RunBackendProcessOptions extends DesktopBackendStartConfig { | |
| 91 | + readonly readinessTimeout?: Duration.Duration; | |
| 92 | + readonly onStarted?: (pid: number) => Effect.Effect<void>; | |
| 93 | + readonly onReady?: () => Effect.Effect<void>; | |
| 94 | + readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect<void>; | |
| 95 | + readonly onOutput?: ( | |
| 96 | + streamName: BackendProcessOutputStream, | |
| 97 | + chunk: Uint8Array, | |
| 98 | + ) => Effect.Effect<void>; | |
| 99 | +} | |
| 100 | + | |
| 101 | +export interface DesktopBackendSnapshot { | |
| 102 | + readonly desiredRunning: boolean; | |
| 103 | + readonly ready: boolean; | |
| 104 | + readonly activePid: Option.Option<number>; | |
| 105 | + readonly restartAttempt: number; | |
| 106 | + readonly restartScheduled: boolean; | |
| 107 | +} | |
| 108 | + | |
| 109 | +export interface DesktopBackendManagerShape { | |
| 110 | + readonly start: Effect.Effect<void>; | |
| 111 | + readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect<void>; | |
| 112 | + readonly currentConfig: Effect.Effect<Option.Option<DesktopBackendStartConfig>>; | |
| 113 | + readonly snapshot: Effect.Effect<DesktopBackendSnapshot>; | |
| 114 | +} | |
| 115 | + | |
| 116 | +export class DesktopBackendManager extends Context.Service< | |
| 117 | + DesktopBackendManager, | |
| 118 | + DesktopBackendManagerShape | |
| 119 | +>()("t3/desktop/BackendManager") {} | |
| 120 | + | |
| 121 | +const { logWarning: logBackendManagerWarning, logError: logBackendManagerError } = | |
| 122 | + DesktopObservability.makeComponentLogger("desktop-backend-manager"); | |
| 123 | + | |
| 124 | +interface ActiveBackendRun { | |
| 125 | + readonly id: number; | |
| 126 | + readonly scope: Scope.Closeable; | |
| 127 | + readonly fiber: Option.Option<Fiber.Fiber<void, never>>; | |
| 128 | + readonly pid: Option.Option<number>; | |
| 129 | +} | |
| 130 | + | |
| 131 | +interface BackendManagerState { | |
| 132 | + readonly desiredRunning: boolean; | |
| 133 | + readonly ready: boolean; | |
| 134 | + readonly config: Option.Option<DesktopBackendStartConfig>; | |
| 135 | + readonly active: Option.Option<ActiveBackendRun>; | |
| 136 | + readonly restartAttempt: number; | |
| 137 | + readonly restartFiber: Option.Option<Fiber.Fiber<void, never>>; | |
| 138 | + readonly nextRunId: number; | |
| 139 | +} | |
| 140 | + | |
| 141 | +const initialState: BackendManagerState = { | |
| 142 | + desiredRunning: false, | |
| 143 | + ready: false, | |
| 144 | + config: Option.none(), | |
| 145 | + active: Option.none(), | |
| 146 | + restartAttempt: 0, | |
| 147 | + restartFiber: Option.none(), | |
| 148 | + nextRunId: 1, | |
| 149 | +}; | |
| 150 | + | |
| 151 | +const activePid = (active: Option.Option<ActiveBackendRun>): Option.Option<number> => | |
| 152 | + Option.flatMap(active, (run) => run.pid); | |
| 153 | + | |
| 154 | +const withActiveRun = | |
| 155 | + (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => | |
| 156 | + (state: BackendManagerState): BackendManagerState => ({ | |
| 157 | + ...state, | |
| 158 | + active: Option.map(state.active, (run) => (run.id === runId ? f(run) : run)), | |
| 159 | + }); | |
| 160 | + | |
| 161 | +const calculateRestartDelay = (attempt: number): Duration.Duration => | |
| 162 | + Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY); | |
| 163 | + | |
| 164 | +const closeRun = ( | |
| 165 | + run: ActiveBackendRun, | |
| 166 | + options?: { readonly timeout?: Duration.Duration }, | |
| 167 | +): Effect.Effect<void> => { | |
| 168 | + const waitForFiber = Option.match(run.fiber, { | |
| 169 | + onNone: () => Effect.void, | |
| 170 | + onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid), | |
| 171 | + }); | |
| 172 | + const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber)); | |
| 173 | + | |
| 174 | + return ( | |
| 175 | + options?.timeout ? close.pipe(Effect.timeoutOption(options.timeout), Effect.asVoid) : close | |
| 176 | + ).pipe(Effect.ignore); | |
| 177 | +}; | |
| 178 | + | |
| 179 | +const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( | |
| 180 | + baseUrl: URL, | |
| 181 | + timeout: Duration.Duration, | |
| 182 | +): Effect.fn.Return<void, BackendTimeoutError, HttpClient.HttpClient> { | |
| 183 | + const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl); | |
| 184 | + const client = (yield* HttpClient.HttpClient).pipe( | |
| 185 | + HttpClient.filterStatusOk, | |
| 186 | + HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)), | |
| 187 | + HttpClient.retry(Schedule.spaced(DEFAULT_BACKEND_READINESS_INTERVAL)), | |
| 188 | + ); | |
| 189 | + | |
| 190 | + yield* client.get(readinessUrl).pipe( | |
| 191 | + Effect.asVoid, | |
| 192 | + Effect.timeout(timeout), | |
| 193 | + Effect.mapError(() => new BackendTimeoutError({ url: readinessUrl })), | |
| 194 | + ); | |
| 195 | +}); | |
| 196 | + | |
| 197 | +function describeProcessExit( | |
| 198 | + result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>, | |
| 199 | +): BackendProcessExit { | |
| 200 | + if (Result.isSuccess(result)) { | |
| 201 | + return { | |
| 202 | + code: Option.some(result.success), | |
| 203 | + reason: `code=${result.success}`, | |
| 204 | + result, | |
| 205 | + }; | |
| 206 | + } | |
| 207 | + | |
| 208 | + return { | |
| 209 | + code: Option.none(), | |
| 210 | + reason: result.failure.message, | |
| 211 | + result, | |
| 212 | + }; | |
| 213 | +} | |
| 214 | + | |
| 215 | +function drainBackendOutput( | |
| 216 | + streamName: BackendProcessOutputStream, | |
| 217 | + stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>, | |
| 218 | + onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect<void>, | |
| 219 | +): Effect.Effect<void> { | |
| 220 | + return stream.pipe( | |
| 221 | + Stream.runForEach((chunk) => onOutput(streamName, chunk)), | |
| 222 | + Effect.ignore, | |
| 223 | + ); | |
| 224 | +} | |
| 225 | + | |
| 226 | +const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); | |
| 227 | + | |
| 228 | +const runBackendProcess = Effect.fn("runBackendProcess")(function* ( | |
| 229 | + options: RunBackendProcessOptions, | |
| 230 | +): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> { | |
| 231 | + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 232 | + const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( | |
| 233 | + Effect.mapError((cause) => new BackendProcessBootstrapEncodeError({ cause })), | |
| 234 | + ); | |
| 235 | + const onOutput = options.onOutput ?? (() => Effect.void); | |
| 236 | + const command = ChildProcess.make( | |
| 237 | + options.executablePath, | |
| 238 | + [options.entryPath, "--bootstrap-fd", "3"], | |
| 239 | + { | |
| 240 | + cwd: options.cwd, | |
| 241 | + env: options.env, | |
| 242 | + extendEnv: true, | |
| 243 | + // In Electron main, process.execPath points to the Electron binary. | |
| 244 | + // Run the child in Node mode so this backend process does not become a GUI app instance. | |
| 245 | + stdin: "ignore", | |
| 246 | + stdout: options.captureOutput ? "pipe" : "inherit", | |
| 247 | + stderr: options.captureOutput ? "pipe" : "inherit", | |
| 248 | + killSignal: "SIGTERM", | |
| 249 | + forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, | |
| 250 | + additionalFds: { | |
| 251 | + fd3: { | |
| 252 | + type: "input", | |
| 253 | + stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)), | |
| 254 | + }, | |
| 255 | + }, | |
| 256 | + }, | |
| 257 | + ); | |
| 258 | + | |
| 259 | + const handle = yield* spawner | |
| 260 | + .spawn(command) | |
| 261 | + .pipe(Effect.mapError((cause) => new BackendProcessSpawnError({ cause }))); | |
| 262 | + | |
| 263 | + yield* options.onStarted?.(handle.pid) ?? Effect.void; | |
| 264 | + if (options.captureOutput) { | |
| 265 | + yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); | |
| 266 | + yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); | |
| 267 | + } | |
| 268 | + yield* waitForHttpReady( | |
| 269 | + options.httpBaseUrl, | |
| 270 | + options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, | |
| 271 | + ).pipe( | |
| 272 | + Effect.tap(() => options.onReady?.() ?? Effect.void), | |
| 273 | + Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), | |
| 274 | + Effect.forkScoped, | |
| 275 | + ); | |
| 276 | + | |
| 277 | + return describeProcessExit(yield* Effect.result(handle.exitCode)); | |
| 278 | +}); | |
| 279 | + | |
| 280 | +const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () { | |
| 281 | + const parentScope = yield* Scope.Scope; | |
| 282 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 283 | + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 284 | + const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog; | |
| 285 | + const desktopState = yield* DesktopState.DesktopState; | |
| 286 | + const desktopWindow = yield* DesktopWindow.DesktopWindow; | |
| 287 | + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 288 | + const httpClient = yield* HttpClient.HttpClient; | |
| 289 | + const state = yield* Ref.make(initialState); | |
| 290 | + const mutex = yield* Semaphore.make(1); | |
| 291 | + | |
| 292 | + const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => | |
| 293 | + Ref.update(state, withActiveRun(runId, f)); | |
| 294 | + | |
| 295 | + const snapshot = Ref.get(state).pipe( | |
| 296 | + Effect.map( | |
| 297 | + (current): DesktopBackendSnapshot => ({ | |
| 298 | + desiredRunning: current.desiredRunning, | |
| 299 | + ready: current.ready, | |
| 300 | + activePid: activePid(current.active), | |
| 301 | + restartAttempt: current.restartAttempt, | |
| 302 | + restartScheduled: Option.isSome(current.restartFiber), | |
| 303 | + }), | |
| 304 | + ), | |
| 305 | + ); | |
| 306 | + const currentConfig = Ref.get(state).pipe(Effect.map((current) => current.config)); | |
| 307 | + | |
| 308 | + const cancelRestart = Effect.gen(function* () { | |
| 309 | + const restartFiber = yield* Ref.modify(state, (current) => [ | |
| 310 | + current.restartFiber, | |
| 311 | + { | |
| 312 | + ...current, | |
| 313 | + restartFiber: Option.none(), | |
| 314 | + }, | |
| 315 | + ]); | |
| 316 | + | |
| 317 | + yield* Option.match(restartFiber, { | |
| 318 | + onNone: () => Effect.void, | |
| 319 | + onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), | |
| 320 | + }); | |
| 321 | + }); | |
| 322 | + | |
| 323 | + const start: Effect.Effect<void> = Effect.suspend(() => | |
| 324 | + mutex.withPermits(1)( | |
| 325 | + Effect.gen(function* () { | |
| 326 | + const current = yield* Ref.get(state); | |
| 327 | + if (Option.isSome(current.active)) { | |
| 328 | + return; | |
| 329 | + } | |
| 330 | + | |
| 331 | + yield* Ref.set(desktopState.backendReady, false); | |
| 332 | + const config = yield* configuration.resolve; | |
| 333 | + const entryExists = yield* fileSystem | |
| 334 | + .exists(config.entryPath) | |
| 335 | + .pipe(Effect.orElseSucceed(() => false)); | |
| 336 | + | |
| 337 | + yield* cancelRestart; | |
| 338 | + yield* Ref.update(state, (latest) => ({ | |
| 339 | + ...latest, | |
| 340 | + desiredRunning: true, | |
| 341 | + ready: false, | |
| 342 | + config: Option.some(config), | |
| 343 | + })); | |
| 344 | + | |
| 345 | + if (!entryExists) { | |
| 346 | + yield* scheduleRestart(`missing server entry at ${config.entryPath}`); | |
| 347 | + return; | |
| 348 | + } | |
| 349 | + | |
| 350 | + const runScope = yield* Scope.make("sequential"); | |
| 351 | + const runId = yield* Ref.modify(state, (latest) => [ | |
| 352 | + latest.nextRunId, | |
| 353 | + { | |
| 354 | + ...latest, | |
| 355 | + active: Option.some({ | |
| 356 | + id: latest.nextRunId, | |
| 357 | + scope: runScope, | |
| 358 | + fiber: Option.none(), | |
| 359 | + pid: Option.none(), | |
| 360 | + } satisfies ActiveBackendRun), | |
| 361 | + nextRunId: latest.nextRunId + 1, | |
| 362 | + }, | |
| 363 | + ]); | |
| 364 | + | |
| 365 | + const finalizeRun = Effect.fn("desktop.backendManager.finalizeRun")(function* ( | |
| 366 | + reason: string, | |
| 367 | + ) { | |
| 368 | + yield* mutex.withPermits(1)( | |
| 369 | + Effect.gen(function* () { | |
| 370 | + const { isCurrentRun, nextState, pid } = yield* Ref.modify( | |
| 371 | + state, | |
| 372 | + ( | |
| 373 | + latest, | |
| 374 | + ): readonly [ | |
| 375 | + { | |
| 376 | + readonly isCurrentRun: boolean; | |
| 377 | + readonly nextState: BackendManagerState; | |
| 378 | + readonly pid: Option.Option<number>; | |
| 379 | + }, | |
| 380 | + BackendManagerState, | |
| 381 | + ] => { | |
| 382 | + const currentRun = Option.getOrUndefined(latest.active); | |
| 383 | + if (currentRun?.id !== runId) { | |
| 384 | + return [ | |
| 385 | + { | |
| 386 | + isCurrentRun: false, | |
| 387 | + nextState: latest, | |
| 388 | + pid: Option.none<number>(), | |
| 389 | + }, | |
| 390 | + latest, | |
| 391 | + ] as const; | |
| 392 | + } | |
| 393 | + | |
| 394 | + const next = { | |
| 395 | + ...latest, | |
| 396 | + active: Option.none<ActiveBackendRun>(), | |
| 397 | + ready: false, | |
| 398 | + }; | |
| 399 | + return [ | |
| 400 | + { | |
| 401 | + isCurrentRun: true, | |
| 402 | + nextState: next, | |
| 403 | + pid: currentRun.pid, | |
| 404 | + }, | |
| 405 | + next, | |
| 406 | + ] as const; | |
| 407 | + }, | |
| 408 | + ); | |
| 409 | + | |
| 410 | + if (isCurrentRun) { | |
| 411 | + if (Option.isSome(pid)) { | |
| 412 | + yield* backendOutputLog.writeSessionBoundary({ | |
| 413 | + phase: "END", | |
| 414 | + details: `pid=${pid.value} ${reason}`, | |
| 415 | + }); | |
| 416 | + } | |
| 417 | + yield* Ref.set(desktopState.backendReady, false); | |
| 418 | + } | |
| 419 | + | |
| 420 | + if (isCurrentRun && nextState.desiredRunning) { | |
| 421 | + yield* scheduleRestart(reason); | |
| 422 | + } | |
| 423 | + }), | |
| 424 | + ); | |
| 425 | + }); | |
| 426 | + | |
| 427 | + const program = runBackendProcess({ | |
| 428 | + ...config, | |
| 429 | + onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) { | |
| 430 | + yield* updateActiveRun(runId, (run) => ({ | |
| 431 | + ...run, | |
| 432 | + pid: Option.some(pid), | |
| 433 | + })); | |
| 434 | + yield* backendOutputLog.writeSessionBoundary({ | |
| 435 | + phase: "START", | |
| 436 | + details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`, | |
| 437 | + }); | |
| 438 | + }), | |
| 439 | + onReady: Effect.fn("desktop.backendManager.onReady")(function* () { | |
| 440 | + const isCurrentRun = yield* Ref.modify(state, (latest) => { | |
| 441 | + const activeRun = Option.getOrUndefined(latest.active); | |
| 442 | + if (activeRun?.id !== runId) { | |
| 443 | + return [false, latest] as const; | |
| 444 | + } | |
| 445 | + | |
| 446 | + return [ | |
| 447 | + true, | |
| 448 | + { | |
| 449 | + ...latest, | |
| 450 | + restartAttempt: 0, | |
| 451 | + ready: true, | |
| 452 | + }, | |
| 453 | + ] as const; | |
| 454 | + }); | |
| 455 | + if (!isCurrentRun) { | |
| 456 | + return; | |
| 457 | + } | |
| 458 | + | |
| 459 | + yield* Ref.set(desktopState.backendReady, true); | |
| 460 | + yield* desktopWindow.handleBackendReady.pipe( | |
| 461 | + Effect.catch((error) => | |
| 462 | + logBackendManagerError("failed to open main window after backend readiness", { | |
| 463 | + message: error.message, | |
| 464 | + }), | |
| 465 | + ), | |
| 466 | + ); | |
| 467 | + }), | |
| 468 | + onReadinessFailure: (error) => | |
| 469 | + logBackendManagerWarning("backend readiness check failed during bootstrap", { | |
| 470 | + error: error.message, | |
| 471 | + }), | |
| 472 | + onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), | |
| 473 | + }).pipe( | |
| 474 | + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 475 | + Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 476 | + Scope.provide(runScope), | |
| 477 | + Effect.matchEffect({ | |
| 478 | + onFailure: (error) => finalizeRun(error.message), | |
| 479 | + onSuccess: (exit) => finalizeRun(exit.reason), | |
| 480 | + }), | |
| 481 | + Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), | |
| 482 | + ); | |
| 483 | + | |
| 484 | + const fiber = yield* Effect.forkIn(program, parentScope); | |
| 485 | + yield* updateActiveRun(runId, (run) => ({ | |
| 486 | + ...run, | |
| 487 | + fiber: Option.some(fiber), | |
| 488 | + })); | |
| 489 | + }), | |
| 490 | + ), | |
| 491 | + ).pipe(Effect.withSpan("desktop.backendManager.start")); | |
| 492 | + | |
| 493 | + const scheduleRestart = Effect.fn("desktop.backendManager.scheduleRestart")(function* ( | |
| 494 | + reason: string, | |
| 495 | + ) { | |
| 496 | + const scheduled = yield* Ref.modify(state, (latest) => { | |
| 497 | + if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) { | |
| 498 | + return [Option.none<Duration.Duration>(), latest] as const; | |
| 499 | + } | |
| 500 | + | |
| 501 | + const delay = calculateRestartDelay(latest.restartAttempt); | |
| 502 | + return [ | |
| 503 | + Option.some(delay), | |
| 504 | + { | |
| 505 | + ...latest, | |
| 506 | + restartAttempt: latest.restartAttempt + 1, | |
| 507 | + }, | |
| 508 | + ] as const; | |
| 509 | + }); | |
| 510 | + | |
| 511 | + yield* Option.match(scheduled, { | |
| 512 | + onNone: () => Effect.void, | |
| 513 | + onSome: Effect.fn("desktop.backendManager.scheduleRestartFiber")(function* (delay) { | |
| 514 | + yield* logBackendManagerError("backend exited unexpectedly; restart scheduled", { | |
| 515 | + reason, | |
| 516 | + delayMs: Duration.toMillis(delay), | |
| 517 | + }); | |
| 518 | + const restartFiber = yield* Effect.forkIn( | |
| 519 | + Effect.sleep(delay).pipe( | |
| 520 | + Effect.andThen( | |
| 521 | + Ref.modify(state, (latest) => { | |
| 522 | + const shouldRestart = latest.desiredRunning; | |
| 523 | + return [ | |
| 524 | + shouldRestart, | |
| 525 | + { | |
| 526 | + ...latest, | |
| 527 | + restartFiber: Option.none(), | |
| 528 | + }, | |
| 529 | + ] as const; | |
| 530 | + }), | |
| 531 | + ), | |
| 532 | + Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), | |
| 533 | + Effect.catchCause((cause) => | |
| 534 | + logBackendManagerError("desktop backend restart fiber failed", { | |
| 535 | + cause: Cause.pretty(cause), | |
| 536 | + }), | |
| 537 | + ), | |
| 538 | + ), | |
| 539 | + parentScope, | |
| 540 | + ); | |
| 541 | + yield* Ref.update(state, (latest) => | |
| 542 | + Option.isNone(latest.restartFiber) | |
| 543 | + ? { | |
| 544 | + ...latest, | |
| 545 | + restartFiber: Option.some(restartFiber), | |
| 546 | + } | |
| 547 | + : latest, | |
| 548 | + ); | |
| 549 | + }), | |
| 550 | + }); | |
| 551 | + }); | |
| 552 | + | |
| 553 | + const stop = Effect.fn("desktop.backendManager.stop")(function* (options?: { | |
| 554 | + readonly timeout?: Duration.Duration; | |
| 555 | + }) { | |
| 556 | + const { active, restartFiber } = yield* mutex.withPermits(1)( | |
| 557 | + Effect.gen(function* () { | |
| 558 | + const result = yield* Ref.modify(state, (latest) => [ | |
| 559 | + { | |
| 560 | + active: latest.active, | |
| 561 | + restartFiber: latest.restartFiber, | |
| 562 | + }, | |
| 563 | + { | |
| 564 | + ...latest, | |
| 565 | + desiredRunning: false, | |
| 566 | + ready: false, | |
| 567 | + active: Option.none<ActiveBackendRun>(), | |
| 568 | + restartFiber: Option.none<Fiber.Fiber<void, never>>(), | |
| 569 | + }, | |
| 570 | + ]); | |
| 571 | + yield* Ref.set(desktopState.backendReady, false); | |
| 572 | + return result; | |
| 573 | + }), | |
| 574 | + ); | |
| 575 | + | |
| 576 | + yield* Option.match(restartFiber, { | |
| 577 | + onNone: () => Effect.void, | |
| 578 | + onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), | |
| 579 | + }); | |
| 580 | + yield* Option.match(active, { | |
| 581 | + onNone: () => Effect.void, | |
| 582 | + onSome: (run) => closeRun(run, options), | |
| 583 | + }); | |
| 584 | + }); | |
| 585 | + | |
| 586 | + yield* Effect.addFinalizer(() => stop()); | |
| 587 | + | |
| 588 | + return DesktopBackendManager.of({ | |
| 589 | + start, | |
| 590 | + stop, | |
| 591 | + currentConfig, | |
| 592 | + snapshot, | |
| 593 | + }); | |
| 594 | +}); | |
| 595 | + | |
| 596 | +export const layer = Layer.effect(DesktopBackendManager, makeDesktopBackendManager()); | |
| @@ -0,0 +1,365 @@ | ||
| 1 | +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; | |
| 2 | +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; | |
| 3 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | +import { assert, describe, it } from "@effect/vitest"; | |
| 5 | +import * as Effect from "effect/Effect"; | |
| 6 | +import * as FileSystem from "effect/FileSystem"; | |
| 7 | +import * as Layer from "effect/Layer"; | |
| 8 | +import * as Sink from "effect/Sink"; | |
| 9 | +import * as Stream from "effect/Stream"; | |
| 10 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 11 | + | |
| 12 | +import { | |
| 13 | + DesktopEnvironment, | |
| 14 | + layer as makeDesktopEnvironmentLayer, | |
| 15 | +} from "../app/DesktopEnvironment.ts"; | |
| 16 | +import * as DesktopConfig from "../app/DesktopConfig.ts"; | |
| 17 | +import * as DesktopServerExposure from "./DesktopServerExposure.ts"; | |
| 18 | +import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; | |
| 19 | +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | |
| 20 | + | |
| 21 | +const encoder = new TextEncoder(); | |
| 22 | + | |
| 23 | +const emptyNetworkInterfaces: DesktopNetworkInterfaces = {}; | |
| 24 | +const lanNetworkInterfaces: DesktopNetworkInterfaces = { | |
| 25 | + en0: [ | |
| 26 | + { | |
| 27 | + address: "192.168.1.20", | |
| 28 | + family: "IPv4", | |
| 29 | + internal: false, | |
| 30 | + }, | |
| 31 | + ], | |
| 32 | +}; | |
| 33 | + | |
| 34 | +const tailnetNetworkInterfaces: DesktopNetworkInterfaces = { | |
| 35 | + tailscale0: [ | |
| 36 | + { | |
| 37 | + address: "100.90.1.2", | |
| 38 | + family: "IPv4", | |
| 39 | + internal: false, | |
| 40 | + }, | |
| 41 | + ], | |
| 42 | +}; | |
| 43 | + | |
| 44 | +function mockSpawnerLayer(statusJson = "{}") { | |
| 45 | + return Layer.succeed( | |
| 46 | + ChildProcessSpawner.ChildProcessSpawner, | |
| 47 | + ChildProcessSpawner.make(() => | |
| 48 | + Effect.succeed( | |
| 49 | + ChildProcessSpawner.makeHandle({ | |
| 50 | + pid: ChildProcessSpawner.ProcessId(1), | |
| 51 | + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), | |
| 52 | + isRunning: Effect.succeed(false), | |
| 53 | + kill: () => Effect.void, | |
| 54 | + unref: Effect.succeed(Effect.void), | |
| 55 | + stdin: Sink.drain, | |
| 56 | + stdout: Stream.make(encoder.encode(statusJson)), | |
| 57 | + stderr: Stream.empty, | |
| 58 | + all: Stream.empty, | |
| 59 | + getInputFd: () => Sink.drain, | |
| 60 | + getOutputFd: () => Stream.empty, | |
| 61 | + }), | |
| 62 | + ), | |
| 63 | + ), | |
| 64 | + ); | |
| 65 | +} | |
| 66 | + | |
| 67 | +function makeEnvironmentLayer(baseDir: string, env: Record<string, string | undefined> = {}) { | |
| 68 | + return makeDesktopEnvironmentLayer({ | |
| 69 | + dirname: "/repo/apps/desktop/src", | |
| 70 | + homeDirectory: baseDir, | |
| 71 | + platform: "darwin", | |
| 72 | + processArch: "x64", | |
| 73 | + appVersion: "1.2.3", | |
| 74 | + appPath: "/repo", | |
| 75 | + isPackaged: true, | |
| 76 | + resourcesPath: "/missing/resources", | |
| 77 | + runningUnderArm64Translation: false, | |
| 78 | + }).pipe( | |
| 79 | + Layer.provide( | |
| 80 | + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir, ...env })), | |
| 81 | + ), | |
| 82 | + ); | |
| 83 | +} | |
| 84 | + | |
| 85 | +function makeLayer(input: { | |
| 86 | + readonly baseDir: string; | |
| 87 | + readonly networkInterfaces?: DesktopNetworkInterfaces; | |
| 88 | + readonly env?: Record<string, string | undefined>; | |
| 89 | +}) { | |
| 90 | + const env = { T3CODE_HOME: input.baseDir, ...input.env }; | |
| 91 | + const environmentLayer = makeEnvironmentLayer(input.baseDir, env); | |
| 92 | + const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, { | |
| 93 | + read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces), | |
| 94 | + }); | |
| 95 | + | |
| 96 | + return DesktopServerExposure.layer.pipe( | |
| 97 | + Layer.provideMerge(DesktopAppSettings.layer), | |
| 98 | + Layer.provideMerge(NodeFileSystem.layer), | |
| 99 | + Layer.provideMerge(NodeHttpClient.layerUndici), | |
| 100 | + Layer.provideMerge(mockSpawnerLayer()), | |
| 101 | + Layer.provideMerge(networkLayer), | |
| 102 | + Layer.provideMerge(DesktopConfig.layerTest(env)), | |
| 103 | + Layer.provideMerge(environmentLayer), | |
| 104 | + ); | |
| 105 | +} | |
| 106 | + | |
| 107 | +const withHarness = <A, E, R>( | |
| 108 | + networkInterfaces: DesktopNetworkInterfaces, | |
| 109 | + effect: Effect.Effect< | |
| 110 | + A, | |
| 111 | + E, | |
| 112 | + | R | |
| 113 | + | DesktopEnvironment | |
| 114 | + | FileSystem.FileSystem | |
| 115 | + | DesktopServerExposure.DesktopServerExposure | |
| 116 | + | DesktopAppSettings.DesktopAppSettings | |
| 117 | + >, | |
| 118 | + env: Record<string, string | undefined> = {}, | |
| 119 | +) => | |
| 120 | + Effect.gen(function* () { | |
| 121 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 122 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 123 | + prefix: "t3-desktop-server-exposure-test-", | |
| 124 | + }); | |
| 125 | + return yield* effect.pipe(Effect.provide(makeLayer({ baseDir, networkInterfaces, env }))); | |
| 126 | + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); | |
| 127 | + | |
| 128 | +describe("DesktopServerExposure", () => { | |
| 129 | + it.effect("falls back to local-only without losing the requested network preference", () => | |
| 130 | + withHarness( | |
| 131 | + emptyNetworkInterfaces, | |
| 132 | + Effect.gen(function* () { | |
| 133 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 134 | + const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 135 | + | |
| 136 | + yield* settings.setServerExposureMode("network-accessible"); | |
| 137 | + | |
| 138 | + const state = yield* serverExposure.configureFromSettings({ port: 4173 }); | |
| 139 | + assert.equal(state.mode, "local-only"); | |
| 140 | + assert.equal(state.endpointUrl, null); | |
| 141 | + assert.equal((yield* settings.get).serverExposureMode, "network-accessible"); | |
| 142 | + | |
| 143 | + const backendConfig = yield* serverExposure.backendConfig; | |
| 144 | + assert.equal(backendConfig.bindHost, "127.0.0.1"); | |
| 145 | + assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); | |
| 146 | + }), | |
| 147 | + ), | |
| 148 | + ); | |
| 149 | + | |
| 150 | + it.effect("returns a typed error when network access is explicitly unavailable", () => | |
| 151 | + withHarness( | |
| 152 | + emptyNetworkInterfaces, | |
| 153 | + Effect.gen(function* () { | |
| 154 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 155 | + yield* serverExposure.configureFromSettings({ port: 4173 }); | |
| 156 | + | |
| 157 | + const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); | |
| 158 | + assert.ok(error._tag === "DesktopServerExposureNoNetworkAddressError"); | |
| 159 | + assert.equal(error.port, 4173); | |
| 160 | + }), | |
| 161 | + ), | |
| 162 | + ); | |
| 163 | + | |
| 164 | + it.effect("persists network-accessible mode and updates backend binding state", () => | |
| 165 | + withHarness( | |
| 166 | + lanNetworkInterfaces, | |
| 167 | + Effect.gen(function* () { | |
| 168 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 169 | + const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 170 | + | |
| 171 | + yield* settings.load; | |
| 172 | + yield* serverExposure.configureFromSettings({ port: 4173 }); | |
| 173 | + | |
| 174 | + const change = yield* serverExposure.setMode("network-accessible"); | |
| 175 | + assert.equal(change.requiresRelaunch, true); | |
| 176 | + assert.deepEqual(change.state, { | |
| 177 | + mode: "network-accessible", | |
| 178 | + endpointUrl: "http://192.168.1.20:4173", | |
| 179 | + advertisedHost: "192.168.1.20", | |
| 180 | + tailscaleServeEnabled: false, | |
| 181 | + tailscaleServePort: 443, | |
| 182 | + }); | |
| 183 | + | |
| 184 | + const backendConfig = yield* serverExposure.backendConfig; | |
| 185 | + assert.equal(backendConfig.bindHost, "0.0.0.0"); | |
| 186 | + assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); | |
| 187 | + | |
| 188 | + const persisted = yield* settings.get; | |
| 189 | + assert.equal(persisted.serverExposureMode, "network-accessible"); | |
| 190 | + }), | |
| 191 | + ), | |
| 192 | + ); | |
| 193 | + | |
| 194 | + it.effect("persists tailscale serve preferences atomically and reports no-op updates", () => | |
| 195 | + withHarness( | |
| 196 | + emptyNetworkInterfaces, | |
| 197 | + Effect.gen(function* () { | |
| 198 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 199 | + const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 200 | + | |
| 201 | + yield* settings.load; | |
| 202 | + yield* serverExposure.configureFromSettings({ port: 4173 }); | |
| 203 | + | |
| 204 | + const changed = yield* serverExposure.setTailscaleServeEnabled({ | |
| 205 | + enabled: true, | |
| 206 | + port: 8443, | |
| 207 | + }); | |
| 208 | + assert.equal(changed.requiresRelaunch, true); | |
| 209 | + assert.equal(changed.state.tailscaleServeEnabled, true); | |
| 210 | + assert.equal(changed.state.tailscaleServePort, 8443); | |
| 211 | + | |
| 212 | + const unchanged = yield* serverExposure.setTailscaleServeEnabled({ | |
| 213 | + enabled: true, | |
| 214 | + port: 8443, | |
| 215 | + }); | |
| 216 | + assert.equal(unchanged.requiresRelaunch, false); | |
| 217 | + | |
| 218 | + const persisted = yield* settings.get; | |
| 219 | + assert.equal(persisted.tailscaleServeEnabled, true); | |
| 220 | + assert.equal(persisted.tailscaleServePort, 8443); | |
| 221 | + }), | |
| 222 | + ), | |
| 223 | + ); | |
| 224 | + | |
| 225 | + it.effect("resolves advertised endpoints from the scoped runtime state", () => | |
| 226 | + withHarness( | |
| 227 | + { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, | |
| 228 | + Effect.gen(function* () { | |
| 229 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 230 | + yield* serverExposure.configureFromSettings({ port: 4173 }); | |
| 231 | + yield* serverExposure.setMode("network-accessible"); | |
| 232 | + | |
| 233 | + const endpoints = yield* serverExposure.getAdvertisedEndpoints; | |
| 234 | + assert.deepEqual( | |
| 235 | + endpoints.map((endpoint) => endpoint.httpBaseUrl), | |
| 236 | + ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/", "http://100.90.1.2:4173/"], | |
| 237 | + ); | |
| 238 | + }), | |
| 239 | + ), | |
| 240 | + ); | |
| 241 | + | |
| 242 | + it.effect("uses ConfigProvider desktop exposure overrides", () => | |
| 243 | + withHarness( | |
| 244 | + lanNetworkInterfaces, | |
| 245 | + Effect.gen(function* () { | |
| 246 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 247 | + yield* serverExposure.configureFromSettings({ port: 4173 }); | |
| 248 | + const change = yield* serverExposure.setMode("network-accessible"); | |
| 249 | + | |
| 250 | + assert.equal(change.state.advertisedHost, "10.0.0.7"); | |
| 251 | + assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173"); | |
| 252 | + | |
| 253 | + const endpoints = yield* serverExposure.getAdvertisedEndpoints; | |
| 254 | + assert.deepEqual( | |
| 255 | + endpoints.map((endpoint) => endpoint.httpBaseUrl), | |
| 256 | + ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"], | |
| 257 | + ); | |
| 258 | + }), | |
| 259 | + { | |
| 260 | + T3CODE_DESKTOP_LAN_HOST: "10.0.0.7", | |
| 261 | + T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test", | |
| 262 | + }, | |
| 263 | + ), | |
| 264 | + ); | |
| 265 | + | |
| 266 | + it.effect("advertises loopback, LAN, and configured manual endpoints from runtime state", () => | |
| 267 | + withHarness( | |
| 268 | + lanNetworkInterfaces, | |
| 269 | + Effect.gen(function* () { | |
| 270 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 271 | + yield* serverExposure.configureFromSettings({ port: 3773 }); | |
| 272 | + yield* serverExposure.setMode("network-accessible"); | |
| 273 | + | |
| 274 | + const endpoints = yield* serverExposure.getAdvertisedEndpoints; | |
| 275 | + assert.deepEqual(endpoints, [ | |
| 276 | + { | |
| 277 | + id: "desktop-loopback:3773", | |
| 278 | + label: "This machine", | |
| 279 | + provider: { | |
| 280 | + id: "desktop-core", | |
| 281 | + label: "Desktop", | |
| 282 | + kind: "core", | |
| 283 | + isAddon: false, | |
| 284 | + }, | |
| 285 | + httpBaseUrl: "http://127.0.0.1:3773/", | |
| 286 | + wsBaseUrl: "ws://127.0.0.1:3773/", | |
| 287 | + reachability: "loopback", | |
| 288 | + compatibility: { | |
| 289 | + hostedHttpsApp: "mixed-content-blocked", | |
| 290 | + desktopApp: "compatible", | |
| 291 | + }, | |
| 292 | + source: "desktop-core", | |
| 293 | + status: "available", | |
| 294 | + description: "Loopback endpoint for this desktop app.", | |
| 295 | + }, | |
| 296 | + { | |
| 297 | + id: "desktop-lan:http://192.168.1.20:3773", | |
| 298 | + label: "Local network", | |
| 299 | + provider: { | |
| 300 | + id: "desktop-core", | |
| 301 | + label: "Desktop", | |
| 302 | + kind: "core", | |
| 303 | + isAddon: false, | |
| 304 | + }, | |
| 305 | + httpBaseUrl: "http://192.168.1.20:3773/", | |
| 306 | + wsBaseUrl: "ws://192.168.1.20:3773/", | |
| 307 | + reachability: "lan", | |
| 308 | + compatibility: { | |
| 309 | + hostedHttpsApp: "mixed-content-blocked", | |
| 310 | + desktopApp: "compatible", | |
| 311 | + }, | |
| 312 | + source: "desktop-core", | |
| 313 | + status: "available", | |
| 314 | + isDefault: true, | |
| 315 | + description: "Reachable from devices on the same network.", | |
| 316 | + }, | |
| 317 | + { | |
| 318 | + id: "manual:https://desktop.example.ts.net", | |
| 319 | + label: "Custom HTTPS", | |
| 320 | + provider: { | |
| 321 | + id: "manual", | |
| 322 | + label: "Manual", | |
| 323 | + kind: "manual", | |
| 324 | + isAddon: false, | |
| 325 | + }, | |
| 326 | + httpBaseUrl: "https://desktop.example.ts.net/", | |
| 327 | + wsBaseUrl: "wss://desktop.example.ts.net/", | |
| 328 | + reachability: "public", | |
| 329 | + compatibility: { | |
| 330 | + hostedHttpsApp: "compatible", | |
| 331 | + desktopApp: "compatible", | |
| 332 | + }, | |
| 333 | + source: "user", | |
| 334 | + status: "unknown", | |
| 335 | + description: "User-configured HTTPS endpoint for this desktop backend.", | |
| 336 | + }, | |
| 337 | + { | |
| 338 | + id: "manual:http://desktop.example.test:3773", | |
| 339 | + label: "Custom endpoint", | |
| 340 | + provider: { | |
| 341 | + id: "manual", | |
| 342 | + label: "Manual", | |
| 343 | + kind: "manual", | |
| 344 | + isAddon: false, | |
| 345 | + }, | |
| 346 | + httpBaseUrl: "http://desktop.example.test:3773/", | |
| 347 | + wsBaseUrl: "ws://desktop.example.test:3773/", | |
| 348 | + reachability: "public", | |
| 349 | + compatibility: { | |
| 350 | + hostedHttpsApp: "mixed-content-blocked", | |
| 351 | + desktopApp: "compatible", | |
| 352 | + }, | |
| 353 | + source: "user", | |
| 354 | + status: "unknown", | |
| 355 | + description: "User-configured endpoint for this desktop backend.", | |
| 356 | + }, | |
| 357 | + ]); | |
| 358 | + }), | |
| 359 | + { | |
| 360 | + T3CODE_DESKTOP_HTTPS_ENDPOINTS: | |
| 361 | + "https://desktop.example.ts.net,http://desktop.example.test:3773,not-a-url", | |
| 362 | + }, | |
| 363 | + ), | |
| 364 | + ); | |
| 365 | +}); | |
| @@ -0,0 +1,548 @@ | ||
| 1 | +import * as NodeOS from "node:os"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + createAdvertisedEndpoint, | |
| 5 | + type CreateAdvertisedEndpointInput, | |
| 6 | +} from "@t3tools/client-runtime"; | |
| 7 | +import type { | |
| 8 | + AdvertisedEndpoint, | |
| 9 | + AdvertisedEndpointProvider, | |
| 10 | + DesktopServerExposureMode, | |
| 11 | + DesktopServerExposureState, | |
| 12 | +} from "@t3tools/contracts"; | |
| 13 | +import * as Context from "effect/Context"; | |
| 14 | +import * as Data from "effect/Data"; | |
| 15 | +import * as Effect from "effect/Effect"; | |
| 16 | +import * as Layer from "effect/Layer"; | |
| 17 | +import * as Option from "effect/Option"; | |
| 18 | +import * as Ref from "effect/Ref"; | |
| 19 | +import { HttpClient } from "effect/unstable/http"; | |
| 20 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 21 | + | |
| 22 | +import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts"; | |
| 23 | +import * as DesktopConfig from "../app/DesktopConfig.ts"; | |
| 24 | +import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; | |
| 25 | +import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts"; | |
| 26 | + | |
| 27 | +export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; | |
| 28 | +const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; | |
| 29 | + | |
| 30 | +export interface DesktopNetworkInterfaceInfo { | |
| 31 | + readonly address: string; | |
| 32 | + readonly family: string | number; | |
| 33 | + readonly internal: boolean; | |
| 34 | + readonly netmask?: string; | |
| 35 | + readonly mac?: string; | |
| 36 | + readonly cidr?: string | null; | |
| 37 | + readonly scopeid?: number; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export type DesktopNetworkInterfaces = Readonly< | |
| 41 | + Record<string, readonly DesktopNetworkInterfaceInfo[] | undefined> | |
| 42 | +>; | |
| 43 | + | |
| 44 | +interface ResolvedDesktopServerExposure { | |
| 45 | + readonly mode: DesktopServerExposureMode; | |
| 46 | + readonly bindHost: string; | |
| 47 | + readonly localHttpUrl: string; | |
| 48 | + readonly localWsUrl: string; | |
| 49 | + readonly endpointUrl: string | null; | |
| 50 | + readonly advertisedHost: string | null; | |
| 51 | +} | |
| 52 | + | |
| 53 | +interface DesktopAdvertisedEndpointInput { | |
| 54 | + readonly port: number; | |
| 55 | + readonly exposure: ResolvedDesktopServerExposure; | |
| 56 | + readonly customHttpsEndpointUrls?: readonly string[]; | |
| 57 | +} | |
| 58 | + | |
| 59 | +const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { | |
| 60 | + id: "desktop-core", | |
| 61 | + label: "Desktop", | |
| 62 | + kind: "core", | |
| 63 | + isAddon: false, | |
| 64 | +}; | |
| 65 | + | |
| 66 | +const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { | |
| 67 | + id: "manual", | |
| 68 | + label: "Manual", | |
| 69 | + kind: "manual", | |
| 70 | + isAddon: false, | |
| 71 | +}; | |
| 72 | + | |
| 73 | +const normalizeOptionalHost = (value: string | undefined): string | undefined => { | |
| 74 | + const normalized = value?.trim(); | |
| 75 | + return normalized && normalized.length > 0 ? normalized : undefined; | |
| 76 | +}; | |
| 77 | + | |
| 78 | +const isUsableLanIpv4Address = (address: string): boolean => | |
| 79 | + !address.startsWith("127.") && !address.startsWith("169.254."); | |
| 80 | + | |
| 81 | +const isHttpsEndpointUrl = (value: string): boolean => { | |
| 82 | + try { | |
| 83 | + return new URL(value).protocol === "https:"; | |
| 84 | + } catch { | |
| 85 | + return false; | |
| 86 | + } | |
| 87 | +}; | |
| 88 | + | |
| 89 | +const resolveLanAdvertisedHost = ( | |
| 90 | + networkInterfaces: DesktopNetworkInterfaces, | |
| 91 | + explicitHost: string | undefined, | |
| 92 | +): string | null => { | |
| 93 | + const normalizedExplicitHost = normalizeOptionalHost(explicitHost); | |
| 94 | + if (normalizedExplicitHost) { | |
| 95 | + return normalizedExplicitHost; | |
| 96 | + } | |
| 97 | + | |
| 98 | + for (const interfaceAddresses of Object.values(networkInterfaces)) { | |
| 99 | + if (!interfaceAddresses) continue; | |
| 100 | + | |
| 101 | + for (const address of interfaceAddresses) { | |
| 102 | + if (address.internal) continue; | |
| 103 | + if (address.family !== "IPv4") continue; | |
| 104 | + if (!isUsableLanIpv4Address(address.address)) continue; | |
| 105 | + return address.address; | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + return null; | |
| 110 | +}; | |
| 111 | + | |
| 112 | +const resolveDesktopServerExposure = (input: { | |
| 113 | + readonly mode: DesktopServerExposureMode; | |
| 114 | + readonly port: number; | |
| 115 | + readonly networkInterfaces: DesktopNetworkInterfaces; | |
| 116 | + readonly advertisedHostOverride?: string; | |
| 117 | +}): ResolvedDesktopServerExposure => { | |
| 118 | + const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; | |
| 119 | + const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; | |
| 120 | + | |
| 121 | + if (input.mode === "local-only") { | |
| 122 | + return { | |
| 123 | + mode: input.mode, | |
| 124 | + bindHost: DESKTOP_LOOPBACK_HOST, | |
| 125 | + localHttpUrl, | |
| 126 | + localWsUrl, | |
| 127 | + endpointUrl: null, | |
| 128 | + advertisedHost: null, | |
| 129 | + }; | |
| 130 | + } | |
| 131 | + | |
| 132 | + const advertisedHost = resolveLanAdvertisedHost( | |
| 133 | + input.networkInterfaces, | |
| 134 | + input.advertisedHostOverride, | |
| 135 | + ); | |
| 136 | + | |
| 137 | + return { | |
| 138 | + mode: input.mode, | |
| 139 | + bindHost: DESKTOP_LAN_BIND_HOST, | |
| 140 | + localHttpUrl, | |
| 141 | + localWsUrl, | |
| 142 | + endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null, | |
| 143 | + advertisedHost, | |
| 144 | + }; | |
| 145 | +}; | |
| 146 | + | |
| 147 | +const createDesktopEndpoint = ( | |
| 148 | + input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">, | |
| 149 | +): AdvertisedEndpoint => | |
| 150 | + createAdvertisedEndpoint({ | |
| 151 | + ...input, | |
| 152 | + provider: DESKTOP_CORE_ENDPOINT_PROVIDER, | |
| 153 | + source: "desktop-core", | |
| 154 | + }); | |
| 155 | + | |
| 156 | +const createManualEndpoint = ( | |
| 157 | + input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">, | |
| 158 | +): AdvertisedEndpoint => | |
| 159 | + createAdvertisedEndpoint({ | |
| 160 | + ...input, | |
| 161 | + provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER, | |
| 162 | + source: "user", | |
| 163 | + }); | |
| 164 | + | |
| 165 | +const resolveDesktopCoreAdvertisedEndpoints = ( | |
| 166 | + input: DesktopAdvertisedEndpointInput, | |
| 167 | +): readonly AdvertisedEndpoint[] => { | |
| 168 | + const endpoints: AdvertisedEndpoint[] = [ | |
| 169 | + createDesktopEndpoint({ | |
| 170 | + id: `desktop-loopback:${input.port}`, | |
| 171 | + label: "This machine", | |
| 172 | + httpBaseUrl: input.exposure.localHttpUrl, | |
| 173 | + reachability: "loopback", | |
| 174 | + status: "available", | |
| 175 | + description: "Loopback endpoint for this desktop app.", | |
| 176 | + }), | |
| 177 | + ]; | |
| 178 | + | |
| 179 | + if (input.exposure.endpointUrl) { | |
| 180 | + endpoints.push( | |
| 181 | + createDesktopEndpoint({ | |
| 182 | + id: `desktop-lan:${input.exposure.endpointUrl}`, | |
| 183 | + label: "Local network", | |
| 184 | + httpBaseUrl: input.exposure.endpointUrl, | |
| 185 | + reachability: "lan", | |
| 186 | + status: "available", | |
| 187 | + isDefault: true, | |
| 188 | + description: "Reachable from devices on the same network.", | |
| 189 | + }), | |
| 190 | + ); | |
| 191 | + } | |
| 192 | + | |
| 193 | + for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) { | |
| 194 | + try { | |
| 195 | + const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl); | |
| 196 | + endpoints.push( | |
| 197 | + createManualEndpoint({ | |
| 198 | + id: `manual:${customEndpointUrl}`, | |
| 199 | + label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint", | |
| 200 | + httpBaseUrl: customEndpointUrl, | |
| 201 | + reachability: "public", | |
| 202 | + ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}), | |
| 203 | + status: "unknown", | |
| 204 | + description: isHttpsEndpoint | |
| 205 | + ? "User-configured HTTPS endpoint for this desktop backend." | |
| 206 | + : "User-configured endpoint for this desktop backend.", | |
| 207 | + }), | |
| 208 | + ); | |
| 209 | + } catch { | |
| 210 | + // Ignore malformed user-configured endpoints without dropping valid endpoints. | |
| 211 | + } | |
| 212 | + } | |
| 213 | + | |
| 214 | + return endpoints; | |
| 215 | +}; | |
| 216 | + | |
| 217 | +type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve"; | |
| 218 | + | |
| 219 | +export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError( | |
| 220 | + "DesktopServerExposureNoNetworkAddressError", | |
| 221 | +)<{ | |
| 222 | + readonly port: number; | |
| 223 | +}> { | |
| 224 | + override get message() { | |
| 225 | + return `No reachable network address is available for desktop network access on port ${this.port}.`; | |
| 226 | + } | |
| 227 | +} | |
| 228 | + | |
| 229 | +export class DesktopServerExposurePersistenceError extends Data.TaggedError( | |
| 230 | + "DesktopServerExposurePersistenceError", | |
| 231 | +)<{ | |
| 232 | + readonly operation: DesktopServerExposurePersistenceOperation; | |
| 233 | + readonly cause: DesktopAppSettingsService.DesktopSettingsWriteError; | |
| 234 | +}> { | |
| 235 | + override get message() { | |
| 236 | + return `Failed to persist desktop ${this.operation} settings.`; | |
| 237 | + } | |
| 238 | +} | |
| 239 | + | |
| 240 | +export type DesktopServerExposureSetModeError = | |
| 241 | + | DesktopServerExposureNoNetworkAddressError | |
| 242 | + | DesktopServerExposurePersistenceError; | |
| 243 | + | |
| 244 | +export type DesktopServerExposureError = DesktopServerExposureSetModeError; | |
| 245 | + | |
| 246 | +export interface DesktopServerExposureBackendConfig { | |
| 247 | + readonly port: number; | |
| 248 | + readonly bindHost: string; | |
| 249 | + readonly httpBaseUrl: URL; | |
| 250 | + readonly tailscaleServeEnabled: boolean; | |
| 251 | + readonly tailscaleServePort: number; | |
| 252 | +} | |
| 253 | + | |
| 254 | +export interface DesktopServerExposureChange { | |
| 255 | + readonly state: DesktopServerExposureState; | |
| 256 | + readonly requiresRelaunch: boolean; | |
| 257 | +} | |
| 258 | + | |
| 259 | +export interface DesktopServerExposureShape { | |
| 260 | + readonly getState: Effect.Effect<DesktopServerExposureState>; | |
| 261 | + readonly backendConfig: Effect.Effect<DesktopServerExposureBackendConfig>; | |
| 262 | + readonly configureFromSettings: (input: { | |
| 263 | + readonly port: number; | |
| 264 | + }) => Effect.Effect<DesktopServerExposureState>; | |
| 265 | + readonly setMode: ( | |
| 266 | + mode: DesktopServerExposureMode, | |
| 267 | + ) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposureSetModeError>; | |
| 268 | + readonly setTailscaleServeEnabled: (input: { | |
| 269 | + readonly enabled: boolean; | |
| 270 | + readonly port?: number; | |
| 271 | + }) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposurePersistenceError>; | |
| 272 | + readonly getAdvertisedEndpoints: Effect.Effect<readonly AdvertisedEndpoint[]>; | |
| 273 | +} | |
| 274 | + | |
| 275 | +export class DesktopServerExposure extends Context.Service< | |
| 276 | + DesktopServerExposure, | |
| 277 | + DesktopServerExposureShape | |
| 278 | +>()("t3/desktop/ServerExposure") {} | |
| 279 | + | |
| 280 | +export interface DesktopNetworkInterfacesServiceShape { | |
| 281 | + readonly read: Effect.Effect<DesktopNetworkInterfaces>; | |
| 282 | +} | |
| 283 | + | |
| 284 | +export class DesktopNetworkInterfacesService extends Context.Service< | |
| 285 | + DesktopNetworkInterfacesService, | |
| 286 | + DesktopNetworkInterfacesServiceShape | |
| 287 | +>()("t3/desktop/ServerExposure/NetworkInterfaces") {} | |
| 288 | + | |
| 289 | +interface RuntimeState { | |
| 290 | + readonly requestedMode: DesktopServerExposureMode; | |
| 291 | + readonly mode: DesktopServerExposureMode; | |
| 292 | + readonly port: number; | |
| 293 | + readonly bindHost: string; | |
| 294 | + readonly localHttpUrl: string; | |
| 295 | + readonly localWsUrl: string; | |
| 296 | + readonly httpBaseUrl: URL; | |
| 297 | + readonly endpointUrl: Option.Option<string>; | |
| 298 | + readonly advertisedHost: Option.Option<string>; | |
| 299 | + readonly tailscaleServeEnabled: boolean; | |
| 300 | + readonly tailscaleServePort: number; | |
| 301 | +} | |
| 302 | + | |
| 303 | +interface ResolvedRuntimeState { | |
| 304 | + readonly state: RuntimeState; | |
| 305 | + readonly unavailable: boolean; | |
| 306 | +} | |
| 307 | + | |
| 308 | +const initialRuntimeState = (): RuntimeState => | |
| 309 | + runtimeStateFromResolvedExposure({ | |
| 310 | + requestedMode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, | |
| 311 | + settings: DEFAULT_DESKTOP_SETTINGS, | |
| 312 | + exposure: resolveDesktopServerExposure({ | |
| 313 | + mode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, | |
| 314 | + port: 0, | |
| 315 | + networkInterfaces: {}, | |
| 316 | + }), | |
| 317 | + port: 0, | |
| 318 | + }); | |
| 319 | + | |
| 320 | +const toContractState = (state: RuntimeState): DesktopServerExposureState => ({ | |
| 321 | + mode: state.mode, | |
| 322 | + endpointUrl: Option.getOrNull(state.endpointUrl), | |
| 323 | + advertisedHost: Option.getOrNull(state.advertisedHost), | |
| 324 | + tailscaleServeEnabled: state.tailscaleServeEnabled, | |
| 325 | + tailscaleServePort: state.tailscaleServePort, | |
| 326 | +}); | |
| 327 | + | |
| 328 | +const toBackendConfig = (state: RuntimeState): DesktopServerExposureBackendConfig => ({ | |
| 329 | + port: state.port, | |
| 330 | + bindHost: state.bindHost, | |
| 331 | + httpBaseUrl: state.httpBaseUrl, | |
| 332 | + tailscaleServeEnabled: state.tailscaleServeEnabled, | |
| 333 | + tailscaleServePort: state.tailscaleServePort, | |
| 334 | +}); | |
| 335 | + | |
| 336 | +const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure => ({ | |
| 337 | + mode: state.mode, | |
| 338 | + bindHost: state.bindHost, | |
| 339 | + localHttpUrl: state.localHttpUrl, | |
| 340 | + localWsUrl: state.localWsUrl, | |
| 341 | + endpointUrl: Option.getOrNull(state.endpointUrl), | |
| 342 | + advertisedHost: Option.getOrNull(state.advertisedHost), | |
| 343 | +}); | |
| 344 | + | |
| 345 | +function runtimeStateFromResolvedExposure(input: { | |
| 346 | + readonly requestedMode: DesktopServerExposureMode; | |
| 347 | + readonly settings: DesktopSettings; | |
| 348 | + readonly exposure: ResolvedDesktopServerExposure; | |
| 349 | + readonly port: number; | |
| 350 | +}): RuntimeState { | |
| 351 | + return { | |
| 352 | + requestedMode: input.requestedMode, | |
| 353 | + mode: input.exposure.mode, | |
| 354 | + port: input.port, | |
| 355 | + bindHost: input.exposure.bindHost, | |
| 356 | + localHttpUrl: input.exposure.localHttpUrl, | |
| 357 | + localWsUrl: input.exposure.localWsUrl, | |
| 358 | + httpBaseUrl: new URL(input.exposure.localHttpUrl), | |
| 359 | + endpointUrl: Option.fromNullishOr(input.exposure.endpointUrl), | |
| 360 | + advertisedHost: Option.fromNullishOr(input.exposure.advertisedHost), | |
| 361 | + tailscaleServeEnabled: input.settings.tailscaleServeEnabled, | |
| 362 | + tailscaleServePort: input.settings.tailscaleServePort, | |
| 363 | + }; | |
| 364 | +} | |
| 365 | + | |
| 366 | +function resolveRuntimeState(input: { | |
| 367 | + readonly requestedMode: DesktopServerExposureMode; | |
| 368 | + readonly settings: DesktopSettings; | |
| 369 | + readonly port: number; | |
| 370 | + readonly networkInterfaces: DesktopNetworkInterfaces; | |
| 371 | + readonly advertisedHostOverride: Option.Option<string>; | |
| 372 | +}): ResolvedRuntimeState { | |
| 373 | + const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride); | |
| 374 | + const requestedExposure = resolveDesktopServerExposure({ | |
| 375 | + mode: input.requestedMode, | |
| 376 | + port: input.port, | |
| 377 | + networkInterfaces: input.networkInterfaces, | |
| 378 | + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), | |
| 379 | + }); | |
| 380 | + const unavailable = | |
| 381 | + input.requestedMode === "network-accessible" && requestedExposure.endpointUrl === null; | |
| 382 | + const exposure = unavailable | |
| 383 | + ? resolveDesktopServerExposure({ | |
| 384 | + mode: "local-only", | |
| 385 | + port: input.port, | |
| 386 | + networkInterfaces: input.networkInterfaces, | |
| 387 | + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), | |
| 388 | + }) | |
| 389 | + : requestedExposure; | |
| 390 | + | |
| 391 | + return { | |
| 392 | + state: runtimeStateFromResolvedExposure({ | |
| 393 | + requestedMode: input.requestedMode, | |
| 394 | + settings: input.settings, | |
| 395 | + exposure, | |
| 396 | + port: input.port, | |
| 397 | + }), | |
| 398 | + unavailable, | |
| 399 | + }; | |
| 400 | +} | |
| 401 | + | |
| 402 | +const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): boolean => | |
| 403 | + previous.port !== next.port || | |
| 404 | + previous.bindHost !== next.bindHost || | |
| 405 | + previous.localHttpUrl !== next.localHttpUrl; | |
| 406 | + | |
| 407 | +const make = Effect.gen(function* () { | |
| 408 | + const config = yield* DesktopConfig.DesktopConfig; | |
| 409 | + const networkInterfaces = yield* DesktopNetworkInterfacesService; | |
| 410 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 411 | + const httpClient = yield* HttpClient.HttpClient; | |
| 412 | + const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings; | |
| 413 | + const stateRef = yield* Ref.make(initialRuntimeState()); | |
| 414 | + | |
| 415 | + const readNetworkInterfaces = networkInterfaces.read; | |
| 416 | + | |
| 417 | + const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); | |
| 418 | + const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig)); | |
| 419 | + | |
| 420 | + const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")( | |
| 421 | + function* ({ port }: { readonly port: number }) { | |
| 422 | + yield* Effect.annotateCurrentSpan({ port }); | |
| 423 | + const settings = yield* desktopSettings.get; | |
| 424 | + const currentNetworkInterfaces = yield* readNetworkInterfaces; | |
| 425 | + const resolved = resolveRuntimeState({ | |
| 426 | + requestedMode: settings.serverExposureMode, | |
| 427 | + settings, | |
| 428 | + port, | |
| 429 | + networkInterfaces: currentNetworkInterfaces, | |
| 430 | + advertisedHostOverride: config.desktopLanHostOverride, | |
| 431 | + }); | |
| 432 | + yield* Ref.set(stateRef, resolved.state); | |
| 433 | + return toContractState(resolved.state); | |
| 434 | + }, | |
| 435 | + ); | |
| 436 | + | |
| 437 | + const setMode = Effect.fn("desktop.serverExposure.setMode")(function* ( | |
| 438 | + mode: DesktopServerExposureMode, | |
| 439 | + ) { | |
| 440 | + yield* Effect.annotateCurrentSpan({ mode }); | |
| 441 | + const previous = yield* Ref.get(stateRef); | |
| 442 | + const currentSettings = yield* desktopSettings.get; | |
| 443 | + const nextSettings = { | |
| 444 | + ...currentSettings, | |
| 445 | + serverExposureMode: mode, | |
| 446 | + }; | |
| 447 | + const currentNetworkInterfaces = yield* readNetworkInterfaces; | |
| 448 | + const resolved = resolveRuntimeState({ | |
| 449 | + requestedMode: mode, | |
| 450 | + settings: nextSettings, | |
| 451 | + port: previous.port, | |
| 452 | + networkInterfaces: currentNetworkInterfaces, | |
| 453 | + advertisedHostOverride: config.desktopLanHostOverride, | |
| 454 | + }); | |
| 455 | + | |
| 456 | + if (resolved.unavailable) { | |
| 457 | + return yield* new DesktopServerExposureNoNetworkAddressError({ port: previous.port }); | |
| 458 | + } | |
| 459 | + | |
| 460 | + const change = yield* desktopSettings.setServerExposureMode(mode).pipe( | |
| 461 | + Effect.mapError( | |
| 462 | + (cause) => | |
| 463 | + new DesktopServerExposurePersistenceError({ | |
| 464 | + operation: "server-exposure-mode", | |
| 465 | + cause, | |
| 466 | + }), | |
| 467 | + ), | |
| 468 | + ); | |
| 469 | + | |
| 470 | + yield* Ref.set(stateRef, resolved.state); | |
| 471 | + return { | |
| 472 | + state: toContractState(resolved.state), | |
| 473 | + requiresRelaunch: change.changed || requiresBackendRelaunch(previous, resolved.state), | |
| 474 | + }; | |
| 475 | + }); | |
| 476 | + | |
| 477 | + const setTailscaleServeEnabled = Effect.fn("desktop.serverExposure.setTailscaleServeEnabled")( | |
| 478 | + function* (input: { readonly enabled: boolean; readonly port?: number }) { | |
| 479 | + yield* Effect.annotateCurrentSpan({ | |
| 480 | + enabled: input.enabled, | |
| 481 | + ...(input.port === undefined ? {} : { port: input.port }), | |
| 482 | + }); | |
| 483 | + const result = yield* desktopSettings | |
| 484 | + .setTailscaleServe({ | |
| 485 | + enabled: input.enabled, | |
| 486 | + port: Option.fromNullishOr(input.port), | |
| 487 | + }) | |
| 488 | + .pipe( | |
| 489 | + Effect.mapError( | |
| 490 | + (cause) => | |
| 491 | + new DesktopServerExposurePersistenceError({ | |
| 492 | + operation: "tailscale-serve", | |
| 493 | + cause, | |
| 494 | + }), | |
| 495 | + ), | |
| 496 | + ); | |
| 497 | + | |
| 498 | + const nextState = yield* Ref.updateAndGet(stateRef, (current) => ({ | |
| 499 | + ...current, | |
| 500 | + tailscaleServeEnabled: result.settings.tailscaleServeEnabled, | |
| 501 | + tailscaleServePort: result.settings.tailscaleServePort, | |
| 502 | + })); | |
| 503 | + | |
| 504 | + return { | |
| 505 | + state: toContractState(nextState), | |
| 506 | + requiresRelaunch: result.changed, | |
| 507 | + }; | |
| 508 | + }, | |
| 509 | + ); | |
| 510 | + | |
| 511 | + const getAdvertisedEndpoints = Effect.gen(function* () { | |
| 512 | + const state = yield* Ref.get(stateRef); | |
| 513 | + const currentNetworkInterfaces = yield* readNetworkInterfaces; | |
| 514 | + const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ | |
| 515 | + port: state.port, | |
| 516 | + exposure: toResolvedExposure(state), | |
| 517 | + customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, | |
| 518 | + }); | |
| 519 | + const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ | |
| 520 | + port: state.port, | |
| 521 | + serveEnabled: state.tailscaleServeEnabled, | |
| 522 | + servePort: state.tailscaleServePort, | |
| 523 | + networkInterfaces: currentNetworkInterfaces, | |
| 524 | + }).pipe( | |
| 525 | + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), | |
| 526 | + Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 527 | + ); | |
| 528 | + return [...coreEndpoints, ...tailscaleEndpoints]; | |
| 529 | + }).pipe(Effect.withSpan("desktop.serverExposure.getAdvertisedEndpoints")); | |
| 530 | + | |
| 531 | + return DesktopServerExposure.of({ | |
| 532 | + getState, | |
| 533 | + backendConfig, | |
| 534 | + configureFromSettings, | |
| 535 | + setMode, | |
| 536 | + setTailscaleServeEnabled, | |
| 537 | + getAdvertisedEndpoints, | |
| 538 | + }); | |
| 539 | +}); | |
| 540 | + | |
| 541 | +export const layer = Layer.effect(DesktopServerExposure, make); | |
| 542 | + | |
| 543 | +export const networkInterfacesLayer = Layer.succeed( | |
| 544 | + DesktopNetworkInterfacesService, | |
| 545 | + DesktopNetworkInterfacesService.of({ | |
| 546 | + read: Effect.sync(() => NodeOS.networkInterfaces()), | |
| 547 | + }), | |
| 548 | +); | |
| @@ -0,0 +1,142 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Layer from "effect/Layer"; | |
| 4 | +import { HttpClient } from "effect/unstable/http"; | |
| 5 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 6 | + | |
| 7 | +import { | |
| 8 | + isTailscaleIpv4Address, | |
| 9 | + parseTailscaleMagicDnsName, | |
| 10 | + resolveTailscaleAdvertisedEndpoints, | |
| 11 | +} from "./tailscaleEndpointProvider.ts"; | |
| 12 | + | |
| 13 | +const unusedTailscaleExternalServicesLayer = Layer.mergeAll( | |
| 14 | + Layer.succeed( | |
| 15 | + HttpClient.HttpClient, | |
| 16 | + HttpClient.make(() => Effect.die("unexpected Tailscale HTTPS probe")), | |
| 17 | + ), | |
| 18 | + Layer.succeed( | |
| 19 | + ChildProcessSpawner.ChildProcessSpawner, | |
| 20 | + ChildProcessSpawner.make(() => Effect.die("unexpected tailscale status process")), | |
| 21 | + ), | |
| 22 | +); | |
| 23 | + | |
| 24 | +describe("tailscale endpoint provider", () => { | |
| 25 | + it("detects Tailnet IPv4 addresses", () => { | |
| 26 | + assert.equal(isTailscaleIpv4Address("100.64.0.1"), true); | |
| 27 | + assert.equal(isTailscaleIpv4Address("100.127.255.254"), true); | |
| 28 | + assert.equal(isTailscaleIpv4Address("100.128.0.1"), false); | |
| 29 | + assert.equal(isTailscaleIpv4Address("192.168.1.44"), false); | |
| 30 | + }); | |
| 31 | + | |
| 32 | + it.effect("parses MagicDNS names from tailscale status", () => | |
| 33 | + Effect.gen(function* () { | |
| 34 | + const dnsName = yield* parseTailscaleMagicDnsName( | |
| 35 | + `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, | |
| 36 | + ); | |
| 37 | + assert.equal(dnsName, "desktop.tail.ts.net"); | |
| 38 | + assert.equal(yield* parseTailscaleMagicDnsName("{}"), null); | |
| 39 | + const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json")); | |
| 40 | + assert.isTrue(malformed._tag === "Failure"); | |
| 41 | + }), | |
| 42 | + ); | |
| 43 | + | |
| 44 | + it.effect("resolves Tailscale endpoints as add-on advertised endpoints", () => | |
| 45 | + Effect.gen(function* () { | |
| 46 | + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ | |
| 47 | + port: 3773, | |
| 48 | + networkInterfaces: { | |
| 49 | + tailscale0: [ | |
| 50 | + { | |
| 51 | + address: "100.100.100.100", | |
| 52 | + family: "IPv4", | |
| 53 | + internal: false, | |
| 54 | + netmask: "255.192.0.0", | |
| 55 | + cidr: "100.100.100.100/10", | |
| 56 | + mac: "00:00:00:00:00:00", | |
| 57 | + }, | |
| 58 | + ], | |
| 59 | + }, | |
| 60 | + statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, | |
| 61 | + }); | |
| 62 | + assert.deepEqual(endpoints, [ | |
| 63 | + { | |
| 64 | + id: "tailscale-ip:http://100.100.100.100:3773", | |
| 65 | + label: "Tailscale IP", | |
| 66 | + provider: { | |
| 67 | + id: "tailscale", | |
| 68 | + label: "Tailscale", | |
| 69 | + kind: "private-network", | |
| 70 | + isAddon: true, | |
| 71 | + }, | |
| 72 | + httpBaseUrl: "http://100.100.100.100:3773/", | |
| 73 | + wsBaseUrl: "ws://100.100.100.100:3773/", | |
| 74 | + reachability: "private-network", | |
| 75 | + compatibility: { | |
| 76 | + hostedHttpsApp: "mixed-content-blocked", | |
| 77 | + desktopApp: "compatible", | |
| 78 | + }, | |
| 79 | + source: "desktop-addon", | |
| 80 | + status: "available", | |
| 81 | + description: "Reachable from devices on the same Tailnet.", | |
| 82 | + }, | |
| 83 | + { | |
| 84 | + id: "tailscale-magicdns:https://desktop.tail.ts.net/", | |
| 85 | + label: "Tailscale HTTPS", | |
| 86 | + provider: { | |
| 87 | + id: "tailscale", | |
| 88 | + label: "Tailscale", | |
| 89 | + kind: "private-network", | |
| 90 | + isAddon: true, | |
| 91 | + }, | |
| 92 | + httpBaseUrl: "https://desktop.tail.ts.net/", | |
| 93 | + wsBaseUrl: "wss://desktop.tail.ts.net/", | |
| 94 | + reachability: "private-network", | |
| 95 | + compatibility: { | |
| 96 | + hostedHttpsApp: "requires-configuration", | |
| 97 | + desktopApp: "compatible", | |
| 98 | + }, | |
| 99 | + source: "desktop-addon", | |
| 100 | + status: "unavailable", | |
| 101 | + description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", | |
| 102 | + }, | |
| 103 | + ]); | |
| 104 | + }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), | |
| 105 | + ); | |
| 106 | + | |
| 107 | + it.effect( | |
| 108 | + "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", | |
| 109 | + () => | |
| 110 | + Effect.gen(function* () { | |
| 111 | + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ | |
| 112 | + port: 3773, | |
| 113 | + networkInterfaces: {}, | |
| 114 | + statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, | |
| 115 | + serveEnabled: true, | |
| 116 | + probe: () => Effect.succeed(true), | |
| 117 | + }); | |
| 118 | + assert.deepEqual(endpoints, [ | |
| 119 | + { | |
| 120 | + id: "tailscale-magicdns:https://desktop.tail.ts.net/", | |
| 121 | + label: "Tailscale HTTPS", | |
| 122 | + provider: { | |
| 123 | + id: "tailscale", | |
| 124 | + label: "Tailscale", | |
| 125 | + kind: "private-network", | |
| 126 | + isAddon: true, | |
| 127 | + }, | |
| 128 | + httpBaseUrl: "https://desktop.tail.ts.net/", | |
| 129 | + wsBaseUrl: "wss://desktop.tail.ts.net/", | |
| 130 | + reachability: "private-network", | |
| 131 | + compatibility: { | |
| 132 | + hostedHttpsApp: "compatible", | |
| 133 | + desktopApp: "compatible", | |
| 134 | + }, | |
| 135 | + source: "desktop-addon", | |
| 136 | + status: "available", | |
| 137 | + description: "HTTPS endpoint served by Tailscale Serve.", | |
| 138 | + }, | |
| 139 | + ]); | |
| 140 | + }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), | |
| 141 | + ); | |
| 142 | +}); | |
| @@ -0,0 +1,138 @@ | ||
| 1 | +import { createAdvertisedEndpoint } from "@t3tools/client-runtime"; | |
| 2 | +import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; | |
| 3 | +import { | |
| 4 | + buildTailscaleHttpsBaseUrl, | |
| 5 | + isTailscaleIpv4Address, | |
| 6 | + parseTailscaleMagicDnsName, | |
| 7 | + probeTailscaleHttpsEndpoint, | |
| 8 | + readTailscaleStatus, | |
| 9 | +} from "@t3tools/tailscale"; | |
| 10 | +import * as Effect from "effect/Effect"; | |
| 11 | +import * as Option from "effect/Option"; | |
| 12 | +import { HttpClient } from "effect/unstable/http"; | |
| 13 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 14 | + | |
| 15 | +import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; | |
| 16 | + | |
| 17 | +export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; | |
| 18 | + | |
| 19 | +const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { | |
| 20 | + id: "tailscale", | |
| 21 | + label: "Tailscale", | |
| 22 | + kind: "private-network", | |
| 23 | + isAddon: true, | |
| 24 | +}; | |
| 25 | + | |
| 26 | +function resolveTailscaleIpAdvertisedEndpoints(input: { | |
| 27 | + readonly port: number; | |
| 28 | + readonly networkInterfaces: DesktopNetworkInterfaces; | |
| 29 | +}): readonly AdvertisedEndpoint[] { | |
| 30 | + const seen = new Set<string>(); | |
| 31 | + const endpoints: AdvertisedEndpoint[] = []; | |
| 32 | + | |
| 33 | + for (const interfaceAddresses of Object.values(input.networkInterfaces)) { | |
| 34 | + if (!interfaceAddresses) continue; | |
| 35 | + | |
| 36 | + for (const address of interfaceAddresses) { | |
| 37 | + if (address.internal) continue; | |
| 38 | + if (address.family !== "IPv4") continue; | |
| 39 | + if (!isTailscaleIpv4Address(address.address)) continue; | |
| 40 | + if (seen.has(address.address)) continue; | |
| 41 | + seen.add(address.address); | |
| 42 | + | |
| 43 | + endpoints.push( | |
| 44 | + createAdvertisedEndpoint({ | |
| 45 | + provider: TAILSCALE_ENDPOINT_PROVIDER, | |
| 46 | + source: "desktop-addon", | |
| 47 | + id: `tailscale-ip:http://${address.address}:${input.port}`, | |
| 48 | + label: "Tailscale IP", | |
| 49 | + httpBaseUrl: `http://${address.address}:${input.port}`, | |
| 50 | + reachability: "private-network", | |
| 51 | + status: "available", | |
| 52 | + description: "Reachable from devices on the same Tailnet.", | |
| 53 | + }), | |
| 54 | + ); | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + return endpoints; | |
| 59 | +} | |
| 60 | + | |
| 61 | +const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( | |
| 62 | + "resolveTailscaleMagicDnsAdvertisedEndpoint", | |
| 63 | +)(function* (input: { | |
| 64 | + readonly dnsName: string | null; | |
| 65 | + readonly serveEnabled: boolean; | |
| 66 | + readonly servePort?: number; | |
| 67 | + readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>; | |
| 68 | +}): Effect.fn.Return<Option.Option<AdvertisedEndpoint>, never, HttpClient.HttpClient> { | |
| 69 | + if (!input.dnsName) { | |
| 70 | + return Option.none(); | |
| 71 | + } | |
| 72 | + | |
| 73 | + const httpBaseUrl = buildTailscaleHttpsBaseUrl({ | |
| 74 | + magicDnsName: input.dnsName, | |
| 75 | + ...(input.servePort === undefined ? {} : { servePort: input.servePort }), | |
| 76 | + }); | |
| 77 | + const probe = | |
| 78 | + input.probe?.(httpBaseUrl) ?? | |
| 79 | + probeTailscaleHttpsEndpoint({ | |
| 80 | + baseUrl: httpBaseUrl, | |
| 81 | + }); | |
| 82 | + const isReachable = input.serveEnabled ? yield* probe : false; | |
| 83 | + | |
| 84 | + return Option.some( | |
| 85 | + createAdvertisedEndpoint({ | |
| 86 | + provider: TAILSCALE_ENDPOINT_PROVIDER, | |
| 87 | + source: "desktop-addon", | |
| 88 | + id: `tailscale-magicdns:${httpBaseUrl}`, | |
| 89 | + label: "Tailscale HTTPS", | |
| 90 | + httpBaseUrl, | |
| 91 | + reachability: "private-network", | |
| 92 | + hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration", | |
| 93 | + status: isReachable ? "available" : "unavailable", | |
| 94 | + description: isReachable | |
| 95 | + ? "HTTPS endpoint served by Tailscale Serve." | |
| 96 | + : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", | |
| 97 | + }), | |
| 98 | + ); | |
| 99 | +}); | |
| 100 | + | |
| 101 | +export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAdvertisedEndpoints")( | |
| 102 | + function* (input: { | |
| 103 | + readonly port: number; | |
| 104 | + readonly serveEnabled?: boolean; | |
| 105 | + readonly servePort?: number; | |
| 106 | + readonly networkInterfaces: DesktopNetworkInterfaces; | |
| 107 | + readonly statusJson?: string | null; | |
| 108 | + readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>; | |
| 109 | + }): Effect.fn.Return< | |
| 110 | + readonly AdvertisedEndpoint[], | |
| 111 | + never, | |
| 112 | + ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient | |
| 113 | + > { | |
| 114 | + const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); | |
| 115 | + const dnsName = | |
| 116 | + input.statusJson === undefined | |
| 117 | + ? yield* readTailscaleStatus.pipe( | |
| 118 | + Effect.map((status) => status.magicDnsName), | |
| 119 | + Effect.catch(() => Effect.succeed(null)), | |
| 120 | + ) | |
| 121 | + : input.statusJson | |
| 122 | + ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe( | |
| 123 | + Effect.catch(() => Effect.succeed(null)), | |
| 124 | + ) | |
| 125 | + : null; | |
| 126 | + const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({ | |
| 127 | + dnsName, | |
| 128 | + serveEnabled: input.serveEnabled === true, | |
| 129 | + ...(input.servePort === undefined ? {} : { servePort: input.servePort }), | |
| 130 | + ...(input.probe === undefined ? {} : { probe: input.probe }), | |
| 131 | + }); | |
| 132 | + | |
| 133 | + return Option.match(magicDnsEndpoint, { | |
| 134 | + onNone: () => ipEndpoints, | |
| 135 | + onSome: (endpoint) => [...ipEndpoints, endpoint], | |
| 136 | + }); | |
| 137 | + }, | |
| 138 | +); | |
| @@ -1,109 +0,0 @@ | ||
| 1 | −import { describe, expect, it, vi } from "vitest"; | |
| 2 | − | |
| 3 | −import { resolveDesktopBackendPort } from "./backendPort.ts"; | |
| 4 | − | |
| 5 | −describe("resolveDesktopBackendPort", () => { | |
| 6 | − it("returns the starting port when it is available", async () => { | |
| 7 | − const canListenOnHost = vi.fn(async (port: number) => port === 3773); | |
| 8 | − | |
| 9 | − await expect( | |
| 10 | − resolveDesktopBackendPort({ | |
| 11 | − host: "127.0.0.1", | |
| 12 | − startPort: 3773, | |
| 13 | − canListenOnHost, | |
| 14 | − }), | |
| 15 | − ).resolves.toBe(3773); | |
| 16 | − | |
| 17 | − expect(canListenOnHost).toHaveBeenCalledTimes(1); | |
| 18 | − expect(canListenOnHost).toHaveBeenCalledWith(3773, "127.0.0.1"); | |
| 19 | − }); | |
| 20 | − | |
| 21 | − it("increments sequentially until it finds an available port", async () => { | |
| 22 | − const canListenOnHost = vi.fn(async (port: number) => port === 3775); | |
| 23 | − | |
| 24 | − await expect( | |
| 25 | − resolveDesktopBackendPort({ | |
| 26 | − host: "127.0.0.1", | |
| 27 | − startPort: 3773, | |
| 28 | − canListenOnHost, | |
| 29 | − }), | |
| 30 | − ).resolves.toBe(3775); | |
| 31 | − | |
| 32 | − expect(canListenOnHost.mock.calls).toEqual([ | |
| 33 | − [3773, "127.0.0.1"], | |
| 34 | − [3774, "127.0.0.1"], | |
| 35 | − [3775, "127.0.0.1"], | |
| 36 | − ]); | |
| 37 | − }); | |
| 38 | − | |
| 39 | − it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => { | |
| 40 | − const canListenOnHost = vi.fn(async (port: number, host: string) => { | |
| 41 | − if (port === 3773 && host === "127.0.0.1") return true; | |
| 42 | − if (port === 3773 && host === "0.0.0.0") return false; | |
| 43 | − return port === 3774; | |
| 44 | − }); | |
| 45 | − | |
| 46 | − await expect( | |
| 47 | − resolveDesktopBackendPort({ | |
| 48 | − host: "127.0.0.1", | |
| 49 | − requiredHosts: ["0.0.0.0"], | |
| 50 | − startPort: 3773, | |
| 51 | − canListenOnHost, | |
| 52 | − }), | |
| 53 | − ).resolves.toBe(3774); | |
| 54 | − | |
| 55 | − expect(canListenOnHost.mock.calls).toEqual([ | |
| 56 | − [3773, "127.0.0.1"], | |
| 57 | − [3773, "0.0.0.0"], | |
| 58 | − [3774, "127.0.0.1"], | |
| 59 | − [3774, "0.0.0.0"], | |
| 60 | − ]); | |
| 61 | − }); | |
| 62 | − | |
| 63 | − it("checks overlapping hosts sequentially to avoid self-interference", async () => { | |
| 64 | − let inFlightCount = 0; | |
| 65 | − const canListenOnHost = vi.fn(async (_port: number, _host: string) => { | |
| 66 | − inFlightCount += 1; | |
| 67 | − const overlapped = inFlightCount > 1; | |
| 68 | − await Promise.resolve(); | |
| 69 | − inFlightCount -= 1; | |
| 70 | − return !overlapped; | |
| 71 | − }); | |
| 72 | − | |
| 73 | − await expect( | |
| 74 | − resolveDesktopBackendPort({ | |
| 75 | − host: "127.0.0.1", | |
| 76 | − requiredHosts: ["0.0.0.0", "::"], | |
| 77 | − startPort: 3773, | |
| 78 | − maxPort: 3773, | |
| 79 | − canListenOnHost, | |
| 80 | − }), | |
| 81 | − ).resolves.toBe(3773); | |
| 82 | − | |
| 83 | − expect(canListenOnHost.mock.calls).toEqual([ | |
| 84 | − [3773, "127.0.0.1"], | |
| 85 | − [3773, "0.0.0.0"], | |
| 86 | − [3773, "::"], | |
| 87 | − ]); | |
| 88 | − }); | |
| 89 | − | |
| 90 | − it("fails when the scan range is exhausted", async () => { | |
| 91 | − const canListenOnHost = vi.fn(async () => false); | |
| 92 | − | |
| 93 | − await expect( | |
| 94 | − resolveDesktopBackendPort({ | |
| 95 | − host: "127.0.0.1", | |
| 96 | − startPort: 65534, | |
| 97 | − maxPort: 65535, | |
| 98 | − canListenOnHost, | |
| 99 | − }), | |
| 100 | − ).rejects.toThrow( | |
| 101 | − "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535", | |
| 102 | − ); | |
| 103 | − | |
| 104 | − expect(canListenOnHost.mock.calls).toEqual([ | |
| 105 | − [65534, "127.0.0.1"], | |
| 106 | − [65535, "127.0.0.1"], | |
| 107 | − ]); | |
| 108 | − }); | |
| 109 | −}); | |
| @@ -1,83 +0,0 @@ | ||
| 1 | −import * as Effect from "effect/Effect"; | |
| 2 | −import { NetService } from "@t3tools/shared/Net"; | |
| 3 | − | |
| 4 | −export const DEFAULT_DESKTOP_BACKEND_PORT = 3773; | |
| 5 | −const MAX_TCP_PORT = 65_535; | |
| 6 | − | |
| 7 | −export interface ResolveDesktopBackendPortOptions { | |
| 8 | − readonly host: string; | |
| 9 | − readonly startPort?: number; | |
| 10 | − readonly maxPort?: number; | |
| 11 | − readonly requiredHosts?: ReadonlyArray<string>; | |
| 12 | − readonly canListenOnHost?: (port: number, host: string) => Promise<boolean>; | |
| 13 | −} | |
| 14 | − | |
| 15 | −const defaultCanListenOnHost = async (port: number, host: string): Promise<boolean> => | |
| 16 | − Effect.service(NetService).pipe( | |
| 17 | − Effect.flatMap((net) => net.canListenOnHost(port, host)), | |
| 18 | − Effect.provide(NetService.layer), | |
| 19 | − Effect.runPromise, | |
| 20 | − ); | |
| 21 | − | |
| 22 | −const isValidPort = (port: number): boolean => | |
| 23 | − Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT; | |
| 24 | − | |
| 25 | −const normalizeHosts = ( | |
| 26 | − host: string, | |
| 27 | − requiredHosts: ReadonlyArray<string>, | |
| 28 | −): ReadonlyArray<string> => | |
| 29 | − Array.from( | |
| 30 | − new Set( | |
| 31 | − [host, ...requiredHosts] | |
| 32 | − .map((candidate) => candidate.trim()) | |
| 33 | − .filter((candidate) => candidate.length > 0), | |
| 34 | − ), | |
| 35 | − ); | |
| 36 | − | |
| 37 | −async function canListenOnAllHosts( | |
| 38 | − port: number, | |
| 39 | − hosts: ReadonlyArray<string>, | |
| 40 | − canListenOnHost: (port: number, host: string) => Promise<boolean>, | |
| 41 | −): Promise<boolean> { | |
| 42 | − for (const candidateHost of hosts) { | |
| 43 | − if (!(await canListenOnHost(port, candidateHost))) { | |
| 44 | − return false; | |
| 45 | − } | |
| 46 | − } | |
| 47 | − | |
| 48 | − return true; | |
| 49 | −} | |
| 50 | − | |
| 51 | −export async function resolveDesktopBackendPort({ | |
| 52 | − host, | |
| 53 | − startPort = DEFAULT_DESKTOP_BACKEND_PORT, | |
| 54 | − maxPort = MAX_TCP_PORT, | |
| 55 | − requiredHosts = [], | |
| 56 | − canListenOnHost = defaultCanListenOnHost, | |
| 57 | −}: ResolveDesktopBackendPortOptions): Promise<number> { | |
| 58 | − if (!isValidPort(startPort)) { | |
| 59 | − throw new Error(`Invalid desktop backend start port: ${startPort}`); | |
| 60 | − } | |
| 61 | − | |
| 62 | − if (!isValidPort(maxPort)) { | |
| 63 | − throw new Error(`Invalid desktop backend max port: ${maxPort}`); | |
| 64 | − } | |
| 65 | − | |
| 66 | − if (maxPort < startPort) { | |
| 67 | − throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`); | |
| 68 | − } | |
| 69 | − | |
| 70 | − const hostsToCheck = normalizeHosts(host, requiredHosts); | |
| 71 | − | |
| 72 | − // Keep desktop startup predictable across app restarts by probing upward from | |
| 73 | − // the same preferred port instead of picking a fresh ephemeral port. | |
| 74 | − for (let port = startPort; port <= maxPort; port += 1) { | |
| 75 | − if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) { | |
| 76 | − return port; | |
| 77 | − } | |
| 78 | − } | |
| 79 | − | |
| 80 | − throw new Error( | |
| 81 | − `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`, | |
| 82 | − ); | |
| 83 | −} | |
| @@ -1,114 +0,0 @@ | ||
| 1 | −import { describe, expect, it, vi } from "vitest"; | |
| 2 | − | |
| 3 | −import { | |
| 4 | − BackendReadinessAbortedError, | |
| 5 | − isBackendReadinessAborted, | |
| 6 | − waitForHttpReady, | |
| 7 | −} from "./backendReadiness.ts"; | |
| 8 | − | |
| 9 | −describe("waitForHttpReady", () => { | |
| 10 | − it("returns once the backend serves the requested readiness path", async () => { | |
| 11 | − const fetchImpl = vi | |
| 12 | − .fn<typeof fetch>() | |
| 13 | − .mockResolvedValueOnce(new Response(null, { status: 503 })) | |
| 14 | − .mockResolvedValueOnce(new Response(null, { status: 200 })); | |
| 15 | − | |
| 16 | − await waitForHttpReady("http://127.0.0.1:3773", { | |
| 17 | − fetchImpl, | |
| 18 | − timeoutMs: 1_000, | |
| 19 | − intervalMs: 0, | |
| 20 | − }); | |
| 21 | − | |
| 22 | − expect(fetchImpl).toHaveBeenCalledTimes(2); | |
| 23 | − expect(fetchImpl).toHaveBeenNthCalledWith( | |
| 24 | − 1, | |
| 25 | − "http://127.0.0.1:3773/", | |
| 26 | − expect.objectContaining({ redirect: "manual" }), | |
| 27 | − ); | |
| 28 | − }); | |
| 29 | − | |
| 30 | − it("retries after a readiness request stalls past the per-request timeout", async () => { | |
| 31 | − const fetchImpl = vi | |
| 32 | − .fn<typeof fetch>() | |
| 33 | − .mockImplementationOnce( | |
| 34 | − (_input, init) => | |
| 35 | − new Promise((_resolve, reject) => { | |
| 36 | − init?.signal?.addEventListener( | |
| 37 | − "abort", | |
| 38 | − () => { | |
| 39 | − reject(new Error("request timed out")); | |
| 40 | − }, | |
| 41 | − { once: true }, | |
| 42 | − ); | |
| 43 | − }) as ReturnType<typeof fetch>, | |
| 44 | − ) | |
| 45 | − .mockResolvedValueOnce(new Response(null, { status: 200 })); | |
| 46 | − | |
| 47 | − await waitForHttpReady("http://127.0.0.1:3773", { | |
| 48 | − fetchImpl, | |
| 49 | − timeoutMs: 100, | |
| 50 | − intervalMs: 0, | |
| 51 | − requestTimeoutMs: 1, | |
| 52 | − }); | |
| 53 | − | |
| 54 | − expect(fetchImpl).toHaveBeenCalledTimes(2); | |
| 55 | − }); | |
| 56 | − | |
| 57 | − it("aborts an in-flight readiness wait", async () => { | |
| 58 | − const controller = new AbortController(); | |
| 59 | − const fetchImpl = vi.fn<typeof fetch>().mockImplementation( | |
| 60 | − () => | |
| 61 | − new Promise((_resolve, reject) => { | |
| 62 | − controller.signal.addEventListener( | |
| 63 | − "abort", | |
| 64 | − () => { | |
| 65 | − reject(new BackendReadinessAbortedError()); | |
| 66 | − }, | |
| 67 | − { once: true }, | |
| 68 | − ); | |
| 69 | − }) as ReturnType<typeof fetch>, | |
| 70 | − ); | |
| 71 | − | |
| 72 | − const waitPromise = waitForHttpReady("http://127.0.0.1:3773", { | |
| 73 | − fetchImpl, | |
| 74 | − timeoutMs: 1_000, | |
| 75 | − intervalMs: 0, | |
| 76 | − signal: controller.signal, | |
| 77 | − }); | |
| 78 | − | |
| 79 | − controller.abort(); | |
| 80 | − | |
| 81 | − await expect(waitPromise).rejects.toBeInstanceOf(BackendReadinessAbortedError); | |
| 82 | − }); | |
| 83 | − | |
| 84 | − it("recognizes aborted readiness errors", () => { | |
| 85 | − expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true); | |
| 86 | − expect(isBackendReadinessAborted(new Error("nope"))).toBe(false); | |
| 87 | − }); | |
| 88 | − | |
| 89 | − it("supports custom readiness predicates", async () => { | |
| 90 | − const fetchImpl = vi | |
| 91 | − .fn<typeof fetch>() | |
| 92 | − .mockResolvedValueOnce(new Response(null, { status: 200 })) | |
| 93 | − .mockResolvedValueOnce(new Response(null, { status: 204 })); | |
| 94 | − | |
| 95 | − await waitForHttpReady("http://127.0.0.1:3773", { | |
| 96 | − fetchImpl, | |
| 97 | − timeoutMs: 1_000, | |
| 98 | − intervalMs: 0, | |
| 99 | − path: "/api/healthz", | |
| 100 | − isReady: (response) => response.status === 204, | |
| 101 | − }); | |
| 102 | − | |
| 103 | − expect(fetchImpl).toHaveBeenNthCalledWith( | |
| 104 | − 1, | |
| 105 | − "http://127.0.0.1:3773/api/healthz", | |
| 106 | − expect.objectContaining({ redirect: "manual" }), | |
| 107 | − ); | |
| 108 | − expect(fetchImpl).toHaveBeenNthCalledWith( | |
| 109 | − 2, | |
| 110 | − "http://127.0.0.1:3773/api/healthz", | |
| 111 | − expect.objectContaining({ redirect: "manual" }), | |
| 112 | − ); | |
| 113 | − }); | |
| 114 | −}); | |
| @@ -1,107 +0,0 @@ | ||
| 1 | −export interface WaitForHttpReadyOptions { | |
| 2 | − readonly timeoutMs?: number; | |
| 3 | − readonly intervalMs?: number; | |
| 4 | − readonly requestTimeoutMs?: number; | |
| 5 | − readonly fetchImpl?: typeof fetch; | |
| 6 | − readonly signal?: AbortSignal; | |
| 7 | − readonly path?: string; | |
| 8 | − readonly isReady?: (response: Response) => boolean; | |
| 9 | −} | |
| 10 | − | |
| 11 | −const DEFAULT_TIMEOUT_MS = 30_000; | |
| 12 | −const DEFAULT_INTERVAL_MS = 100; | |
| 13 | −const DEFAULT_REQUEST_TIMEOUT_MS = 1_000; | |
| 14 | − | |
| 15 | −export class BackendReadinessAbortedError extends Error { | |
| 16 | − constructor() { | |
| 17 | − super("Backend readiness wait was aborted."); | |
| 18 | − this.name = "BackendReadinessAbortedError"; | |
| 19 | − } | |
| 20 | −} | |
| 21 | − | |
| 22 | −function delay(ms: number, signal: AbortSignal | undefined): Promise<void> { | |
| 23 | − return new Promise<void>((resolve, reject) => { | |
| 24 | − const timer = setTimeout(() => { | |
| 25 | − cleanup(); | |
| 26 | − resolve(); | |
| 27 | − }, ms); | |
| 28 | − | |
| 29 | − const onAbort = () => { | |
| 30 | − cleanup(); | |
| 31 | − reject(new BackendReadinessAbortedError()); | |
| 32 | − }; | |
| 33 | − | |
| 34 | − const cleanup = () => { | |
| 35 | − clearTimeout(timer); | |
| 36 | − signal?.removeEventListener("abort", onAbort); | |
| 37 | − }; | |
| 38 | − | |
| 39 | − if (signal?.aborted) { | |
| 40 | − cleanup(); | |
| 41 | − reject(new BackendReadinessAbortedError()); | |
| 42 | − return; | |
| 43 | − } | |
| 44 | − | |
| 45 | − signal?.addEventListener("abort", onAbort, { once: true }); | |
| 46 | − }); | |
| 47 | −} | |
| 48 | − | |
| 49 | −export function isBackendReadinessAborted(error: unknown): error is BackendReadinessAbortedError { | |
| 50 | − return error instanceof BackendReadinessAbortedError; | |
| 51 | −} | |
| 52 | − | |
| 53 | −export async function waitForHttpReady( | |
| 54 | − baseUrl: string, | |
| 55 | − options?: WaitForHttpReadyOptions, | |
| 56 | −): Promise<void> { | |
| 57 | − const fetchImpl = options?.fetchImpl ?? fetch; | |
| 58 | − const signal = options?.signal; | |
| 59 | − const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; | |
| 60 | − const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS; | |
| 61 | − const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; | |
| 62 | − const readinessPath = options?.path ?? "/"; | |
| 63 | − const isReady = options?.isReady ?? ((response: Response) => response.ok); | |
| 64 | − const deadline = Date.now() + timeoutMs; | |
| 65 | − | |
| 66 | − for (;;) { | |
| 67 | − if (signal?.aborted) { | |
| 68 | − throw new BackendReadinessAbortedError(); | |
| 69 | − } | |
| 70 | − | |
| 71 | − const requestController = new AbortController(); | |
| 72 | − const requestTimeout = setTimeout(() => { | |
| 73 | − requestController.abort(); | |
| 74 | − }, requestTimeoutMs); | |
| 75 | − const abortRequest = () => { | |
| 76 | − requestController.abort(); | |
| 77 | − }; | |
| 78 | − signal?.addEventListener("abort", abortRequest, { once: true }); | |
| 79 | − | |
| 80 | − try { | |
| 81 | − const response = await fetchImpl(new URL(readinessPath, baseUrl).toString(), { | |
| 82 | − redirect: "manual", | |
| 83 | − signal: requestController.signal, | |
| 84 | − }); | |
| 85 | − if (isReady(response)) { | |
| 86 | − return; | |
| 87 | − } | |
| 88 | − } catch (error) { | |
| 89 | − if (isBackendReadinessAborted(error)) { | |
| 90 | − throw error; | |
| 91 | − } | |
| 92 | − if (signal?.aborted) { | |
| 93 | − throw new BackendReadinessAbortedError(); | |
| 94 | − } | |
| 95 | − // Retry until the backend becomes reachable or the deadline expires. | |
| 96 | − } finally { | |
| 97 | − clearTimeout(requestTimeout); | |
| 98 | − signal?.removeEventListener("abort", abortRequest); | |
| 99 | − } | |
| 100 | − | |
| 101 | − if (Date.now() >= deadline) { | |
| 102 | − throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`); | |
| 103 | − } | |
| 104 | − | |
| 105 | − await delay(intervalMs, signal); | |
| 106 | − } | |
| 107 | −} | |
| @@ -1,58 +0,0 @@ | ||
| 1 | −import { describe, expect, it, vi } from "vitest"; | |
| 2 | − | |
| 3 | −import { BackendReadinessAbortedError } from "./backendReadiness.ts"; | |
| 4 | −import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; | |
| 5 | − | |
| 6 | −describe("waitForBackendStartupReady", () => { | |
| 7 | − it("falls back to the HTTP probe when no listening signal exists", async () => { | |
| 8 | − const waitForHttpReady = vi.fn<() => Promise<void>>().mockResolvedValue(undefined); | |
| 9 | − const cancelHttpWait = vi.fn(); | |
| 10 | − | |
| 11 | − await expect( | |
| 12 | − waitForBackendStartupReady({ | |
| 13 | − waitForHttpReady, | |
| 14 | − cancelHttpWait, | |
| 15 | − }), | |
| 16 | − ).resolves.toBe("http"); | |
| 17 | − | |
| 18 | − expect(waitForHttpReady).toHaveBeenCalledTimes(1); | |
| 19 | − expect(cancelHttpWait).not.toHaveBeenCalled(); | |
| 20 | − }); | |
| 21 | − | |
| 22 | − it("uses the listening signal and cancels the HTTP probe", async () => { | |
| 23 | − let rejectHttpWait: ((error: unknown) => void) | null = null; | |
| 24 | − const waitForHttpReady = vi.fn( | |
| 25 | − () => | |
| 26 | − new Promise<void>((_resolve, reject) => { | |
| 27 | − rejectHttpWait = reject; | |
| 28 | − }), | |
| 29 | − ); | |
| 30 | − const cancelHttpWait = vi.fn(() => { | |
| 31 | − rejectHttpWait?.(new BackendReadinessAbortedError()); | |
| 32 | − }); | |
| 33 | − | |
| 34 | − await expect( | |
| 35 | − waitForBackendStartupReady({ | |
| 36 | − listeningPromise: Promise.resolve(), | |
| 37 | − waitForHttpReady, | |
| 38 | − cancelHttpWait, | |
| 39 | − }), | |
| 40 | − ).resolves.toBe("listening"); | |
| 41 | − | |
| 42 | − expect(waitForHttpReady).toHaveBeenCalledTimes(1); | |
| 43 | − expect(cancelHttpWait).toHaveBeenCalledTimes(1); | |
| 44 | − }); | |
| 45 | − | |
| 46 | − it("rejects when the listening signal fails before HTTP readiness", async () => { | |
| 47 | − const error = new Error("backend exited"); | |
| 48 | − const waitForHttpReady = vi.fn(() => new Promise<void>(() => {})); | |
| 49 | − | |
| 50 | − await expect( | |
| 51 | − waitForBackendStartupReady({ | |
| 52 | − listeningPromise: Promise.reject(error), | |
| 53 | − waitForHttpReady, | |
| 54 | − cancelHttpWait: vi.fn(), | |
| 55 | − }), | |
| 56 | − ).rejects.toBe(error); | |
| 57 | − }); | |
| 58 | −}); | |
| @@ -1,56 +0,0 @@ | ||
| 1 | −import { isBackendReadinessAborted } from "./backendReadiness.ts"; | |
| 2 | − | |
| 3 | −export interface WaitForBackendStartupReadyOptions { | |
| 4 | − readonly listeningPromise?: Promise<void> | null; | |
| 5 | − readonly waitForHttpReady: () => Promise<void>; | |
| 6 | − readonly cancelHttpWait: () => void; | |
| 7 | −} | |
| 8 | − | |
| 9 | −export async function waitForBackendStartupReady( | |
| 10 | − options: WaitForBackendStartupReadyOptions, | |
| 11 | −): Promise<"listening" | "http"> { | |
| 12 | − const httpReadyPromise = options.waitForHttpReady(); | |
| 13 | − const listeningPromise = options.listeningPromise; | |
| 14 | − | |
| 15 | − if (!listeningPromise) { | |
| 16 | − await httpReadyPromise; | |
| 17 | − return "http"; | |
| 18 | − } | |
| 19 | − | |
| 20 | − return await new Promise<"listening" | "http">((resolve, reject) => { | |
| 21 | − let settled = false; | |
| 22 | − | |
| 23 | − const settleResolve = (source: "listening" | "http") => { | |
| 24 | − if (settled) { | |
| 25 | − return; | |
| 26 | − } | |
| 27 | − settled = true; | |
| 28 | − if (source === "listening") { | |
| 29 | − options.cancelHttpWait(); | |
| 30 | − } | |
| 31 | − resolve(source); | |
| 32 | − }; | |
| 33 | − | |
| 34 | − const settleReject = (error: unknown) => { | |
| 35 | − if (settled) { | |
| 36 | − return; | |
| 37 | − } | |
| 38 | − settled = true; | |
| 39 | − reject(error); | |
| 40 | − }; | |
| 41 | − | |
| 42 | − listeningPromise.then( | |
| 43 | − () => settleResolve("listening"), | |
| 44 | − (error) => settleReject(error), | |
| 45 | − ); | |
| 46 | − httpReadyPromise.then( | |
| 47 | − () => settleResolve("http"), | |
| 48 | − (error) => { | |
| 49 | − if (settled && isBackendReadinessAborted(error)) { | |
| 50 | − return; | |
| 51 | − } | |
| 52 | − settleReject(error); | |
| 53 | − }, | |
| 54 | − ); | |
| 55 | − }); | |
| 56 | −} | |
| @@ -1,249 +0,0 @@ | ||
| 1 | −import * as fs from "node:fs"; | |
| 2 | −import * as os from "node:os"; | |
| 3 | −import * as path from "node:path"; | |
| 4 | − | |
| 5 | −import { | |
| 6 | − EnvironmentId, | |
| 7 | − type ClientSettings, | |
| 8 | − type PersistedSavedEnvironmentRecord, | |
| 9 | −} from "@t3tools/contracts"; | |
| 10 | −import { afterEach, describe, expect, it } from "vitest"; | |
| 11 | − | |
| 12 | −import { | |
| 13 | − readClientSettings, | |
| 14 | − readSavedEnvironmentRegistry, | |
| 15 | − readSavedEnvironmentSecret, | |
| 16 | − removeSavedEnvironmentSecret, | |
| 17 | − writeClientSettings, | |
| 18 | − writeSavedEnvironmentRegistry, | |
| 19 | − writeSavedEnvironmentSecret, | |
| 20 | − type DesktopSecretStorage, | |
| 21 | −} from "./clientPersistence.ts"; | |
| 22 | − | |
| 23 | −const tempDirectories: string[] = []; | |
| 24 | − | |
| 25 | −afterEach(() => { | |
| 26 | − for (const directory of tempDirectories.splice(0)) { | |
| 27 | − fs.rmSync(directory, { recursive: true, force: true }); | |
| 28 | − } | |
| 29 | −}); | |
| 30 | − | |
| 31 | −function makeTempPath(fileName: string): string { | |
| 32 | − const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-client-persistence-test-")); | |
| 33 | − tempDirectories.push(directory); | |
| 34 | − return path.join(directory, fileName); | |
| 35 | −} | |
| 36 | − | |
| 37 | −function makeSecretStorage(available: boolean): DesktopSecretStorage { | |
| 38 | − return { | |
| 39 | − isEncryptionAvailable: () => available, | |
| 40 | − encryptString: (value) => Buffer.from(`enc:${value}`, "utf8"), | |
| 41 | − decryptString: (value) => { | |
| 42 | − const decoded = value.toString("utf8"); | |
| 43 | − if (!decoded.startsWith("enc:")) { | |
| 44 | − throw new Error("invalid secret"); | |
| 45 | − } | |
| 46 | − return decoded.slice("enc:".length); | |
| 47 | − }, | |
| 48 | − }; | |
| 49 | −} | |
| 50 | − | |
| 51 | −const clientSettings: ClientSettings = { | |
| 52 | − autoOpenPlanSidebar: false, | |
| 53 | − confirmThreadArchive: true, | |
| 54 | − confirmThreadDelete: false, | |
| 55 | − dismissedProviderUpdateNotificationKeys: [], | |
| 56 | − diffIgnoreWhitespace: true, | |
| 57 | − diffWordWrap: true, | |
| 58 | − favorites: [], | |
| 59 | − providerModelPreferences: {}, | |
| 60 | − sidebarProjectGroupingMode: "repository_path", | |
| 61 | − sidebarProjectGroupingOverrides: { | |
| 62 | − "environment-1:/tmp/project-a": "separate", | |
| 63 | − }, | |
| 64 | − sidebarProjectSortOrder: "manual", | |
| 65 | − sidebarThreadSortOrder: "created_at", | |
| 66 | − timestampFormat: "24-hour", | |
| 67 | −}; | |
| 68 | − | |
| 69 | −const savedRegistryRecord: PersistedSavedEnvironmentRecord = { | |
| 70 | − environmentId: EnvironmentId.make("environment-1"), | |
| 71 | − label: "Remote environment", | |
| 72 | − httpBaseUrl: "https://remote.example.com/", | |
| 73 | − wsBaseUrl: "wss://remote.example.com/", | |
| 74 | − createdAt: "2026-04-09T00:00:00.000Z", | |
| 75 | − lastConnectedAt: "2026-04-09T01:00:00.000Z", | |
| 76 | − desktopSsh: { | |
| 77 | − alias: "devbox", | |
| 78 | − hostname: "devbox.example.com", | |
| 79 | − username: "julius", | |
| 80 | − port: 22, | |
| 81 | − }, | |
| 82 | −}; | |
| 83 | − | |
| 84 | −describe("clientPersistence", () => { | |
| 85 | − it("persists and reloads client settings", () => { | |
| 86 | − const settingsPath = makeTempPath("client-settings.json"); | |
| 87 | − | |
| 88 | − writeClientSettings(settingsPath, clientSettings); | |
| 89 | − | |
| 90 | − expect(readClientSettings(settingsPath)).toEqual(clientSettings); | |
| 91 | − }); | |
| 92 | − | |
| 93 | − it("persists and reloads saved environment metadata", () => { | |
| 94 | − const registryPath = makeTempPath("saved-environments.json"); | |
| 95 | − | |
| 96 | − writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 97 | − | |
| 98 | − expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); | |
| 99 | − }); | |
| 100 | − | |
| 101 | − it("persists encrypted saved environment secrets when encryption is available", () => { | |
| 102 | − const registryPath = makeTempPath("saved-environments.json"); | |
| 103 | − const secretStorage = makeSecretStorage(true); | |
| 104 | − | |
| 105 | − writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 106 | − | |
| 107 | − expect( | |
| 108 | − writeSavedEnvironmentSecret({ | |
| 109 | − registryPath, | |
| 110 | − environmentId: savedRegistryRecord.environmentId, | |
| 111 | − secret: "bearer-token", | |
| 112 | − secretStorage, | |
| 113 | − }), | |
| 114 | − ).toBe(true); | |
| 115 | − | |
| 116 | − expect( | |
| 117 | − readSavedEnvironmentSecret({ | |
| 118 | − registryPath, | |
| 119 | − environmentId: savedRegistryRecord.environmentId, | |
| 120 | − secretStorage, | |
| 121 | − }), | |
| 122 | − ).toBe("bearer-token"); | |
| 123 | − | |
| 124 | − expect(JSON.parse(fs.readFileSync(registryPath, "utf8"))).toEqual({ | |
| 125 | − records: [ | |
| 126 | − { | |
| 127 | − ...savedRegistryRecord, | |
| 128 | − encryptedBearerToken: Buffer.from("enc:bearer-token", "utf8").toString("base64"), | |
| 129 | − }, | |
| 130 | − ], | |
| 131 | − }); | |
| 132 | − }); | |
| 133 | − | |
| 134 | − it("preserves existing secrets when encryption is unavailable", () => { | |
| 135 | − const registryPath = makeTempPath("saved-environments.json"); | |
| 136 | − const availableSecretStorage = makeSecretStorage(true); | |
| 137 | − | |
| 138 | − writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 139 | − | |
| 140 | − writeSavedEnvironmentSecret({ | |
| 141 | − registryPath, | |
| 142 | − environmentId: savedRegistryRecord.environmentId, | |
| 143 | − secret: "bearer-token", | |
| 144 | − secretStorage: availableSecretStorage, | |
| 145 | − }); | |
| 146 | − | |
| 147 | − expect( | |
| 148 | − writeSavedEnvironmentSecret({ | |
| 149 | − registryPath, | |
| 150 | − environmentId: savedRegistryRecord.environmentId, | |
| 151 | − secret: "next-token", | |
| 152 | − secretStorage: makeSecretStorage(false), | |
| 153 | − }), | |
| 154 | − ).toBe(false); | |
| 155 | − | |
| 156 | − expect( | |
| 157 | − readSavedEnvironmentSecret({ | |
| 158 | − registryPath, | |
| 159 | − environmentId: savedRegistryRecord.environmentId, | |
| 160 | − secretStorage: availableSecretStorage, | |
| 161 | − }), | |
| 162 | − ).toBe("bearer-token"); | |
| 163 | − }); | |
| 164 | − | |
| 165 | − it("removes saved environment secrets", () => { | |
| 166 | − const registryPath = makeTempPath("saved-environments.json"); | |
| 167 | − const secretStorage = makeSecretStorage(true); | |
| 168 | − | |
| 169 | − writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 170 | − | |
| 171 | − writeSavedEnvironmentSecret({ | |
| 172 | − registryPath, | |
| 173 | − environmentId: savedRegistryRecord.environmentId, | |
| 174 | − secret: "bearer-token", | |
| 175 | − secretStorage, | |
| 176 | − }); | |
| 177 | − | |
| 178 | − removeSavedEnvironmentSecret({ | |
| 179 | − registryPath, | |
| 180 | − environmentId: savedRegistryRecord.environmentId, | |
| 181 | − }); | |
| 182 | − | |
| 183 | − expect( | |
| 184 | − readSavedEnvironmentSecret({ | |
| 185 | − registryPath, | |
| 186 | − environmentId: savedRegistryRecord.environmentId, | |
| 187 | − secretStorage, | |
| 188 | − }), | |
| 189 | − ).toBeNull(); | |
| 190 | − }); | |
| 191 | − | |
| 192 | − it("treats malformed secrets documents as empty", () => { | |
| 193 | − const registryPath = makeTempPath("saved-environments.json"); | |
| 194 | − fs.writeFileSync(registryPath, "{}\n", "utf8"); | |
| 195 | − | |
| 196 | − expect( | |
| 197 | − readSavedEnvironmentSecret({ | |
| 198 | − registryPath, | |
| 199 | − environmentId: savedRegistryRecord.environmentId, | |
| 200 | − secretStorage: makeSecretStorage(true), | |
| 201 | − }), | |
| 202 | − ).toBeNull(); | |
| 203 | − | |
| 204 | − expect(() => | |
| 205 | − removeSavedEnvironmentSecret({ | |
| 206 | − registryPath, | |
| 207 | − environmentId: savedRegistryRecord.environmentId, | |
| 208 | − }), | |
| 209 | − ).not.toThrow(); | |
| 210 | − }); | |
| 211 | − | |
| 212 | − it("returns false when writing a secret without metadata", () => { | |
| 213 | − const registryPath = makeTempPath("saved-environments.json"); | |
| 214 | − | |
| 215 | − expect( | |
| 216 | − writeSavedEnvironmentSecret({ | |
| 217 | − registryPath, | |
| 218 | − environmentId: savedRegistryRecord.environmentId, | |
| 219 | − secret: "bearer-token", | |
| 220 | − secretStorage: makeSecretStorage(true), | |
| 221 | − }), | |
| 222 | − ).toBe(false); | |
| 223 | − }); | |
| 224 | − | |
| 225 | − it("preserves encrypted secrets when metadata is rewritten", () => { | |
| 226 | − const registryPath = makeTempPath("saved-environments.json"); | |
| 227 | − const secretStorage = makeSecretStorage(true); | |
| 228 | − | |
| 229 | − writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 230 | − | |
| 231 | − writeSavedEnvironmentSecret({ | |
| 232 | − registryPath, | |
| 233 | − environmentId: savedRegistryRecord.environmentId, | |
| 234 | − secret: "bearer-token", | |
| 235 | − secretStorage, | |
| 236 | − }); | |
| 237 | − | |
| 238 | − writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 239 | − | |
| 240 | − expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); | |
| 241 | − expect( | |
| 242 | − readSavedEnvironmentSecret({ | |
| 243 | − registryPath, | |
| 244 | − environmentId: savedRegistryRecord.environmentId, | |
| 245 | − secretStorage, | |
| 246 | − }), | |
| 247 | − ).toBe("bearer-token"); | |
| 248 | − }); | |
| 249 | −}); | |
| @@ -1,238 +0,0 @@ | ||
| 1 | −import * as FS from "node:fs"; | |
| 2 | −import * as Path from "node:path"; | |
| 3 | − | |
| 4 | −import { | |
| 5 | − ClientSettingsSchema, | |
| 6 | − type ClientSettings, | |
| 7 | − type PersistedSavedEnvironmentRecord, | |
| 8 | −} from "@t3tools/contracts"; | |
| 9 | −import { Predicate } from "effect"; | |
| 10 | −import * as Schema from "effect/Schema"; | |
| 11 | − | |
| 12 | −interface ClientSettingsDocument { | |
| 13 | − readonly settings: ClientSettings; | |
| 14 | −} | |
| 15 | − | |
| 16 | −interface PersistedSavedEnvironmentStorageRecord extends PersistedSavedEnvironmentRecord { | |
| 17 | − readonly encryptedBearerToken?: string; | |
| 18 | −} | |
| 19 | − | |
| 20 | −interface SavedEnvironmentRegistryDocument { | |
| 21 | − readonly records: readonly PersistedSavedEnvironmentStorageRecord[]; | |
| 22 | −} | |
| 23 | − | |
| 24 | −export interface DesktopSecretStorage { | |
| 25 | − readonly isEncryptionAvailable: () => boolean; | |
| 26 | − readonly encryptString: (value: string) => Buffer; | |
| 27 | − readonly decryptString: (value: Buffer) => string; | |
| 28 | −} | |
| 29 | − | |
| 30 | −function readJsonFile<T>(filePath: string): T | null { | |
| 31 | − try { | |
| 32 | − if (!FS.existsSync(filePath)) { | |
| 33 | − return null; | |
| 34 | − } | |
| 35 | − return JSON.parse(FS.readFileSync(filePath, "utf8")) as T; | |
| 36 | − } catch { | |
| 37 | − return null; | |
| 38 | − } | |
| 39 | −} | |
| 40 | − | |
| 41 | −function writeJsonFile(filePath: string, value: unknown): void { | |
| 42 | − const directory = Path.dirname(filePath); | |
| 43 | − const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; | |
| 44 | − FS.mkdirSync(directory, { recursive: true }); | |
| 45 | − FS.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); | |
| 46 | − FS.renameSync(tempPath, filePath); | |
| 47 | −} | |
| 48 | − | |
| 49 | −function isPersistedSavedEnvironmentStorageRecord( | |
| 50 | − value: unknown, | |
| 51 | −): value is PersistedSavedEnvironmentStorageRecord { | |
| 52 | − return ( | |
| 53 | − Predicate.isObject(value) && | |
| 54 | − typeof value.environmentId === "string" && | |
| 55 | − typeof value.label === "string" && | |
| 56 | − typeof value.httpBaseUrl === "string" && | |
| 57 | − typeof value.wsBaseUrl === "string" && | |
| 58 | − typeof value.createdAt === "string" && | |
| 59 | − (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") && | |
| 60 | − (value.desktopSsh === undefined || | |
| 61 | − (Predicate.isObject(value.desktopSsh) && | |
| 62 | − typeof value.desktopSsh.alias === "string" && | |
| 63 | − typeof value.desktopSsh.hostname === "string" && | |
| 64 | − (value.desktopSsh.username === null || typeof value.desktopSsh.username === "string") && | |
| 65 | − (value.desktopSsh.port === null || typeof value.desktopSsh.port === "number"))) && | |
| 66 | − (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string") | |
| 67 | − ); | |
| 68 | −} | |
| 69 | − | |
| 70 | −function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmentRegistryDocument { | |
| 71 | − const parsed = readJsonFile<SavedEnvironmentRegistryDocument>(filePath); | |
| 72 | − if (!Predicate.isObject(parsed)) { | |
| 73 | − return { records: [] }; | |
| 74 | − } | |
| 75 | − | |
| 76 | − return { | |
| 77 | − records: Array.isArray(parsed.records) | |
| 78 | − ? parsed.records.filter(isPersistedSavedEnvironmentStorageRecord) | |
| 79 | − : [], | |
| 80 | − }; | |
| 81 | −} | |
| 82 | − | |
| 83 | −function toPersistedSavedEnvironmentRecord( | |
| 84 | − record: PersistedSavedEnvironmentStorageRecord, | |
| 85 | −): PersistedSavedEnvironmentRecord { | |
| 86 | − const nextRecord = { | |
| 87 | − environmentId: record.environmentId, | |
| 88 | − label: record.label, | |
| 89 | − httpBaseUrl: record.httpBaseUrl, | |
| 90 | − wsBaseUrl: record.wsBaseUrl, | |
| 91 | − createdAt: record.createdAt, | |
| 92 | − lastConnectedAt: record.lastConnectedAt, | |
| 93 | − }; | |
| 94 | − return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; | |
| 95 | −} | |
| 96 | − | |
| 97 | −export function readClientSettings(settingsPath: string): ClientSettings | null { | |
| 98 | − const raw = readJsonFile<ClientSettingsDocument>(settingsPath)?.settings; | |
| 99 | − if (!raw) { | |
| 100 | − return null; | |
| 101 | − } | |
| 102 | − try { | |
| 103 | − return Schema.decodeUnknownSync(ClientSettingsSchema)(raw); | |
| 104 | − } catch { | |
| 105 | − return null; | |
| 106 | − } | |
| 107 | −} | |
| 108 | − | |
| 109 | −export function writeClientSettings(settingsPath: string, settings: ClientSettings): void { | |
| 110 | − writeJsonFile(settingsPath, { settings } satisfies ClientSettingsDocument); | |
| 111 | −} | |
| 112 | − | |
| 113 | −export function readSavedEnvironmentRegistry( | |
| 114 | − registryPath: string, | |
| 115 | −): readonly PersistedSavedEnvironmentRecord[] { | |
| 116 | − return readSavedEnvironmentRegistryDocument(registryPath).records.map((record) => | |
| 117 | − toPersistedSavedEnvironmentRecord(record), | |
| 118 | − ); | |
| 119 | −} | |
| 120 | − | |
| 121 | −export function writeSavedEnvironmentRegistry( | |
| 122 | − registryPath: string, | |
| 123 | − records: readonly PersistedSavedEnvironmentRecord[], | |
| 124 | −): void { | |
| 125 | − const currentDocument = readSavedEnvironmentRegistryDocument(registryPath); | |
| 126 | − const encryptedBearerTokenById = new Map( | |
| 127 | − currentDocument.records.flatMap((record) => | |
| 128 | − record.encryptedBearerToken | |
| 129 | − ? [[record.environmentId, record.encryptedBearerToken] as const] | |
| 130 | − : [], | |
| 131 | − ), | |
| 132 | − ); | |
| 133 | − writeJsonFile(registryPath, { | |
| 134 | − records: records.map((record) => { | |
| 135 | − const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId); | |
| 136 | − return encryptedBearerToken | |
| 137 | − ? { | |
| 138 | − environmentId: record.environmentId, | |
| 139 | − label: record.label, | |
| 140 | − httpBaseUrl: record.httpBaseUrl, | |
| 141 | − wsBaseUrl: record.wsBaseUrl, | |
| 142 | − createdAt: record.createdAt, | |
| 143 | − lastConnectedAt: record.lastConnectedAt, | |
| 144 | − ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | |
| 145 | − encryptedBearerToken, | |
| 146 | − } | |
| 147 | − : record; | |
| 148 | − }), | |
| 149 | − } satisfies SavedEnvironmentRegistryDocument); | |
| 150 | −} | |
| 151 | − | |
| 152 | −export function readSavedEnvironmentSecret(input: { | |
| 153 | − readonly registryPath: string; | |
| 154 | − readonly environmentId: string; | |
| 155 | − readonly secretStorage: DesktopSecretStorage; | |
| 156 | −}): string | null { | |
| 157 | − const document = readSavedEnvironmentRegistryDocument(input.registryPath); | |
| 158 | − const encoded = document.records.find( | |
| 159 | − (record) => record.environmentId === input.environmentId, | |
| 160 | − )?.encryptedBearerToken; | |
| 161 | − if (!encoded) { | |
| 162 | − return null; | |
| 163 | − } | |
| 164 | − | |
| 165 | − if (!input.secretStorage.isEncryptionAvailable()) { | |
| 166 | − return null; | |
| 167 | − } | |
| 168 | − | |
| 169 | − try { | |
| 170 | − return input.secretStorage.decryptString(Buffer.from(encoded, "base64")); | |
| 171 | − } catch { | |
| 172 | − return null; | |
| 173 | − } | |
| 174 | −} | |
| 175 | − | |
| 176 | −export function writeSavedEnvironmentSecret(input: { | |
| 177 | − readonly registryPath: string; | |
| 178 | − readonly environmentId: string; | |
| 179 | − readonly secret: string; | |
| 180 | − readonly secretStorage: DesktopSecretStorage; | |
| 181 | −}): boolean { | |
| 182 | − const document = readSavedEnvironmentRegistryDocument(input.registryPath); | |
| 183 | − | |
| 184 | − if (!input.secretStorage.isEncryptionAvailable()) { | |
| 185 | − return false; | |
| 186 | − } | |
| 187 | − | |
| 188 | − let found = false; | |
| 189 | − | |
| 190 | − writeJsonFile(input.registryPath, { | |
| 191 | − records: document.records.map((record) => { | |
| 192 | − if (record.environmentId !== input.environmentId) { | |
| 193 | − return record; | |
| 194 | − } | |
| 195 | − | |
| 196 | − found = true; | |
| 197 | − const encryptedBearerToken = input.secretStorage | |
| 198 | − .encryptString(input.secret) | |
| 199 | − .toString("base64"); | |
| 200 | − const nextRecord = { | |
| 201 | − environmentId: record.environmentId, | |
| 202 | − label: record.label, | |
| 203 | − httpBaseUrl: record.httpBaseUrl, | |
| 204 | − wsBaseUrl: record.wsBaseUrl, | |
| 205 | − createdAt: record.createdAt, | |
| 206 | − lastConnectedAt: record.lastConnectedAt, | |
| 207 | − encryptedBearerToken, | |
| 208 | − }; | |
| 209 | − return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; | |
| 210 | − }), | |
| 211 | − } satisfies SavedEnvironmentRegistryDocument); | |
| 212 | − return found; | |
| 213 | −} | |
| 214 | − | |
| 215 | −export function removeSavedEnvironmentSecret(input: { | |
| 216 | − readonly registryPath: string; | |
| 217 | − readonly environmentId: string; | |
| 218 | −}): void { | |
| 219 | − const document = readSavedEnvironmentRegistryDocument(input.registryPath); | |
| 220 | − if ( | |
| 221 | − !document.records.some( | |
| 222 | − (record) => | |
| 223 | − record.environmentId === input.environmentId && record.encryptedBearerToken !== undefined, | |
| 224 | − ) | |
| 225 | − ) { | |
| 226 | − return; | |
| 227 | − } | |
| 228 | − | |
| 229 | − writeJsonFile(input.registryPath, { | |
| 230 | − records: document.records.map((record) => { | |
| 231 | − if (record.environmentId !== input.environmentId) { | |
| 232 | − return record; | |
| 233 | − } | |
| 234 | − | |
| 235 | − return toPersistedSavedEnvironmentRecord(record); | |
| 236 | − }), | |
| 237 | − } satisfies SavedEnvironmentRegistryDocument); | |
| 238 | −} | |
| @@ -1,57 +0,0 @@ | ||
| 1 | −import type { BrowserWindow } from "electron"; | |
| 2 | −import { beforeEach, describe, expect, it, vi } from "vitest"; | |
| 3 | − | |
| 4 | −const { showMessageBoxMock } = vi.hoisted(() => ({ | |
| 5 | − showMessageBoxMock: vi.fn(), | |
| 6 | −})); | |
| 7 | − | |
| 8 | −vi.mock("electron", () => ({ | |
| 9 | − dialog: { | |
| 10 | − showMessageBox: showMessageBoxMock, | |
| 11 | − }, | |
| 12 | −})); | |
| 13 | − | |
| 14 | −import { showDesktopConfirmDialog } from "./confirmDialog.ts"; | |
| 15 | − | |
| 16 | −describe("showDesktopConfirmDialog", () => { | |
| 17 | − beforeEach(() => { | |
| 18 | − showMessageBoxMock.mockReset(); | |
| 19 | − }); | |
| 20 | − | |
| 21 | − it("returns false and does not open a dialog for empty messages", async () => { | |
| 22 | − const result = await showDesktopConfirmDialog(" ", null); | |
| 23 | − | |
| 24 | − expect(result).toBe(false); | |
| 25 | − expect(showMessageBoxMock).not.toHaveBeenCalled(); | |
| 26 | − }); | |
| 27 | − | |
| 28 | − it("opens a dialog for the focused window and returns true on confirm", async () => { | |
| 29 | − const ownerWindow = { id: 1 } as BrowserWindow; | |
| 30 | − showMessageBoxMock.mockResolvedValue({ response: 1 }); | |
| 31 | − | |
| 32 | − const result = await showDesktopConfirmDialog("Delete worktree?", ownerWindow); | |
| 33 | − | |
| 34 | − expect(result).toBe(true); | |
| 35 | − expect(showMessageBoxMock).toHaveBeenCalledWith( | |
| 36 | − ownerWindow, | |
| 37 | − expect.objectContaining({ | |
| 38 | − buttons: ["No", "Yes"], | |
| 39 | − message: "Delete worktree?", | |
| 40 | − }), | |
| 41 | − ); | |
| 42 | − }); | |
| 43 | − | |
| 44 | − it("opens an app-level dialog when there is no focused window", async () => { | |
| 45 | − showMessageBoxMock.mockResolvedValue({ response: 0 }); | |
| 46 | − | |
| 47 | − const result = await showDesktopConfirmDialog("Delete worktree?", null); | |
| 48 | − | |
| 49 | − expect(result).toBe(false); | |
| 50 | − expect(showMessageBoxMock).toHaveBeenCalledWith( | |
| 51 | − expect.objectContaining({ | |
| 52 | − buttons: ["No", "Yes"], | |
| 53 | − message: "Delete worktree?", | |
| 54 | − }), | |
| 55 | − ); | |
| 56 | − }); | |
| 57 | −}); | |
| @@ -1,26 +0,0 @@ | ||
| 1 | −import { type BrowserWindow, dialog } from "electron"; | |
| 2 | − | |
| 3 | −const CONFIRM_BUTTON_INDEX = 1; | |
| 4 | − | |
| 5 | −export async function showDesktopConfirmDialog( | |
| 6 | − message: string, | |
| 7 | − ownerWindow: BrowserWindow | null, | |
| 8 | −): Promise<boolean> { | |
| 9 | − const normalizedMessage = message.trim(); | |
| 10 | − if (normalizedMessage.length === 0) { | |
| 11 | − return false; | |
| 12 | − } | |
| 13 | − | |
| 14 | − const options = { | |
| 15 | − type: "question" as const, | |
| 16 | − buttons: ["No", "Yes"], | |
| 17 | − defaultId: 0, | |
| 18 | − cancelId: 0, | |
| 19 | − noLink: true, | |
| 20 | − message: normalizedMessage, | |
| 21 | − }; | |
| 22 | − const result = ownerWindow | |
| 23 | − ? await dialog.showMessageBox(ownerWindow, options) | |
| 24 | − : await dialog.showMessageBox(options); | |
| 25 | − return result.response === CONFIRM_BUTTON_INDEX; | |
| 26 | −} | |
| @@ -1,230 +0,0 @@ | ||
| 1 | −import * as fs from "node:fs"; | |
| 2 | −import * as os from "node:os"; | |
| 3 | −import * as path from "node:path"; | |
| 4 | − | |
| 5 | −import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | − | |
| 7 | −import { | |
| 8 | − DEFAULT_DESKTOP_SETTINGS, | |
| 9 | − readDesktopSettings, | |
| 10 | − resolveDefaultDesktopSettings, | |
| 11 | − setDesktopServerExposurePreference, | |
| 12 | − setDesktopTailscaleServePreference, | |
| 13 | − setDesktopUpdateChannelPreference, | |
| 14 | − writeDesktopSettings, | |
| 15 | −} from "./desktopSettings.ts"; | |
| 16 | − | |
| 17 | −const tempDirectories: string[] = []; | |
| 18 | − | |
| 19 | −afterEach(() => { | |
| 20 | − for (const directory of tempDirectories.splice(0)) { | |
| 21 | − fs.rmSync(directory, { recursive: true, force: true }); | |
| 22 | − } | |
| 23 | −}); | |
| 24 | − | |
| 25 | −function makeSettingsPath() { | |
| 26 | − const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-desktop-settings-test-")); | |
| 27 | − tempDirectories.push(directory); | |
| 28 | − return path.join(directory, "desktop-settings.json"); | |
| 29 | −} | |
| 30 | − | |
| 31 | −describe("desktopSettings", () => { | |
| 32 | − it("returns defaults when no settings file exists", () => { | |
| 33 | − expect(readDesktopSettings(makeSettingsPath(), "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS); | |
| 34 | − }); | |
| 35 | − | |
| 36 | − it("defaults packaged nightly builds to the nightly update channel", () => { | |
| 37 | − expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({ | |
| 38 | − serverExposureMode: "local-only", | |
| 39 | − tailscaleServeEnabled: false, | |
| 40 | − tailscaleServePort: 443, | |
| 41 | − updateChannel: "nightly", | |
| 42 | − updateChannelConfiguredByUser: false, | |
| 43 | − }); | |
| 44 | − }); | |
| 45 | − | |
| 46 | − it("persists and reloads the configured server exposure mode", () => { | |
| 47 | − const settingsPath = makeSettingsPath(); | |
| 48 | − | |
| 49 | − writeDesktopSettings(settingsPath, { | |
| 50 | − serverExposureMode: "network-accessible", | |
| 51 | − tailscaleServeEnabled: true, | |
| 52 | − tailscaleServePort: 8443, | |
| 53 | − updateChannel: "latest", | |
| 54 | − updateChannelConfiguredByUser: true, | |
| 55 | − }); | |
| 56 | − | |
| 57 | − expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ | |
| 58 | − serverExposureMode: "network-accessible", | |
| 59 | − tailscaleServeEnabled: true, | |
| 60 | − tailscaleServePort: 8443, | |
| 61 | − updateChannel: "latest", | |
| 62 | − updateChannelConfiguredByUser: true, | |
| 63 | − }); | |
| 64 | − }); | |
| 65 | − | |
| 66 | − it("preserves the requested network-accessible preference across temporary fallback", () => { | |
| 67 | − expect( | |
| 68 | − setDesktopServerExposurePreference( | |
| 69 | − { | |
| 70 | − serverExposureMode: "local-only", | |
| 71 | − tailscaleServeEnabled: false, | |
| 72 | − tailscaleServePort: 443, | |
| 73 | − updateChannel: "latest", | |
| 74 | − updateChannelConfiguredByUser: false, | |
| 75 | − }, | |
| 76 | − "network-accessible", | |
| 77 | − ), | |
| 78 | − ).toEqual({ | |
| 79 | − serverExposureMode: "network-accessible", | |
| 80 | − tailscaleServeEnabled: false, | |
| 81 | − tailscaleServePort: 443, | |
| 82 | − updateChannel: "latest", | |
| 83 | − updateChannelConfiguredByUser: false, | |
| 84 | − }); | |
| 85 | − }); | |
| 86 | − | |
| 87 | − it("persists the requested Tailscale Serve preference", () => { | |
| 88 | − expect( | |
| 89 | − setDesktopTailscaleServePreference( | |
| 90 | − { | |
| 91 | − serverExposureMode: "local-only", | |
| 92 | − tailscaleServeEnabled: false, | |
| 93 | − tailscaleServePort: 443, | |
| 94 | − updateChannel: "latest", | |
| 95 | − updateChannelConfiguredByUser: false, | |
| 96 | − }, | |
| 97 | − { enabled: true, port: 8443 }, | |
| 98 | − ), | |
| 99 | − ).toEqual({ | |
| 100 | − serverExposureMode: "local-only", | |
| 101 | − tailscaleServeEnabled: true, | |
| 102 | − tailscaleServePort: 8443, | |
| 103 | − updateChannel: "latest", | |
| 104 | − updateChannelConfiguredByUser: false, | |
| 105 | − }); | |
| 106 | − }); | |
| 107 | − | |
| 108 | − it("preserves the configured Tailscale Serve port when no new port is requested", () => { | |
| 109 | − expect( | |
| 110 | − setDesktopTailscaleServePreference( | |
| 111 | − { | |
| 112 | − serverExposureMode: "local-only", | |
| 113 | − tailscaleServeEnabled: false, | |
| 114 | − tailscaleServePort: 8443, | |
| 115 | − updateChannel: "latest", | |
| 116 | − updateChannelConfiguredByUser: false, | |
| 117 | − }, | |
| 118 | − { enabled: true }, | |
| 119 | − ), | |
| 120 | − ).toEqual({ | |
| 121 | − serverExposureMode: "local-only", | |
| 122 | − tailscaleServeEnabled: true, | |
| 123 | − tailscaleServePort: 8443, | |
| 124 | − updateChannel: "latest", | |
| 125 | − updateChannelConfiguredByUser: false, | |
| 126 | − }); | |
| 127 | − }); | |
| 128 | − | |
| 129 | − it("persists the requested nightly update channel", () => { | |
| 130 | − expect( | |
| 131 | − setDesktopUpdateChannelPreference( | |
| 132 | − { | |
| 133 | − serverExposureMode: "local-only", | |
| 134 | − tailscaleServeEnabled: false, | |
| 135 | − tailscaleServePort: 443, | |
| 136 | − updateChannel: "latest", | |
| 137 | − updateChannelConfiguredByUser: false, | |
| 138 | − }, | |
| 139 | − "nightly", | |
| 140 | − ), | |
| 141 | − ).toEqual({ | |
| 142 | − serverExposureMode: "local-only", | |
| 143 | − tailscaleServeEnabled: false, | |
| 144 | − tailscaleServePort: 443, | |
| 145 | − updateChannel: "nightly", | |
| 146 | − updateChannelConfiguredByUser: true, | |
| 147 | − }); | |
| 148 | − }); | |
| 149 | − | |
| 150 | − it("falls back to defaults when the settings file is malformed", () => { | |
| 151 | − const settingsPath = makeSettingsPath(); | |
| 152 | − fs.writeFileSync(settingsPath, "{not-json", "utf8"); | |
| 153 | − | |
| 154 | − expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS); | |
| 155 | − }); | |
| 156 | − | |
| 157 | − it("falls back to the nightly channel for legacy nightly settings without an update track", () => { | |
| 158 | − const settingsPath = makeSettingsPath(); | |
| 159 | − fs.writeFileSync(settingsPath, JSON.stringify({ serverExposureMode: "local-only" }), "utf8"); | |
| 160 | − | |
| 161 | − expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ | |
| 162 | − serverExposureMode: "local-only", | |
| 163 | − tailscaleServeEnabled: false, | |
| 164 | − tailscaleServePort: 443, | |
| 165 | − updateChannel: "nightly", | |
| 166 | − updateChannelConfiguredByUser: false, | |
| 167 | − }); | |
| 168 | − }); | |
| 169 | − | |
| 170 | − it("migrates legacy implicit stable settings to nightly when running a nightly build", () => { | |
| 171 | − const settingsPath = makeSettingsPath(); | |
| 172 | − fs.writeFileSync( | |
| 173 | − settingsPath, | |
| 174 | − JSON.stringify({ | |
| 175 | − serverExposureMode: "local-only", | |
| 176 | − updateChannel: "latest", | |
| 177 | − }), | |
| 178 | − "utf8", | |
| 179 | − ); | |
| 180 | − | |
| 181 | − expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ | |
| 182 | − serverExposureMode: "local-only", | |
| 183 | − tailscaleServeEnabled: false, | |
| 184 | − tailscaleServePort: 443, | |
| 185 | − updateChannel: "nightly", | |
| 186 | − updateChannelConfiguredByUser: false, | |
| 187 | − }); | |
| 188 | − }); | |
| 189 | − | |
| 190 | − it("preserves an explicit stable choice on nightly builds", () => { | |
| 191 | − const settingsPath = makeSettingsPath(); | |
| 192 | − fs.writeFileSync( | |
| 193 | − settingsPath, | |
| 194 | − JSON.stringify({ | |
| 195 | − serverExposureMode: "local-only", | |
| 196 | − updateChannel: "latest", | |
| 197 | − updateChannelConfiguredByUser: true, | |
| 198 | − }), | |
| 199 | − "utf8", | |
| 200 | − ); | |
| 201 | − | |
| 202 | − expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ | |
| 203 | − serverExposureMode: "local-only", | |
| 204 | − tailscaleServeEnabled: false, | |
| 205 | − tailscaleServePort: 443, | |
| 206 | − updateChannel: "latest", | |
| 207 | − updateChannelConfiguredByUser: true, | |
| 208 | − }); | |
| 209 | − }); | |
| 210 | − | |
| 211 | − it("falls back to the default Tailscale Serve port when the persisted port is invalid", () => { | |
| 212 | − const settingsPath = makeSettingsPath(); | |
| 213 | − fs.writeFileSync( | |
| 214 | − settingsPath, | |
| 215 | − JSON.stringify({ | |
| 216 | − tailscaleServeEnabled: true, | |
| 217 | − tailscaleServePort: 0, | |
| 218 | − }), | |
| 219 | − "utf8", | |
| 220 | − ); | |
| 221 | − | |
| 222 | − expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ | |
| 223 | − serverExposureMode: "local-only", | |
| 224 | − tailscaleServeEnabled: true, | |
| 225 | − tailscaleServePort: 443, | |
| 226 | − updateChannel: "latest", | |
| 227 | − updateChannelConfiguredByUser: false, | |
| 228 | − }); | |
| 229 | − }); | |
| 230 | −}); | |
| @@ -1,125 +0,0 @@ | ||
| 1 | −import * as FS from "node:fs"; | |
| 2 | −import * as Path from "node:path"; | |
| 3 | −import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts"; | |
| 4 | − | |
| 5 | −import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; | |
| 6 | − | |
| 7 | −export interface DesktopSettings { | |
| 8 | − readonly serverExposureMode: DesktopServerExposureMode; | |
| 9 | − readonly tailscaleServeEnabled: boolean; | |
| 10 | − readonly tailscaleServePort: number; | |
| 11 | − readonly updateChannel: DesktopUpdateChannel; | |
| 12 | − readonly updateChannelConfiguredByUser: boolean; | |
| 13 | −} | |
| 14 | − | |
| 15 | −export const DEFAULT_TAILSCALE_SERVE_PORT = 443; | |
| 16 | − | |
| 17 | −export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { | |
| 18 | − serverExposureMode: "local-only", | |
| 19 | − tailscaleServeEnabled: false, | |
| 20 | − tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, | |
| 21 | − updateChannel: "latest", | |
| 22 | − updateChannelConfiguredByUser: false, | |
| 23 | −}; | |
| 24 | − | |
| 25 | −export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { | |
| 26 | − return { | |
| 27 | − ...DEFAULT_DESKTOP_SETTINGS, | |
| 28 | − updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), | |
| 29 | − }; | |
| 30 | −} | |
| 31 | − | |
| 32 | −export function setDesktopServerExposurePreference( | |
| 33 | − settings: DesktopSettings, | |
| 34 | − requestedMode: DesktopServerExposureMode, | |
| 35 | −): DesktopSettings { | |
| 36 | − return settings.serverExposureMode === requestedMode | |
| 37 | − ? settings | |
| 38 | − : { | |
| 39 | − ...settings, | |
| 40 | − serverExposureMode: requestedMode, | |
| 41 | − }; | |
| 42 | −} | |
| 43 | − | |
| 44 | −export function setDesktopTailscaleServePreference( | |
| 45 | − settings: DesktopSettings, | |
| 46 | − input: { readonly enabled: boolean; readonly port?: number }, | |
| 47 | −): DesktopSettings { | |
| 48 | − const port = | |
| 49 | − input.port === undefined | |
| 50 | − ? settings.tailscaleServePort | |
| 51 | − : normalizeTailscaleServePort(input.port); | |
| 52 | − return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port | |
| 53 | − ? settings | |
| 54 | − : { | |
| 55 | − ...settings, | |
| 56 | − tailscaleServeEnabled: input.enabled, | |
| 57 | − tailscaleServePort: port, | |
| 58 | − }; | |
| 59 | −} | |
| 60 | − | |
| 61 | −export function normalizeTailscaleServePort(value: unknown): number { | |
| 62 | − return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535 | |
| 63 | − ? value | |
| 64 | − : DEFAULT_TAILSCALE_SERVE_PORT; | |
| 65 | −} | |
| 66 | − | |
| 67 | −export function setDesktopUpdateChannelPreference( | |
| 68 | − settings: DesktopSettings, | |
| 69 | − requestedChannel: DesktopUpdateChannel, | |
| 70 | −): DesktopSettings { | |
| 71 | − return { | |
| 72 | − ...settings, | |
| 73 | − updateChannel: requestedChannel, | |
| 74 | − updateChannelConfiguredByUser: true, | |
| 75 | − }; | |
| 76 | −} | |
| 77 | − | |
| 78 | −export function readDesktopSettings(settingsPath: string, appVersion: string): DesktopSettings { | |
| 79 | − const defaultSettings = resolveDefaultDesktopSettings(appVersion); | |
| 80 | − | |
| 81 | − try { | |
| 82 | − if (!FS.existsSync(settingsPath)) { | |
| 83 | − return defaultSettings; | |
| 84 | − } | |
| 85 | − | |
| 86 | − const raw = FS.readFileSync(settingsPath, "utf8"); | |
| 87 | − const parsed = JSON.parse(raw) as { | |
| 88 | − readonly serverExposureMode?: unknown; | |
| 89 | − readonly tailscaleServeEnabled?: unknown; | |
| 90 | − readonly tailscaleServePort?: unknown; | |
| 91 | − readonly updateChannel?: unknown; | |
| 92 | − readonly updateChannelConfiguredByUser?: unknown; | |
| 93 | − }; | |
| 94 | − const parsedUpdateChannel = | |
| 95 | − parsed.updateChannel === "nightly" || parsed.updateChannel === "latest" | |
| 96 | − ? parsed.updateChannel | |
| 97 | − : null; | |
| 98 | − const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; | |
| 99 | − const updateChannelConfiguredByUser = | |
| 100 | − parsed.updateChannelConfiguredByUser === true || | |
| 101 | − (isLegacySettings && parsedUpdateChannel === "nightly"); | |
| 102 | − | |
| 103 | − return { | |
| 104 | − serverExposureMode: | |
| 105 | − parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", | |
| 106 | − tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, | |
| 107 | − tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), | |
| 108 | − updateChannel: | |
| 109 | − updateChannelConfiguredByUser && parsedUpdateChannel !== null | |
| 110 | − ? parsedUpdateChannel | |
| 111 | − : defaultSettings.updateChannel, | |
| 112 | − updateChannelConfiguredByUser, | |
| 113 | − }; | |
| 114 | − } catch { | |
| 115 | − return defaultSettings; | |
| 116 | − } | |
| 117 | −} | |
| 118 | − | |
| 119 | −export function writeDesktopSettings(settingsPath: string, settings: DesktopSettings): void { | |
| 120 | − const directory = Path.dirname(settingsPath); | |
| 121 | − const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; | |
| 122 | − FS.mkdirSync(directory, { recursive: true }); | |
| 123 | − FS.writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); | |
| 124 | − FS.renameSync(tempPath, settingsPath); | |
| 125 | −} | |
| @@ -0,0 +1,109 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import { beforeEach, vi } from "vitest"; | |
| 4 | + | |
| 5 | +const { | |
| 6 | + appendSwitchMock, | |
| 7 | + exitMock, | |
| 8 | + getAppPathMock, | |
| 9 | + getVersionMock, | |
| 10 | + onMock, | |
| 11 | + quitMock, | |
| 12 | + relaunchMock, | |
| 13 | + removeListenerMock, | |
| 14 | + setAboutPanelOptionsMock, | |
| 15 | + setAppUserModelIdMock, | |
| 16 | + setDesktopNameMock, | |
| 17 | + setDockIconMock, | |
| 18 | + setNameMock, | |
| 19 | + setPathMock, | |
| 20 | + whenReadyMock, | |
| 21 | +} = vi.hoisted(() => ({ | |
| 22 | + appendSwitchMock: vi.fn(), | |
| 23 | + exitMock: vi.fn(), | |
| 24 | + getAppPathMock: vi.fn(() => "/app"), | |
| 25 | + getVersionMock: vi.fn(() => "1.2.3"), | |
| 26 | + onMock: vi.fn(), | |
| 27 | + quitMock: vi.fn(), | |
| 28 | + relaunchMock: vi.fn(), | |
| 29 | + removeListenerMock: vi.fn(), | |
| 30 | + setAboutPanelOptionsMock: vi.fn(), | |
| 31 | + setAppUserModelIdMock: vi.fn(), | |
| 32 | + setDesktopNameMock: vi.fn(), | |
| 33 | + setDockIconMock: vi.fn(), | |
| 34 | + setNameMock: vi.fn(), | |
| 35 | + setPathMock: vi.fn(), | |
| 36 | + whenReadyMock: vi.fn(() => Promise.resolve()), | |
| 37 | +})); | |
| 38 | + | |
| 39 | +vi.mock("electron", () => ({ | |
| 40 | + app: { | |
| 41 | + commandLine: { | |
| 42 | + appendSwitch: appendSwitchMock, | |
| 43 | + }, | |
| 44 | + dock: { | |
| 45 | + setIcon: setDockIconMock, | |
| 46 | + }, | |
| 47 | + getAppPath: getAppPathMock, | |
| 48 | + getVersion: getVersionMock, | |
| 49 | + isPackaged: true, | |
| 50 | + name: "T3 Code", | |
| 51 | + on: onMock, | |
| 52 | + quit: quitMock, | |
| 53 | + relaunch: relaunchMock, | |
| 54 | + removeListener: removeListenerMock, | |
| 55 | + runningUnderARM64Translation: false, | |
| 56 | + setAboutPanelOptions: setAboutPanelOptionsMock, | |
| 57 | + setAppUserModelId: setAppUserModelIdMock, | |
| 58 | + setDesktopName: setDesktopNameMock, | |
| 59 | + setName: setNameMock, | |
| 60 | + setPath: setPathMock, | |
| 61 | + whenReady: whenReadyMock, | |
| 62 | + exit: exitMock, | |
| 63 | + }, | |
| 64 | +})); | |
| 65 | + | |
| 66 | +import * as ElectronApp from "./ElectronApp.ts"; | |
| 67 | + | |
| 68 | +describe("ElectronApp", () => { | |
| 69 | + beforeEach(() => { | |
| 70 | + appendSwitchMock.mockClear(); | |
| 71 | + exitMock.mockClear(); | |
| 72 | + onMock.mockClear(); | |
| 73 | + quitMock.mockClear(); | |
| 74 | + relaunchMock.mockClear(); | |
| 75 | + removeListenerMock.mockClear(); | |
| 76 | + setPathMock.mockClear(); | |
| 77 | + }); | |
| 78 | + | |
| 79 | + it.effect("reads app metadata through the service", () => | |
| 80 | + Effect.gen(function* () { | |
| 81 | + const electronApp = yield* ElectronApp.ElectronApp; | |
| 82 | + const metadata = yield* electronApp.metadata; | |
| 83 | + | |
| 84 | + assert.deepEqual(metadata, { | |
| 85 | + appVersion: "1.2.3", | |
| 86 | + appPath: "/app", | |
| 87 | + isPackaged: true, | |
| 88 | + resourcesPath: process.resourcesPath, | |
| 89 | + runningUnderArm64Translation: false, | |
| 90 | + }); | |
| 91 | + }).pipe(Effect.provide(ElectronApp.layer)), | |
| 92 | + ); | |
| 93 | + | |
| 94 | + it.effect("scopes app event listeners", () => | |
| 95 | + Effect.gen(function* () { | |
| 96 | + const listener = vi.fn(); | |
| 97 | + | |
| 98 | + yield* Effect.scoped( | |
| 99 | + Effect.gen(function* () { | |
| 100 | + const electronApp = yield* ElectronApp.ElectronApp; | |
| 101 | + yield* electronApp.on("activate", listener); | |
| 102 | + }), | |
| 103 | + ); | |
| 104 | + | |
| 105 | + assert.deepEqual(onMock.mock.calls, [["activate", listener]]); | |
| 106 | + assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]); | |
| 107 | + }).pipe(Effect.provide(ElectronApp.layer)), | |
| 108 | + ); | |
| 109 | +}); | |
| @@ -0,0 +1,118 @@ | ||
| 1 | +import * as Context from "effect/Context"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Layer from "effect/Layer"; | |
| 4 | +import * as Scope from "effect/Scope"; | |
| 5 | + | |
| 6 | +import * as Electron from "electron"; | |
| 7 | + | |
| 8 | +export interface ElectronAppMetadata { | |
| 9 | + readonly appVersion: string; | |
| 10 | + readonly appPath: string; | |
| 11 | + readonly isPackaged: boolean; | |
| 12 | + readonly resourcesPath: string; | |
| 13 | + readonly runningUnderArm64Translation: boolean; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export interface ElectronAppShape { | |
| 17 | + readonly metadata: Effect.Effect<ElectronAppMetadata>; | |
| 18 | + readonly name: Effect.Effect<string>; | |
| 19 | + readonly whenReady: Effect.Effect<void>; | |
| 20 | + readonly quit: Effect.Effect<void>; | |
| 21 | + readonly exit: (code: number) => Effect.Effect<void>; | |
| 22 | + readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect<void>; | |
| 23 | + readonly setPath: ( | |
| 24 | + name: Parameters<Electron.App["setPath"]>[0], | |
| 25 | + path: string, | |
| 26 | + ) => Effect.Effect<void>; | |
| 27 | + readonly setName: (name: string) => Effect.Effect<void>; | |
| 28 | + readonly setAboutPanelOptions: ( | |
| 29 | + options: Electron.AboutPanelOptionsOptions, | |
| 30 | + ) => Effect.Effect<void>; | |
| 31 | + readonly setAppUserModelId: (id: string) => Effect.Effect<void>; | |
| 32 | + readonly setDesktopName: (desktopName: string) => Effect.Effect<void>; | |
| 33 | + readonly setDockIcon: (iconPath: string) => Effect.Effect<void>; | |
| 34 | + readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>; | |
| 35 | + readonly on: <Args extends ReadonlyArray<unknown>>( | |
| 36 | + eventName: string, | |
| 37 | + listener: (...args: Args) => void, | |
| 38 | + ) => Effect.Effect<void, never, Scope.Scope>; | |
| 39 | +} | |
| 40 | + | |
| 41 | +export class ElectronApp extends Context.Service<ElectronApp, ElectronAppShape>()( | |
| 42 | + "t3/desktop/electron/App", | |
| 43 | +) {} | |
| 44 | + | |
| 45 | +const addScopedAppListener = <Args extends ReadonlyArray<unknown>>( | |
| 46 | + eventName: string, | |
| 47 | + listener: (...args: Args) => void, | |
| 48 | +): Effect.Effect<void, never, Scope.Scope> => | |
| 49 | + Effect.acquireRelease( | |
| 50 | + Effect.sync(() => { | |
| 51 | + Electron.app.on(eventName as any, listener as any); | |
| 52 | + }), | |
| 53 | + () => | |
| 54 | + Effect.sync(() => { | |
| 55 | + Electron.app.removeListener(eventName as any, listener as any); | |
| 56 | + }), | |
| 57 | + ).pipe(Effect.asVoid); | |
| 58 | + | |
| 59 | +const make = ElectronApp.of({ | |
| 60 | + metadata: Effect.sync(() => ({ | |
| 61 | + appVersion: Electron.app.getVersion(), | |
| 62 | + appPath: Electron.app.getAppPath(), | |
| 63 | + isPackaged: Electron.app.isPackaged, | |
| 64 | + resourcesPath: process.resourcesPath, | |
| 65 | + runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true, | |
| 66 | + })), | |
| 67 | + name: Effect.sync(() => Electron.app.name), | |
| 68 | + whenReady: Effect.promise(() => Electron.app.whenReady()).pipe(Effect.asVoid), | |
| 69 | + quit: Effect.sync(() => { | |
| 70 | + Electron.app.quit(); | |
| 71 | + }), | |
| 72 | + exit: (code) => | |
| 73 | + Effect.sync(() => { | |
| 74 | + Electron.app.exit(code); | |
| 75 | + }), | |
| 76 | + relaunch: (options) => | |
| 77 | + Effect.sync(() => { | |
| 78 | + Electron.app.relaunch(options); | |
| 79 | + }), | |
| 80 | + setPath: (name, path) => | |
| 81 | + Effect.sync(() => { | |
| 82 | + Electron.app.setPath(name, path); | |
| 83 | + }), | |
| 84 | + setName: (name) => | |
| 85 | + Effect.sync(() => { | |
| 86 | + Electron.app.setName(name); | |
| 87 | + }), | |
| 88 | + setAboutPanelOptions: (options) => | |
| 89 | + Effect.sync(() => { | |
| 90 | + Electron.app.setAboutPanelOptions(options); | |
| 91 | + }), | |
| 92 | + setAppUserModelId: (id) => | |
| 93 | + Effect.sync(() => { | |
| 94 | + Electron.app.setAppUserModelId(id); | |
| 95 | + }), | |
| 96 | + setDesktopName: (desktopName) => | |
| 97 | + Effect.sync(() => { | |
| 98 | + const linuxApp = Electron.app as Electron.App & { | |
| 99 | + setDesktopName?: (desktopName: string) => void; | |
| 100 | + }; | |
| 101 | + linuxApp.setDesktopName?.(desktopName); | |
| 102 | + }), | |
| 103 | + setDockIcon: (iconPath) => | |
| 104 | + Effect.sync(() => { | |
| 105 | + Electron.app.dock?.setIcon(iconPath); | |
| 106 | + }), | |
| 107 | + appendCommandLineSwitch: (switchName, value) => | |
| 108 | + Effect.sync(() => { | |
| 109 | + if (value === undefined) { | |
| 110 | + Electron.app.commandLine.appendSwitch(switchName); | |
| 111 | + return; | |
| 112 | + } | |
| 113 | + Electron.app.commandLine.appendSwitch(switchName, value); | |
| 114 | + }), | |
| 115 | + on: addScopedAppListener, | |
| 116 | +}); | |
| 117 | + | |
| 118 | +export const layer = Layer.succeed(ElectronApp, make); | |
| @@ -0,0 +1,93 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Option from "effect/Option"; | |
| 4 | +import type { BrowserWindow } from "electron"; | |
| 5 | +import { beforeEach, vi } from "vitest"; | |
| 6 | + | |
| 7 | +import * as ElectronDialog from "./ElectronDialog.ts"; | |
| 8 | + | |
| 9 | +const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock } = vi.hoisted(() => ({ | |
| 10 | + showMessageBoxMock: vi.fn(), | |
| 11 | + showOpenDialogMock: vi.fn(), | |
| 12 | + showErrorBoxMock: vi.fn(), | |
| 13 | +})); | |
| 14 | + | |
| 15 | +vi.mock("electron", () => ({ | |
| 16 | + dialog: { | |
| 17 | + showMessageBox: showMessageBoxMock, | |
| 18 | + showOpenDialog: showOpenDialogMock, | |
| 19 | + showErrorBox: showErrorBoxMock, | |
| 20 | + }, | |
| 21 | +})); | |
| 22 | + | |
| 23 | +describe("ElectronDialog", () => { | |
| 24 | + beforeEach(() => { | |
| 25 | + showMessageBoxMock.mockReset(); | |
| 26 | + showOpenDialogMock.mockReset(); | |
| 27 | + showErrorBoxMock.mockReset(); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + it.effect("returns false without opening a confirm dialog for empty messages", () => | |
| 31 | + Effect.gen(function* () { | |
| 32 | + const dialog = yield* ElectronDialog.ElectronDialog; | |
| 33 | + | |
| 34 | + const result = yield* dialog.confirm({ | |
| 35 | + message: " ", | |
| 36 | + owner: Option.none(), | |
| 37 | + }); | |
| 38 | + | |
| 39 | + assert.isFalse(result); | |
| 40 | + assert.equal(showMessageBoxMock.mock.calls.length, 0); | |
| 41 | + }).pipe(Effect.provide(ElectronDialog.layer)), | |
| 42 | + ); | |
| 43 | + | |
| 44 | + it.effect("opens a confirm dialog for the owner window", () => | |
| 45 | + Effect.gen(function* () { | |
| 46 | + const owner = { id: 1 } as BrowserWindow; | |
| 47 | + showMessageBoxMock.mockResolvedValue({ response: 1 }); | |
| 48 | + const dialog = yield* ElectronDialog.ElectronDialog; | |
| 49 | + | |
| 50 | + const result = yield* dialog.confirm({ | |
| 51 | + message: "Delete worktree?", | |
| 52 | + owner: Option.some(owner), | |
| 53 | + }); | |
| 54 | + | |
| 55 | + assert.isTrue(result); | |
| 56 | + assert.deepEqual(showMessageBoxMock.mock.calls[0], [ | |
| 57 | + owner, | |
| 58 | + { | |
| 59 | + type: "question", | |
| 60 | + buttons: ["No", "Yes"], | |
| 61 | + defaultId: 0, | |
| 62 | + cancelId: 0, | |
| 63 | + noLink: true, | |
| 64 | + message: "Delete worktree?", | |
| 65 | + }, | |
| 66 | + ]); | |
| 67 | + }).pipe(Effect.provide(ElectronDialog.layer)), | |
| 68 | + ); | |
| 69 | + | |
| 70 | + it.effect("opens an app-level confirm dialog when there is no owner window", () => | |
| 71 | + Effect.gen(function* () { | |
| 72 | + showMessageBoxMock.mockResolvedValue({ response: 0 }); | |
| 73 | + const dialog = yield* ElectronDialog.ElectronDialog; | |
| 74 | + | |
| 75 | + const result = yield* dialog.confirm({ | |
| 76 | + message: "Delete worktree?", | |
| 77 | + owner: Option.none(), | |
| 78 | + }); | |
| 79 | + | |
| 80 | + assert.isFalse(result); | |
| 81 | + assert.deepEqual(showMessageBoxMock.mock.calls[0], [ | |
| 82 | + { | |
| 83 | + type: "question", | |
| 84 | + buttons: ["No", "Yes"], | |
| 85 | + defaultId: 0, | |
| 86 | + cancelId: 0, | |
| 87 | + noLink: true, | |
| 88 | + message: "Delete worktree?", | |
| 89 | + }, | |
| 90 | + ]); | |
| 91 | + }).pipe(Effect.provide(ElectronDialog.layer)), | |
| 92 | + ); | |
| 93 | +}); | |
| @@ -0,0 +1,84 @@ | ||
| 1 | +import * as Context from "effect/Context"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Layer from "effect/Layer"; | |
| 4 | +import * as Option from "effect/Option"; | |
| 5 | + | |
| 6 | +import * as Electron from "electron"; | |
| 7 | + | |
| 8 | +const CONFIRM_BUTTON_INDEX = 1; | |
| 9 | + | |
| 10 | +export interface ElectronDialogPickFolderInput { | |
| 11 | + readonly owner: Option.Option<Electron.BrowserWindow>; | |
| 12 | + readonly defaultPath: Option.Option<string>; | |
| 13 | +} | |
| 14 | + | |
| 15 | +export interface ElectronDialogConfirmInput { | |
| 16 | + readonly owner: Option.Option<Electron.BrowserWindow>; | |
| 17 | + readonly message: string; | |
| 18 | +} | |
| 19 | + | |
| 20 | +export interface ElectronDialogShape { | |
| 21 | + readonly pickFolder: ( | |
| 22 | + input: ElectronDialogPickFolderInput, | |
| 23 | + ) => Effect.Effect<Option.Option<string>>; | |
| 24 | + readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect<boolean>; | |
| 25 | + readonly showMessageBox: ( | |
| 26 | + options: Electron.MessageBoxOptions, | |
| 27 | + ) => Effect.Effect<Electron.MessageBoxReturnValue>; | |
| 28 | + readonly showErrorBox: (title: string, content: string) => Effect.Effect<void>; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export class ElectronDialog extends Context.Service<ElectronDialog, ElectronDialogShape>()( | |
| 32 | + "t3/desktop/electron/Dialog", | |
| 33 | +) {} | |
| 34 | + | |
| 35 | +const make = ElectronDialog.of({ | |
| 36 | + pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { | |
| 37 | + const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { | |
| 38 | + onNone: () => ({ | |
| 39 | + properties: ["openDirectory", "createDirectory"], | |
| 40 | + }), | |
| 41 | + onSome: (defaultPath) => ({ | |
| 42 | + properties: ["openDirectory", "createDirectory"], | |
| 43 | + defaultPath, | |
| 44 | + }), | |
| 45 | + }); | |
| 46 | + const result = yield* Option.match(input.owner, { | |
| 47 | + onNone: () => Effect.promise(() => Electron.dialog.showOpenDialog(openDialogOptions)), | |
| 48 | + onSome: (owner) => | |
| 49 | + Effect.promise(() => Electron.dialog.showOpenDialog(owner, openDialogOptions)), | |
| 50 | + }); | |
| 51 | + | |
| 52 | + if (result.canceled) { | |
| 53 | + return Option.none(); | |
| 54 | + } | |
| 55 | + return Option.fromNullishOr(result.filePaths[0]); | |
| 56 | + }), | |
| 57 | + confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { | |
| 58 | + const normalizedMessage = input.message.trim(); | |
| 59 | + if (normalizedMessage.length === 0) { | |
| 60 | + return false; | |
| 61 | + } | |
| 62 | + | |
| 63 | + const options = { | |
| 64 | + type: "question" as const, | |
| 65 | + buttons: ["No", "Yes"], | |
| 66 | + defaultId: 0, | |
| 67 | + cancelId: 0, | |
| 68 | + noLink: true, | |
| 69 | + message: normalizedMessage, | |
| 70 | + }; | |
| 71 | + const result = yield* Option.match(input.owner, { | |
| 72 | + onNone: () => Effect.promise(() => Electron.dialog.showMessageBox(options)), | |
| 73 | + onSome: (owner) => Effect.promise(() => Electron.dialog.showMessageBox(owner, options)), | |
| 74 | + }); | |
| 75 | + return result.response === CONFIRM_BUTTON_INDEX; | |
| 76 | + }), | |
| 77 | + showMessageBox: (options) => Effect.promise(() => Electron.dialog.showMessageBox(options)), | |
| 78 | + showErrorBox: (title, content) => | |
| 79 | + Effect.sync(() => { | |
| 80 | + Electron.dialog.showErrorBox(title, content); | |
| 81 | + }), | |
| 82 | +}); | |
| 83 | + | |
| 84 | +export const layer = Layer.succeed(ElectronDialog, make); | |
| @@ -0,0 +1,119 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Option from "effect/Option"; | |
| 4 | +import type * as Electron from "electron"; | |
| 5 | +import { beforeEach, vi } from "vitest"; | |
| 6 | + | |
| 7 | +const { buildFromTemplateMock, createFromNamedImageMock, setApplicationMenuMock } = vi.hoisted( | |
| 8 | + () => ({ | |
| 9 | + buildFromTemplateMock: vi.fn(), | |
| 10 | + createFromNamedImageMock: vi.fn(), | |
| 11 | + setApplicationMenuMock: vi.fn(), | |
| 12 | + }), | |
| 13 | +); | |
| 14 | + | |
| 15 | +vi.mock("electron", () => ({ | |
| 16 | + Menu: { | |
| 17 | + buildFromTemplate: buildFromTemplateMock, | |
| 18 | + setApplicationMenu: setApplicationMenuMock, | |
| 19 | + }, | |
| 20 | + nativeImage: { | |
| 21 | + createFromNamedImage: createFromNamedImageMock, | |
| 22 | + }, | |
| 23 | +})); | |
| 24 | + | |
| 25 | +import * as ElectronMenu from "./ElectronMenu.ts"; | |
| 26 | + | |
| 27 | +describe("ElectronMenu", () => { | |
| 28 | + beforeEach(() => { | |
| 29 | + buildFromTemplateMock.mockReset(); | |
| 30 | + createFromNamedImageMock.mockReset(); | |
| 31 | + setApplicationMenuMock.mockReset(); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it.effect("returns none without building a menu when there are no valid items", () => | |
| 35 | + Effect.gen(function* () { | |
| 36 | + const electronMenu = yield* ElectronMenu.ElectronMenu; | |
| 37 | + const selectedItemId = yield* electronMenu.showContextMenu({ | |
| 38 | + window: {} as Electron.BrowserWindow, | |
| 39 | + items: [], | |
| 40 | + position: Option.none(), | |
| 41 | + }); | |
| 42 | + | |
| 43 | + assert.isTrue(Option.isNone(selectedItemId)); | |
| 44 | + assert.equal(buildFromTemplateMock.mock.calls.length, 0); | |
| 45 | + }).pipe(Effect.provide(ElectronMenu.layer)), | |
| 46 | + ); | |
| 47 | + | |
| 48 | + it.effect("resolves with the clicked leaf item id", () => | |
| 49 | + Effect.gen(function* () { | |
| 50 | + buildFromTemplateMock.mockImplementation( | |
| 51 | + (template: Electron.MenuItemConstructorOptions[]) => ({ | |
| 52 | + popup: () => { | |
| 53 | + const firstItem = template[0]; | |
| 54 | + assert.isDefined(firstItem); | |
| 55 | + const click = firstItem.click; | |
| 56 | + if (!click) { | |
| 57 | + throw new Error("Expected menu item to have a click handler."); | |
| 58 | + } | |
| 59 | + click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); | |
| 60 | + }, | |
| 61 | + }), | |
| 62 | + ); | |
| 63 | + | |
| 64 | + const electronMenu = yield* ElectronMenu.ElectronMenu; | |
| 65 | + const selectedItemId = yield* electronMenu.showContextMenu({ | |
| 66 | + window: {} as Electron.BrowserWindow, | |
| 67 | + items: [{ id: "copy", label: "Copy" }], | |
| 68 | + position: Option.none(), | |
| 69 | + }); | |
| 70 | + | |
| 71 | + assert.equal(Option.getOrNull(selectedItemId), "copy"); | |
| 72 | + }).pipe(Effect.provide(ElectronMenu.layer)), | |
| 73 | + ); | |
| 74 | + | |
| 75 | + it.effect("resolves with none when the menu closes without a click", () => | |
| 76 | + Effect.gen(function* () { | |
| 77 | + buildFromTemplateMock.mockImplementation(() => ({ | |
| 78 | + popup: (options: Electron.PopupOptions) => { | |
| 79 | + options.callback?.(); | |
| 80 | + }, | |
| 81 | + })); | |
| 82 | + | |
| 83 | + const electronMenu = yield* ElectronMenu.ElectronMenu; | |
| 84 | + const selectedItemId = yield* electronMenu.showContextMenu({ | |
| 85 | + window: {} as Electron.BrowserWindow, | |
| 86 | + items: [{ id: "copy", label: "Copy" }], | |
| 87 | + position: Option.some({ x: 10.8, y: 20.2 }), | |
| 88 | + }); | |
| 89 | + | |
| 90 | + assert.isTrue(Option.isNone(selectedItemId)); | |
| 91 | + assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], { | |
| 92 | + label: "Copy", | |
| 93 | + enabled: true, | |
| 94 | + click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, | |
| 95 | + }); | |
| 96 | + }).pipe(Effect.provide(ElectronMenu.layer)), | |
| 97 | + ); | |
| 98 | + | |
| 99 | + it.effect("defers popupTemplate side effects until the returned Effect runs", () => | |
| 100 | + Effect.gen(function* () { | |
| 101 | + const popupMock = vi.fn(); | |
| 102 | + buildFromTemplateMock.mockImplementation(() => ({ popup: popupMock })); | |
| 103 | + | |
| 104 | + const electronMenu = yield* ElectronMenu.ElectronMenu; | |
| 105 | + const popup = electronMenu.popupTemplate({ | |
| 106 | + window: {} as Electron.BrowserWindow, | |
| 107 | + template: [{ label: "Copy" }], | |
| 108 | + }); | |
| 109 | + | |
| 110 | + assert.equal(buildFromTemplateMock.mock.calls.length, 0); | |
| 111 | + assert.equal(popupMock.mock.calls.length, 0); | |
| 112 | + | |
| 113 | + yield* popup; | |
| 114 | + | |
| 115 | + assert.equal(buildFromTemplateMock.mock.calls.length, 1); | |
| 116 | + assert.equal(popupMock.mock.calls.length, 1); | |
| 117 | + }).pipe(Effect.provide(ElectronMenu.layer)), | |
| 118 | + ); | |
| 119 | +}); | |
| @@ -0,0 +1,181 @@ | ||
| 1 | +import type { ContextMenuItem } from "@t3tools/contracts"; | |
| 2 | +import * as Context from "effect/Context"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Layer from "effect/Layer"; | |
| 5 | +import * as Option from "effect/Option"; | |
| 6 | + | |
| 7 | +import * as Electron from "electron"; | |
| 8 | + | |
| 9 | +export interface ElectronMenuPosition { | |
| 10 | + readonly x: number; | |
| 11 | + readonly y: number; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export interface ElectronMenuContextInput { | |
| 15 | + readonly window: Electron.BrowserWindow; | |
| 16 | + readonly items: readonly ContextMenuItem[]; | |
| 17 | + readonly position: Option.Option<ElectronMenuPosition>; | |
| 18 | +} | |
| 19 | + | |
| 20 | +export interface ElectronMenuTemplateInput { | |
| 21 | + readonly window: Electron.BrowserWindow; | |
| 22 | + readonly template: readonly Electron.MenuItemConstructorOptions[]; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export interface ElectronMenuShape { | |
| 26 | + readonly setApplicationMenu: ( | |
| 27 | + template: readonly Electron.MenuItemConstructorOptions[], | |
| 28 | + ) => Effect.Effect<void>; | |
| 29 | + readonly showContextMenu: ( | |
| 30 | + input: ElectronMenuContextInput, | |
| 31 | + ) => Effect.Effect<Option.Option<string>>; | |
| 32 | + readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect<void>; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export class ElectronMenu extends Context.Service<ElectronMenu, ElectronMenuShape>()( | |
| 36 | + "t3/desktop/electron/Menu", | |
| 37 | +) {} | |
| 38 | + | |
| 39 | +function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { | |
| 40 | + const normalizedItems: ContextMenuItem[] = []; | |
| 41 | + | |
| 42 | + for (const sourceItem of source) { | |
| 43 | + if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { | |
| 44 | + continue; | |
| 45 | + } | |
| 46 | + | |
| 47 | + const normalizedItem: ContextMenuItem = { | |
| 48 | + id: sourceItem.id, | |
| 49 | + label: sourceItem.label, | |
| 50 | + destructive: sourceItem.destructive === true, | |
| 51 | + disabled: sourceItem.disabled === true, | |
| 52 | + }; | |
| 53 | + | |
| 54 | + if (sourceItem.children) { | |
| 55 | + const normalizedChildren = normalizeContextMenuItems(sourceItem.children); | |
| 56 | + if (normalizedChildren.length === 0) { | |
| 57 | + continue; | |
| 58 | + } | |
| 59 | + normalizedItem.children = normalizedChildren; | |
| 60 | + } | |
| 61 | + | |
| 62 | + normalizedItems.push(normalizedItem); | |
| 63 | + } | |
| 64 | + | |
| 65 | + return normalizedItems; | |
| 66 | +} | |
| 67 | + | |
| 68 | +const normalizePosition = ( | |
| 69 | + position: Option.Option<ElectronMenuPosition>, | |
| 70 | +): Option.Option<ElectronMenuPosition> => | |
| 71 | + Option.filter( | |
| 72 | + position, | |
| 73 | + ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, | |
| 74 | + ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); | |
| 75 | + | |
| 76 | +export const layer = Layer.sync(ElectronMenu, () => { | |
| 77 | + let destructiveMenuIconCache: Option.Option<Electron.NativeImage> | undefined; | |
| 78 | + | |
| 79 | + const getDestructiveMenuIcon = (): Option.Option<Electron.NativeImage> => { | |
| 80 | + if (process.platform !== "darwin") { | |
| 81 | + return Option.none(); | |
| 82 | + } | |
| 83 | + if (destructiveMenuIconCache !== undefined) { | |
| 84 | + return destructiveMenuIconCache; | |
| 85 | + } | |
| 86 | + | |
| 87 | + try { | |
| 88 | + const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ | |
| 89 | + width: 12, | |
| 90 | + height: 12, | |
| 91 | + }); | |
| 92 | + destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); | |
| 93 | + } catch { | |
| 94 | + destructiveMenuIconCache = Option.none(); | |
| 95 | + } | |
| 96 | + | |
| 97 | + return destructiveMenuIconCache; | |
| 98 | + }; | |
| 99 | + | |
| 100 | + const buildTemplate = ( | |
| 101 | + entries: readonly ContextMenuItem[], | |
| 102 | + complete: (selectedItemId: Option.Option<string>) => void, | |
| 103 | + ): Electron.MenuItemConstructorOptions[] => { | |
| 104 | + const template: Electron.MenuItemConstructorOptions[] = []; | |
| 105 | + let hasInsertedDestructiveSeparator = false; | |
| 106 | + | |
| 107 | + for (const item of entries) { | |
| 108 | + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { | |
| 109 | + template.push({ type: "separator" }); | |
| 110 | + hasInsertedDestructiveSeparator = true; | |
| 111 | + } | |
| 112 | + | |
| 113 | + const itemOption: Electron.MenuItemConstructorOptions = { | |
| 114 | + label: item.label, | |
| 115 | + enabled: !item.disabled, | |
| 116 | + }; | |
| 117 | + if (item.children && item.children.length > 0) { | |
| 118 | + itemOption.submenu = buildTemplate(item.children, complete); | |
| 119 | + } else { | |
| 120 | + itemOption.click = () => complete(Option.some(item.id)); | |
| 121 | + } | |
| 122 | + if (item.destructive && (!item.children || item.children.length === 0)) { | |
| 123 | + const destructiveIcon = getDestructiveMenuIcon(); | |
| 124 | + if (Option.isSome(destructiveIcon)) { | |
| 125 | + itemOption.icon = destructiveIcon.value; | |
| 126 | + } | |
| 127 | + } | |
| 128 | + | |
| 129 | + template.push(itemOption); | |
| 130 | + } | |
| 131 | + | |
| 132 | + return template; | |
| 133 | + }; | |
| 134 | + | |
| 135 | + return ElectronMenu.of({ | |
| 136 | + setApplicationMenu: (template) => | |
| 137 | + Effect.sync(() => { | |
| 138 | + Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); | |
| 139 | + }), | |
| 140 | + popupTemplate: (input) => | |
| 141 | + Effect.sync(() => { | |
| 142 | + if (input.template.length === 0) { | |
| 143 | + return; | |
| 144 | + } | |
| 145 | + Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); | |
| 146 | + }), | |
| 147 | + showContextMenu: (input) => | |
| 148 | + Effect.callback<Option.Option<string>>((resume) => { | |
| 149 | + const normalizedItems = normalizeContextMenuItems(input.items); | |
| 150 | + if (normalizedItems.length === 0) { | |
| 151 | + resume(Effect.succeed(Option.none())); | |
| 152 | + return; | |
| 153 | + } | |
| 154 | + | |
| 155 | + let completed = false; | |
| 156 | + const complete = (selectedItemId: Option.Option<string>) => { | |
| 157 | + if (completed) { | |
| 158 | + return; | |
| 159 | + } | |
| 160 | + completed = true; | |
| 161 | + resume(Effect.succeed(selectedItemId)); | |
| 162 | + }; | |
| 163 | + | |
| 164 | + const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); | |
| 165 | + const popupPosition = normalizePosition(input.position); | |
| 166 | + const popupOptions = Option.match(popupPosition, { | |
| 167 | + onNone: (): Electron.PopupOptions => ({ | |
| 168 | + window: input.window, | |
| 169 | + callback: () => complete(Option.none()), | |
| 170 | + }), | |
| 171 | + onSome: (position): Electron.PopupOptions => ({ | |
| 172 | + window: input.window, | |
| 173 | + x: position.x, | |
| 174 | + y: position.y, | |
| 175 | + callback: () => complete(Option.none()), | |
| 176 | + }), | |
| 177 | + }); | |
| 178 | + menu.popup(popupOptions); | |
| 179 | + }), | |
| 180 | + }); | |
| 181 | +}); | |
| @@ -0,0 +1,105 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Layer from "effect/Layer"; | |
| 4 | +import * as Option from "effect/Option"; | |
| 5 | +import type * as Electron from "electron"; | |
| 6 | +import { beforeEach, vi } from "vitest"; | |
| 7 | + | |
| 8 | +const { registerFileProtocolMock, registerSchemesAsPrivilegedMock, unregisterProtocolMock } = | |
| 9 | + vi.hoisted(() => ({ | |
| 10 | + registerFileProtocolMock: vi.fn(), | |
| 11 | + registerSchemesAsPrivilegedMock: vi.fn(), | |
| 12 | + unregisterProtocolMock: vi.fn(), | |
| 13 | + })); | |
| 14 | + | |
| 15 | +vi.mock("electron", () => ({ | |
| 16 | + protocol: { | |
| 17 | + registerFileProtocol: registerFileProtocolMock, | |
| 18 | + registerSchemesAsPrivileged: registerSchemesAsPrivilegedMock, | |
| 19 | + unregisterProtocol: unregisterProtocolMock, | |
| 20 | + }, | |
| 21 | +})); | |
| 22 | + | |
| 23 | +import * as ElectronProtocol from "./ElectronProtocol.ts"; | |
| 24 | + | |
| 25 | +describe("ElectronProtocol", () => { | |
| 26 | + beforeEach(() => { | |
| 27 | + registerFileProtocolMock.mockReset(); | |
| 28 | + registerSchemesAsPrivilegedMock.mockReset(); | |
| 29 | + unregisterProtocolMock.mockReset(); | |
| 30 | + }); | |
| 31 | + | |
| 32 | + it("normalizes safe desktop protocol pathnames", () => { | |
| 33 | + assert.equal( | |
| 34 | + Option.getOrNull(ElectronProtocol.normalizeDesktopProtocolPathname("/settings/./general")), | |
| 35 | + "settings/general", | |
| 36 | + ); | |
| 37 | + assert.isTrue(Option.isNone(ElectronProtocol.normalizeDesktopProtocolPathname("/../secret"))); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + it.effect("registers desktop scheme privileges through a layer", () => | |
| 41 | + Effect.scoped( | |
| 42 | + Layer.build(ElectronProtocol.layerSchemePrivileges).pipe( | |
| 43 | + Effect.andThen( | |
| 44 | + Effect.sync(() => { | |
| 45 | + assert.deepEqual(registerSchemesAsPrivilegedMock.mock.calls, [ | |
| 46 | + [ | |
| 47 | + [ | |
| 48 | + { | |
| 49 | + scheme: "t3", | |
| 50 | + privileges: { | |
| 51 | + standard: true, | |
| 52 | + secure: true, | |
| 53 | + supportFetchAPI: true, | |
| 54 | + corsEnabled: true, | |
| 55 | + }, | |
| 56 | + }, | |
| 57 | + ], | |
| 58 | + ], | |
| 59 | + ]); | |
| 60 | + }), | |
| 61 | + ), | |
| 62 | + ), | |
| 63 | + ), | |
| 64 | + ); | |
| 65 | + | |
| 66 | + it.effect("scopes registered file protocols", () => | |
| 67 | + Effect.gen(function* () { | |
| 68 | + let capturedHandler: | |
| 69 | + | (( | |
| 70 | + request: Electron.ProtocolRequest, | |
| 71 | + callback: (response: Electron.ProtocolResponse) => void, | |
| 72 | + ) => void) | |
| 73 | + | undefined; | |
| 74 | + | |
| 75 | + registerFileProtocolMock.mockImplementation((_scheme, handler) => { | |
| 76 | + capturedHandler = handler; | |
| 77 | + return true; | |
| 78 | + }); | |
| 79 | + | |
| 80 | + const response = yield* Effect.scoped( | |
| 81 | + Effect.gen(function* () { | |
| 82 | + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; | |
| 83 | + yield* electronProtocol.registerFileProtocol({ | |
| 84 | + scheme: "t3", | |
| 85 | + handler: () => Effect.succeed({ path: "/app/index.html" }), | |
| 86 | + }); | |
| 87 | + | |
| 88 | + assert.isDefined(capturedHandler); | |
| 89 | + return yield* Effect.callback<Electron.ProtocolResponse>((resume) => { | |
| 90 | + capturedHandler?.({ url: "t3://app/" } as Electron.ProtocolRequest, (response) => | |
| 91 | + resume(Effect.succeed(response)), | |
| 92 | + ); | |
| 93 | + }); | |
| 94 | + }), | |
| 95 | + ); | |
| 96 | + | |
| 97 | + assert.deepEqual(response, { path: "/app/index.html" }); | |
| 98 | + assert.deepEqual( | |
| 99 | + registerFileProtocolMock.mock.calls.map((call) => call[0]), | |
| 100 | + ["t3"], | |
| 101 | + ); | |
| 102 | + assert.deepEqual(unregisterProtocolMock.mock.calls, [["t3"]]); | |
| 103 | + }).pipe(Effect.provide(ElectronProtocol.layer)), | |
| 104 | + ); | |
| 105 | +}); | |
| @@ -0,0 +1,272 @@ | ||
| 1 | +import * as Cause from "effect/Cause"; | |
| 2 | +import * as Context from "effect/Context"; | |
| 3 | +import * as Data from "effect/Data"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 5 | +import * as FileSystem from "effect/FileSystem"; | |
| 6 | +import * as Layer from "effect/Layer"; | |
| 7 | +import * as Option from "effect/Option"; | |
| 8 | +import * as Ref from "effect/Ref"; | |
| 9 | +import * as Scope from "effect/Scope"; | |
| 10 | + | |
| 11 | +import * as Electron from "electron"; | |
| 12 | + | |
| 13 | +import { DesktopEnvironment, type DesktopEnvironmentShape } from "../app/DesktopEnvironment.ts"; | |
| 14 | + | |
| 15 | +export const DESKTOP_SCHEME = "t3"; | |
| 16 | + | |
| 17 | +export class ElectronProtocolRegistrationError extends Data.TaggedError( | |
| 18 | + "ElectronProtocolRegistrationError", | |
| 19 | +)<{ | |
| 20 | + readonly scheme: string; | |
| 21 | + readonly cause: unknown; | |
| 22 | +}> { | |
| 23 | + override get message() { | |
| 24 | + return `Failed to register ${this.scheme}: file protocol.`; | |
| 25 | + } | |
| 26 | +} | |
| 27 | + | |
| 28 | +export class ElectronProtocolStaticBundleMissingError extends Data.TaggedError( | |
| 29 | + "ElectronProtocolStaticBundleMissingError", | |
| 30 | +)<{}> { | |
| 31 | + override get message() { | |
| 32 | + return "Desktop static bundle missing. Build apps/server (with bundled client) first."; | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +export interface ElectronProtocolShape { | |
| 37 | + readonly registerFileProtocol: <E, R>(input: { | |
| 38 | + readonly scheme: string; | |
| 39 | + readonly handler: ( | |
| 40 | + request: Electron.ProtocolRequest, | |
| 41 | + ) => Effect.Effect<Electron.ProtocolResponse, E, R>; | |
| 42 | + readonly onFailure?: ( | |
| 43 | + request: Electron.ProtocolRequest, | |
| 44 | + cause: Cause.Cause<E>, | |
| 45 | + ) => Electron.ProtocolResponse; | |
| 46 | + }) => Effect.Effect<void, ElectronProtocolRegistrationError, R | Scope.Scope>; | |
| 47 | + readonly registerDesktopFileProtocol: Effect.Effect< | |
| 48 | + void, | |
| 49 | + ElectronProtocolRegistrationError | ElectronProtocolStaticBundleMissingError, | |
| 50 | + FileSystem.FileSystem | DesktopEnvironment | Scope.Scope | |
| 51 | + >; | |
| 52 | +} | |
| 53 | + | |
| 54 | +export class ElectronProtocol extends Context.Service<ElectronProtocol, ElectronProtocolShape>()( | |
| 55 | + "t3/desktop/electron/Protocol", | |
| 56 | +) {} | |
| 57 | + | |
| 58 | +export function normalizeDesktopProtocolPathname(rawPath: string): Option.Option<string> { | |
| 59 | + const segments: string[] = []; | |
| 60 | + for (const segment of rawPath.split("/")) { | |
| 61 | + if (segment.length === 0 || segment === ".") { | |
| 62 | + continue; | |
| 63 | + } | |
| 64 | + if (segment === "..") { | |
| 65 | + return Option.none(); | |
| 66 | + } | |
| 67 | + segments.push(segment); | |
| 68 | + } | |
| 69 | + return Option.some(segments.join("/")); | |
| 70 | +} | |
| 71 | + | |
| 72 | +const registerDesktopSchemePrivileges = Effect.sync(() => { | |
| 73 | + Electron.protocol.registerSchemesAsPrivileged([ | |
| 74 | + { | |
| 75 | + scheme: DESKTOP_SCHEME, | |
| 76 | + privileges: { | |
| 77 | + standard: true, | |
| 78 | + secure: true, | |
| 79 | + supportFetchAPI: true, | |
| 80 | + corsEnabled: true, | |
| 81 | + }, | |
| 82 | + }, | |
| 83 | + ]); | |
| 84 | +}).pipe(Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges")); | |
| 85 | + | |
| 86 | +export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges); | |
| 87 | + | |
| 88 | +const resolveDesktopStaticDir: Effect.Effect< | |
| 89 | + Option.Option<string>, | |
| 90 | + never, | |
| 91 | + FileSystem.FileSystem | DesktopEnvironment | |
| 92 | +> = Effect.gen(function* () { | |
| 93 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 94 | + const environment = yield* DesktopEnvironment; | |
| 95 | + const candidates = [ | |
| 96 | + environment.path.join(environment.appRoot, "apps/server/dist/client"), | |
| 97 | + environment.path.join(environment.appRoot, "apps/web/dist"), | |
| 98 | + ]; | |
| 99 | + for (const candidate of candidates) { | |
| 100 | + const hasIndex = yield* fileSystem | |
| 101 | + .exists(environment.path.join(candidate, "index.html")) | |
| 102 | + .pipe(Effect.orElseSucceed(() => false)); | |
| 103 | + if (hasIndex) { | |
| 104 | + return Option.some(candidate); | |
| 105 | + } | |
| 106 | + } | |
| 107 | + return Option.none<string>(); | |
| 108 | +}); | |
| 109 | + | |
| 110 | +const resolveDesktopStaticPath = Effect.fn("desktop.electron.protocol.resolveDesktopStaticPath")( | |
| 111 | + function* ( | |
| 112 | + staticRoot: string, | |
| 113 | + requestUrl: string, | |
| 114 | + ): Effect.fn.Return<string, never, FileSystem.FileSystem | DesktopEnvironment> { | |
| 115 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 116 | + const environment = yield* DesktopEnvironment; | |
| 117 | + const url = new URL(requestUrl); | |
| 118 | + const rawPath = decodeURIComponent(url.pathname); | |
| 119 | + const normalizedPath = normalizeDesktopProtocolPathname(rawPath); | |
| 120 | + if (Option.isNone(normalizedPath)) { | |
| 121 | + return environment.path.join(staticRoot, "index.html"); | |
| 122 | + } | |
| 123 | + | |
| 124 | + const requestedPath = normalizedPath.value.length > 0 ? normalizedPath.value : "index.html"; | |
| 125 | + const resolvedPath = environment.path.join(staticRoot, requestedPath); | |
| 126 | + | |
| 127 | + if (environment.path.extname(resolvedPath)) { | |
| 128 | + return resolvedPath; | |
| 129 | + } | |
| 130 | + | |
| 131 | + const nestedIndex = environment.path.join(resolvedPath, "index.html"); | |
| 132 | + const nestedIndexExists = yield* fileSystem | |
| 133 | + .exists(nestedIndex) | |
| 134 | + .pipe(Effect.orElseSucceed(() => false)); | |
| 135 | + if (nestedIndexExists) { | |
| 136 | + return nestedIndex; | |
| 137 | + } | |
| 138 | + | |
| 139 | + return environment.path.join(staticRoot, "index.html"); | |
| 140 | + }, | |
| 141 | +); | |
| 142 | + | |
| 143 | +function isStaticAssetRequest(requestUrl: string, environment: DesktopEnvironmentShape): boolean { | |
| 144 | + try { | |
| 145 | + const url = new URL(requestUrl); | |
| 146 | + return environment.path.extname(url.pathname).length > 0; | |
| 147 | + } catch { | |
| 148 | + return false; | |
| 149 | + } | |
| 150 | +} | |
| 151 | + | |
| 152 | +const make = Effect.gen(function* () { | |
| 153 | + const registeredProtocols = yield* Ref.make<ReadonlySet<string>>(new Set()); | |
| 154 | + | |
| 155 | + const registerFileProtocol = Effect.fn("desktop.electron.protocol.registerFileProtocol")( | |
| 156 | + function* <E, R>({ | |
| 157 | + scheme, | |
| 158 | + handler, | |
| 159 | + onFailure, | |
| 160 | + }: { | |
| 161 | + readonly scheme: string; | |
| 162 | + readonly handler: ( | |
| 163 | + request: Electron.ProtocolRequest, | |
| 164 | + ) => Effect.Effect<Electron.ProtocolResponse, E, R>; | |
| 165 | + readonly onFailure?: ( | |
| 166 | + request: Electron.ProtocolRequest, | |
| 167 | + cause: Cause.Cause<E>, | |
| 168 | + ) => Electron.ProtocolResponse; | |
| 169 | + }): Effect.fn.Return<void, ElectronProtocolRegistrationError, R | Scope.Scope> { | |
| 170 | + yield* Effect.annotateCurrentSpan({ scheme }); | |
| 171 | + const alreadyRegistered = yield* Ref.get(registeredProtocols).pipe( | |
| 172 | + Effect.map((protocols) => protocols.has(scheme)), | |
| 173 | + ); | |
| 174 | + if (alreadyRegistered) { | |
| 175 | + return; | |
| 176 | + } | |
| 177 | + | |
| 178 | + const context = yield* Effect.context<R>(); | |
| 179 | + const runPromise = Effect.runPromiseWith(context); | |
| 180 | + | |
| 181 | + yield* Effect.acquireRelease( | |
| 182 | + Effect.try({ | |
| 183 | + try: () => { | |
| 184 | + const registered = Electron.protocol.registerFileProtocol( | |
| 185 | + scheme, | |
| 186 | + (request, callback) => { | |
| 187 | + const response = handler(request).pipe( | |
| 188 | + Effect.withSpan("desktop.electron.protocol.handleFileRequest"), | |
| 189 | + Effect.catchCause((cause) => | |
| 190 | + Effect.succeed(onFailure?.(request, cause) ?? ({ error: -2 } as const)), | |
| 191 | + ), | |
| 192 | + ); | |
| 193 | + | |
| 194 | + void runPromise(response).then(callback, () => callback({ error: -2 })); | |
| 195 | + }, | |
| 196 | + ); | |
| 197 | + if (!registered) { | |
| 198 | + throw new ElectronProtocolRegistrationError({ | |
| 199 | + scheme, | |
| 200 | + cause: "registerFileProtocol returned false", | |
| 201 | + }); | |
| 202 | + } | |
| 203 | + }, | |
| 204 | + catch: (cause) => | |
| 205 | + cause instanceof ElectronProtocolRegistrationError | |
| 206 | + ? cause | |
| 207 | + : new ElectronProtocolRegistrationError({ scheme, cause }), | |
| 208 | + }).pipe( | |
| 209 | + Effect.andThen( | |
| 210 | + Ref.update(registeredProtocols, (protocols) => new Set(protocols).add(scheme)), | |
| 211 | + ), | |
| 212 | + ), | |
| 213 | + () => | |
| 214 | + Effect.sync(() => { | |
| 215 | + Electron.protocol.unregisterProtocol(scheme); | |
| 216 | + }).pipe( | |
| 217 | + Effect.andThen( | |
| 218 | + Ref.update(registeredProtocols, (protocols) => { | |
| 219 | + const next = new Set(protocols); | |
| 220 | + next.delete(scheme); | |
| 221 | + return next; | |
| 222 | + }), | |
| 223 | + ), | |
| 224 | + ), | |
| 225 | + ); | |
| 226 | + }, | |
| 227 | + ); | |
| 228 | + | |
| 229 | + const registerDesktopFileProtocol = Effect.gen(function* () { | |
| 230 | + const environment = yield* DesktopEnvironment; | |
| 231 | + if (environment.isDevelopment) return; | |
| 232 | + | |
| 233 | + const staticRoot = yield* resolveDesktopStaticDir; | |
| 234 | + if (Option.isNone(staticRoot)) { | |
| 235 | + return yield* new ElectronProtocolStaticBundleMissingError(); | |
| 236 | + } | |
| 237 | + | |
| 238 | + const staticRootResolved = environment.path.resolve(staticRoot.value); | |
| 239 | + const staticRootPrefix = `${staticRootResolved}${environment.path.sep}`; | |
| 240 | + const fallbackIndex = environment.path.join(staticRootResolved, "index.html"); | |
| 241 | + | |
| 242 | + yield* registerFileProtocol({ | |
| 243 | + scheme: DESKTOP_SCHEME, | |
| 244 | + handler: Effect.fn("desktop.electron.protocol.handleDesktopFileRequest")(function* (request) { | |
| 245 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 246 | + const environment = yield* DesktopEnvironment; | |
| 247 | + const candidate = yield* resolveDesktopStaticPath(staticRootResolved, request.url); | |
| 248 | + const resolvedCandidate = environment.path.resolve(candidate); | |
| 249 | + const isInRoot = | |
| 250 | + resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); | |
| 251 | + const isAssetRequest = isStaticAssetRequest(request.url, environment); | |
| 252 | + const exists = yield* fileSystem | |
| 253 | + .exists(resolvedCandidate) | |
| 254 | + .pipe(Effect.orElseSucceed(() => false)); | |
| 255 | + | |
| 256 | + if (!isInRoot || !exists) { | |
| 257 | + return isAssetRequest ? ({ error: -6 } as const) : ({ path: fallbackIndex } as const); | |
| 258 | + } | |
| 259 | + | |
| 260 | + return { path: resolvedCandidate } as const; | |
| 261 | + }), | |
| 262 | + onFailure: () => ({ path: fallbackIndex }), | |
| 263 | + }); | |
| 264 | + }).pipe(Effect.withSpan("desktop.electron.protocol.registerDesktopFileProtocol")); | |
| 265 | + | |
| 266 | + return ElectronProtocol.of({ | |
| 267 | + registerFileProtocol, | |
| 268 | + registerDesktopFileProtocol, | |
| 269 | + }); | |
| 270 | +}); | |
| 271 | + | |
| 272 | +export const layer = Layer.effect(ElectronProtocol, make); | |
| @@ -0,0 +1,70 @@ | ||
| 1 | +import * as Context from "effect/Context"; | |
| 2 | +import * as Data from "effect/Data"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Layer from "effect/Layer"; | |
| 5 | + | |
| 6 | +import * as Electron from "electron"; | |
| 7 | + | |
| 8 | +export class ElectronSafeStorageAvailabilityError extends Data.TaggedError( | |
| 9 | + "ElectronSafeStorageAvailabilityError", | |
| 10 | +)<{ | |
| 11 | + readonly cause: unknown; | |
| 12 | +}> { | |
| 13 | + override get message() { | |
| 14 | + return "Electron safe storage failed to check encryption availability."; | |
| 15 | + } | |
| 16 | +} | |
| 17 | + | |
| 18 | +export class ElectronSafeStorageEncryptError extends Data.TaggedError( | |
| 19 | + "ElectronSafeStorageEncryptError", | |
| 20 | +)<{ | |
| 21 | + readonly cause: unknown; | |
| 22 | +}> { | |
| 23 | + override get message() { | |
| 24 | + return "Electron safe storage failed to encrypt a string."; | |
| 25 | + } | |
| 26 | +} | |
| 27 | + | |
| 28 | +export class ElectronSafeStorageDecryptError extends Data.TaggedError( | |
| 29 | + "ElectronSafeStorageDecryptError", | |
| 30 | +)<{ | |
| 31 | + readonly cause: unknown; | |
| 32 | +}> { | |
| 33 | + override get message() { | |
| 34 | + return "Electron safe storage failed to decrypt a string."; | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +export interface ElectronSafeStorageShape { | |
| 39 | + readonly isEncryptionAvailable: Effect.Effect<boolean, ElectronSafeStorageAvailabilityError>; | |
| 40 | + readonly encryptString: ( | |
| 41 | + value: string, | |
| 42 | + ) => Effect.Effect<Uint8Array, ElectronSafeStorageEncryptError>; | |
| 43 | + readonly decryptString: ( | |
| 44 | + value: Uint8Array, | |
| 45 | + ) => Effect.Effect<string, ElectronSafeStorageDecryptError>; | |
| 46 | +} | |
| 47 | + | |
| 48 | +export class ElectronSafeStorage extends Context.Service< | |
| 49 | + ElectronSafeStorage, | |
| 50 | + ElectronSafeStorageShape | |
| 51 | +>()("@t3tools/desktop/ElectronSafeStorage") {} | |
| 52 | + | |
| 53 | +const make = ElectronSafeStorage.of({ | |
| 54 | + isEncryptionAvailable: Effect.try({ | |
| 55 | + try: () => Electron.safeStorage.isEncryptionAvailable(), | |
| 56 | + catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), | |
| 57 | + }), | |
| 58 | + encryptString: (value) => | |
| 59 | + Effect.try({ | |
| 60 | + try: () => Electron.safeStorage.encryptString(value), | |
| 61 | + catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), | |
| 62 | + }), | |
| 63 | + decryptString: (value) => | |
| 64 | + Effect.try({ | |
| 65 | + try: () => Electron.safeStorage.decryptString(Buffer.from(value)), | |
| 66 | + catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), | |
| 67 | + }), | |
| 68 | +}); | |
| 69 | + | |
| 70 | +export const layer = Layer.succeed(ElectronSafeStorage, make); | |
| @@ -0,0 +1,59 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import { beforeEach, vi } from "vitest"; | |
| 4 | + | |
| 5 | +const { openExternalMock, writeTextMock } = vi.hoisted(() => ({ | |
| 6 | + openExternalMock: vi.fn(), | |
| 7 | + writeTextMock: vi.fn(), | |
| 8 | +})); | |
| 9 | + | |
| 10 | +vi.mock("electron", () => ({ | |
| 11 | + shell: { | |
| 12 | + openExternal: openExternalMock, | |
| 13 | + }, | |
| 14 | + clipboard: { | |
| 15 | + writeText: writeTextMock, | |
| 16 | + }, | |
| 17 | +})); | |
| 18 | + | |
| 19 | +import * as ElectronShell from "./ElectronShell.ts"; | |
| 20 | + | |
| 21 | +describe("ElectronShell", () => { | |
| 22 | + beforeEach(() => { | |
| 23 | + openExternalMock.mockReset(); | |
| 24 | + writeTextMock.mockReset(); | |
| 25 | + }); | |
| 26 | + | |
| 27 | + it.effect("opens safe external URLs", () => | |
| 28 | + Effect.gen(function* () { | |
| 29 | + openExternalMock.mockResolvedValue(undefined); | |
| 30 | + | |
| 31 | + const electronShell = yield* ElectronShell.ElectronShell; | |
| 32 | + const result = yield* electronShell.openExternal("https://example.com/path"); | |
| 33 | + | |
| 34 | + assert.equal(result, true); | |
| 35 | + assert.deepEqual(openExternalMock.mock.calls, [["https://example.com/path"]]); | |
| 36 | + }).pipe(Effect.provide(ElectronShell.layer)), | |
| 37 | + ); | |
| 38 | + | |
| 39 | + it.effect("does not open unsafe external URLs", () => | |
| 40 | + Effect.gen(function* () { | |
| 41 | + const electronShell = yield* ElectronShell.ElectronShell; | |
| 42 | + const result = yield* electronShell.openExternal("file:///etc/passwd"); | |
| 43 | + | |
| 44 | + assert.equal(result, false); | |
| 45 | + assert.equal(openExternalMock.mock.calls.length, 0); | |
| 46 | + }).pipe(Effect.provide(ElectronShell.layer)), | |
| 47 | + ); | |
| 48 | + | |
| 49 | + it.effect("returns false when Electron rejects openExternal", () => | |
| 50 | + Effect.gen(function* () { | |
| 51 | + openExternalMock.mockRejectedValue(new Error("open failed")); | |
| 52 | + | |
| 53 | + const electronShell = yield* ElectronShell.ElectronShell; | |
| 54 | + const result = yield* electronShell.openExternal("https://example.com/path"); | |
| 55 | + | |
| 56 | + assert.equal(result, false); | |
| 57 | + }).pipe(Effect.provide(ElectronShell.layer)), | |
| 58 | + ); | |
| 59 | +}); | |
| @@ -0,0 +1,50 @@ | ||
| 1 | +import * as Context from "effect/Context"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Layer from "effect/Layer"; | |
| 4 | +import * as Option from "effect/Option"; | |
| 5 | + | |
| 6 | +import * as Electron from "electron"; | |
| 7 | + | |
| 8 | +const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); | |
| 9 | + | |
| 10 | +export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> { | |
| 11 | + if (typeof rawUrl !== "string") { | |
| 12 | + return Option.none(); | |
| 13 | + } | |
| 14 | + | |
| 15 | + try { | |
| 16 | + const url = new URL(rawUrl); | |
| 17 | + return SAFE_EXTERNAL_PROTOCOLS.has(url.protocol) ? Option.some(url.href) : Option.none(); | |
| 18 | + } catch { | |
| 19 | + return Option.none(); | |
| 20 | + } | |
| 21 | +} | |
| 22 | + | |
| 23 | +export interface ElectronShellShape { | |
| 24 | + readonly openExternal: (rawUrl: unknown) => Effect.Effect<boolean>; | |
| 25 | + readonly copyText: (text: string) => Effect.Effect<void>; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export class ElectronShell extends Context.Service<ElectronShell, ElectronShellShape>()( | |
| 29 | + "t3/desktop/electron/Shell", | |
| 30 | +) {} | |
| 31 | + | |
| 32 | +const make = ElectronShell.of({ | |
| 33 | + openExternal: (rawUrl) => | |
| 34 | + Option.match(parseSafeExternalUrl(rawUrl), { | |
| 35 | + onNone: () => Effect.succeed(false), | |
| 36 | + onSome: (externalUrl) => | |
| 37 | + Effect.promise(() => | |
| 38 | + Electron.shell.openExternal(externalUrl).then( | |
| 39 | + () => true, | |
| 40 | + () => false, | |
| 41 | + ), | |
| 42 | + ), | |
| 43 | + }), | |
| 44 | + copyText: (text) => | |
| 45 | + Effect.sync(() => { | |
| 46 | + Electron.clipboard.writeText(text); | |
| 47 | + }), | |
| 48 | +}); | |
| 49 | + | |
| 50 | +export const layer = Layer.succeed(ElectronShell, make); | |
| @@ -0,0 +1,52 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import { beforeEach, vi } from "vitest"; | |
| 4 | + | |
| 5 | +const { onMock, removeListenerMock, themeState } = vi.hoisted(() => ({ | |
| 6 | + onMock: vi.fn(), | |
| 7 | + removeListenerMock: vi.fn(), | |
| 8 | + themeState: { | |
| 9 | + shouldUseDarkColors: true, | |
| 10 | + themeSource: "system", | |
| 11 | + }, | |
| 12 | +})); | |
| 13 | + | |
| 14 | +vi.mock("electron", () => ({ | |
| 15 | + nativeTheme: { | |
| 16 | + get shouldUseDarkColors() { | |
| 17 | + return themeState.shouldUseDarkColors; | |
| 18 | + }, | |
| 19 | + set themeSource(value: string) { | |
| 20 | + themeState.themeSource = value; | |
| 21 | + }, | |
| 22 | + on: onMock, | |
| 23 | + removeListener: removeListenerMock, | |
| 24 | + }, | |
| 25 | +})); | |
| 26 | + | |
| 27 | +import * as ElectronTheme from "./ElectronTheme.ts"; | |
| 28 | + | |
| 29 | +describe("ElectronTheme", () => { | |
| 30 | + beforeEach(() => { | |
| 31 | + onMock.mockClear(); | |
| 32 | + removeListenerMock.mockClear(); | |
| 33 | + themeState.shouldUseDarkColors = true; | |
| 34 | + themeState.themeSource = "system"; | |
| 35 | + }); | |
| 36 | + | |
| 37 | + it.effect("scopes native theme update listeners", () => | |
| 38 | + Effect.gen(function* () { | |
| 39 | + const listener = vi.fn(); | |
| 40 | + | |
| 41 | + yield* Effect.scoped( | |
| 42 | + Effect.gen(function* () { | |
| 43 | + const electronTheme = yield* ElectronTheme.ElectronTheme; | |
| 44 | + yield* electronTheme.onUpdated(listener); | |
| 45 | + }), | |
| 46 | + ); | |
| 47 | + | |
| 48 | + assert.deepEqual(onMock.mock.calls, [["updated", listener]]); | |
| 49 | + assert.deepEqual(removeListenerMock.mock.calls, [["updated", listener]]); | |
| 50 | + }).pipe(Effect.provide(ElectronTheme.layer)), | |
| 51 | + ); | |
| 52 | +}); | |
| @@ -0,0 +1,40 @@ | ||
| 1 | +import type { DesktopTheme } from "@t3tools/contracts"; | |
| 2 | +import * as Context from "effect/Context"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Layer from "effect/Layer"; | |
| 5 | +import * as Scope from "effect/Scope"; | |
| 6 | + | |
| 7 | +import * as Electron from "electron"; | |
| 8 | + | |
| 9 | +export interface ElectronThemeShape { | |
| 10 | + readonly shouldUseDarkColors: Effect.Effect<boolean>; | |
| 11 | + readonly setSource: (theme: DesktopTheme) => Effect.Effect<void>; | |
| 12 | + readonly onUpdated: (listener: () => void) => Effect.Effect<void, never, Scope.Scope>; | |
| 13 | +} | |
| 14 | + | |
| 15 | +export class ElectronTheme extends Context.Service<ElectronTheme, ElectronThemeShape>()( | |
| 16 | + "t3/desktop/electron/Theme", | |
| 17 | +) {} | |
| 18 | + | |
| 19 | +const make = ElectronTheme.of({ | |
| 20 | + shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), | |
| 21 | + setSource: (theme) => | |
| 22 | + Effect.suspend(() => { | |
| 23 | + Electron.nativeTheme.themeSource = theme; | |
| 24 | + return Effect.void; | |
| 25 | + }), | |
| 26 | + onUpdated: (listener) => | |
| 27 | + Effect.acquireRelease( | |
| 28 | + Effect.suspend(() => { | |
| 29 | + Electron.nativeTheme.on("updated", listener); | |
| 30 | + return Effect.void; | |
| 31 | + }), | |
| 32 | + () => | |
| 33 | + Effect.suspend(() => { | |
| 34 | + Electron.nativeTheme.removeListener("updated", listener); | |
| 35 | + return Effect.void; | |
| 36 | + }), | |
| 37 | + ), | |
| 38 | +}); | |
| 39 | + | |
| 40 | +export const layer = Layer.succeed(ElectronTheme, make); | |
| @@ -0,0 +1,79 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import * as Cause from "effect/Cause"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import { beforeEach, vi } from "vitest"; | |
| 5 | + | |
| 6 | +const { autoUpdaterMock } = vi.hoisted(() => ({ | |
| 7 | + autoUpdaterMock: { | |
| 8 | + allowDowngrade: false, | |
| 9 | + allowPrerelease: false, | |
| 10 | + autoDownload: true, | |
| 11 | + autoInstallOnAppQuit: true, | |
| 12 | + channel: "latest", | |
| 13 | + disableDifferentialDownload: false, | |
| 14 | + checkForUpdates: vi.fn(() => Promise.resolve(null)), | |
| 15 | + downloadUpdate: vi.fn(() => Promise.resolve([])), | |
| 16 | + on: vi.fn(), | |
| 17 | + quitAndInstall: vi.fn(), | |
| 18 | + removeListener: vi.fn(), | |
| 19 | + setFeedURL: vi.fn(), | |
| 20 | + }, | |
| 21 | +})); | |
| 22 | + | |
| 23 | +vi.mock("electron-updater", () => ({ | |
| 24 | + autoUpdater: autoUpdaterMock, | |
| 25 | +})); | |
| 26 | + | |
| 27 | +import * as ElectronUpdater from "./ElectronUpdater.ts"; | |
| 28 | + | |
| 29 | +describe("ElectronUpdater", () => { | |
| 30 | + beforeEach(() => { | |
| 31 | + autoUpdaterMock.allowDowngrade = false; | |
| 32 | + autoUpdaterMock.allowPrerelease = false; | |
| 33 | + autoUpdaterMock.autoDownload = true; | |
| 34 | + autoUpdaterMock.autoInstallOnAppQuit = true; | |
| 35 | + autoUpdaterMock.channel = "latest"; | |
| 36 | + autoUpdaterMock.disableDifferentialDownload = false; | |
| 37 | + autoUpdaterMock.checkForUpdates.mockClear(); | |
| 38 | + autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null)); | |
| 39 | + autoUpdaterMock.downloadUpdate.mockClear(); | |
| 40 | + autoUpdaterMock.downloadUpdate.mockImplementation(() => Promise.resolve([])); | |
| 41 | + autoUpdaterMock.on.mockClear(); | |
| 42 | + autoUpdaterMock.quitAndInstall.mockClear(); | |
| 43 | + autoUpdaterMock.removeListener.mockClear(); | |
| 44 | + autoUpdaterMock.setFeedURL.mockClear(); | |
| 45 | + }); | |
| 46 | + | |
| 47 | + it.effect("scopes updater event listeners", () => | |
| 48 | + Effect.gen(function* () { | |
| 49 | + const listener = vi.fn(); | |
| 50 | + | |
| 51 | + yield* Effect.scoped( | |
| 52 | + Effect.gen(function* () { | |
| 53 | + const updater = yield* ElectronUpdater.ElectronUpdater; | |
| 54 | + yield* updater.on("update-available", listener); | |
| 55 | + }), | |
| 56 | + ); | |
| 57 | + | |
| 58 | + assert.deepEqual(autoUpdaterMock.on.mock.calls, [["update-available", listener]]); | |
| 59 | + assert.deepEqual(autoUpdaterMock.removeListener.mock.calls, [["update-available", listener]]); | |
| 60 | + }).pipe(Effect.provide(ElectronUpdater.layer)), | |
| 61 | + ); | |
| 62 | + | |
| 63 | + it.effect("wraps rejected update checks in the method-specific typed error", () => | |
| 64 | + Effect.gen(function* () { | |
| 65 | + const cause = new Error("network unavailable"); | |
| 66 | + autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => Promise.reject(cause)); | |
| 67 | + const updater = yield* ElectronUpdater.ElectronUpdater; | |
| 68 | + | |
| 69 | + const exit = yield* Effect.exit(updater.checkForUpdates); | |
| 70 | + | |
| 71 | + assert.equal(exit._tag, "Failure"); | |
| 72 | + if (exit._tag === "Failure") { | |
| 73 | + const error = Cause.squash(exit.cause); | |
| 74 | + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); | |
| 75 | + assert.equal(error.cause, cause); | |
| 76 | + } | |
| 77 | + }).pipe(Effect.provide(ElectronUpdater.layer)), | |
| 78 | + ); | |
| 79 | +}); | |
| @@ -0,0 +1,139 @@ | ||
| 1 | +import * as Context from "effect/Context"; | |
| 2 | +import * as Data from "effect/Data"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Layer from "effect/Layer"; | |
| 5 | +import * as Scope from "effect/Scope"; | |
| 6 | + | |
| 7 | +import { autoUpdater } from "electron-updater"; | |
| 8 | + | |
| 9 | +type AutoUpdater = typeof autoUpdater; | |
| 10 | + | |
| 11 | +export type ElectronUpdaterFeedUrl = Parameters<AutoUpdater["setFeedURL"]>[0]; | |
| 12 | + | |
| 13 | +export class ElectronUpdaterCheckForUpdatesError extends Data.TaggedError( | |
| 14 | + "ElectronUpdaterCheckForUpdatesError", | |
| 15 | +)<{ | |
| 16 | + readonly cause: unknown; | |
| 17 | +}> { | |
| 18 | + override get message() { | |
| 19 | + return "Electron updater failed to check for updates."; | |
| 20 | + } | |
| 21 | +} | |
| 22 | + | |
| 23 | +export class ElectronUpdaterDownloadUpdateError extends Data.TaggedError( | |
| 24 | + "ElectronUpdaterDownloadUpdateError", | |
| 25 | +)<{ | |
| 26 | + readonly cause: unknown; | |
| 27 | +}> { | |
| 28 | + override get message() { | |
| 29 | + return "Electron updater failed to download the update."; | |
| 30 | + } | |
| 31 | +} | |
| 32 | + | |
| 33 | +export class ElectronUpdaterQuitAndInstallError extends Data.TaggedError( | |
| 34 | + "ElectronUpdaterQuitAndInstallError", | |
| 35 | +)<{ | |
| 36 | + readonly cause: unknown; | |
| 37 | +}> { | |
| 38 | + override get message() { | |
| 39 | + return "Electron updater failed to quit and install the update."; | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +export type ElectronUpdaterError = | |
| 44 | + | ElectronUpdaterCheckForUpdatesError | |
| 45 | + | ElectronUpdaterDownloadUpdateError | |
| 46 | + | ElectronUpdaterQuitAndInstallError; | |
| 47 | + | |
| 48 | +export interface ElectronUpdaterShape { | |
| 49 | + readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect<void>; | |
| 50 | + readonly setAutoDownload: (value: boolean) => Effect.Effect<void>; | |
| 51 | + readonly setAutoInstallOnAppQuit: (value: boolean) => Effect.Effect<void>; | |
| 52 | + readonly setChannel: (channel: string) => Effect.Effect<void>; | |
| 53 | + readonly setAllowPrerelease: (value: boolean) => Effect.Effect<void>; | |
| 54 | + readonly allowDowngrade: Effect.Effect<boolean>; | |
| 55 | + readonly setAllowDowngrade: (value: boolean) => Effect.Effect<void>; | |
| 56 | + readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect<void>; | |
| 57 | + readonly checkForUpdates: Effect.Effect<void, ElectronUpdaterCheckForUpdatesError>; | |
| 58 | + readonly downloadUpdate: Effect.Effect<void, ElectronUpdaterDownloadUpdateError>; | |
| 59 | + readonly quitAndInstall: (options: { | |
| 60 | + readonly isSilent: boolean; | |
| 61 | + readonly isForceRunAfter: boolean; | |
| 62 | + }) => Effect.Effect<void, ElectronUpdaterQuitAndInstallError>; | |
| 63 | + readonly on: <Args extends ReadonlyArray<unknown>>( | |
| 64 | + eventName: string, | |
| 65 | + listener: (...args: Args) => void, | |
| 66 | + ) => Effect.Effect<void, never, Scope.Scope>; | |
| 67 | +} | |
| 68 | + | |
| 69 | +export class ElectronUpdater extends Context.Service<ElectronUpdater, ElectronUpdaterShape>()( | |
| 70 | + "t3/desktop/electron/Updater", | |
| 71 | +) {} | |
| 72 | + | |
| 73 | +export const layer = Layer.succeed(ElectronUpdater, { | |
| 74 | + setFeedURL: (options) => | |
| 75 | + Effect.suspend(() => { | |
| 76 | + autoUpdater.setFeedURL(options); | |
| 77 | + return Effect.void; | |
| 78 | + }), | |
| 79 | + setAutoDownload: (value) => | |
| 80 | + Effect.suspend(() => { | |
| 81 | + autoUpdater.autoDownload = value; | |
| 82 | + return Effect.void; | |
| 83 | + }), | |
| 84 | + setAutoInstallOnAppQuit: (value) => | |
| 85 | + Effect.suspend(() => { | |
| 86 | + autoUpdater.autoInstallOnAppQuit = value; | |
| 87 | + return Effect.void; | |
| 88 | + }), | |
| 89 | + setChannel: (channel) => | |
| 90 | + Effect.suspend(() => { | |
| 91 | + autoUpdater.channel = channel; | |
| 92 | + return Effect.void; | |
| 93 | + }), | |
| 94 | + setAllowPrerelease: (value) => | |
| 95 | + Effect.suspend(() => { | |
| 96 | + autoUpdater.allowPrerelease = value; | |
| 97 | + return Effect.void; | |
| 98 | + }), | |
| 99 | + allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade), | |
| 100 | + setAllowDowngrade: (value) => | |
| 101 | + Effect.suspend(() => { | |
| 102 | + autoUpdater.allowDowngrade = value; | |
| 103 | + return Effect.void; | |
| 104 | + }), | |
| 105 | + setDisableDifferentialDownload: (value) => | |
| 106 | + Effect.suspend(() => { | |
| 107 | + autoUpdater.disableDifferentialDownload = value; | |
| 108 | + return Effect.void; | |
| 109 | + }), | |
| 110 | + checkForUpdates: Effect.tryPromise({ | |
| 111 | + try: () => autoUpdater.checkForUpdates(), | |
| 112 | + catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ cause }), | |
| 113 | + }).pipe(Effect.asVoid), | |
| 114 | + downloadUpdate: Effect.tryPromise({ | |
| 115 | + try: () => autoUpdater.downloadUpdate(), | |
| 116 | + catch: (cause) => new ElectronUpdaterDownloadUpdateError({ cause }), | |
| 117 | + }).pipe(Effect.asVoid), | |
| 118 | + quitAndInstall: ({ isSilent, isForceRunAfter }) => | |
| 119 | + Effect.try({ | |
| 120 | + try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), | |
| 121 | + catch: (cause) => new ElectronUpdaterQuitAndInstallError({ cause }), | |
| 122 | + }), | |
| 123 | + on: (eventName, listener) => { | |
| 124 | + const eventTarget = autoUpdater as unknown as { | |
| 125 | + on: (eventName: string, listener: (...args: Array<unknown>) => void) => void; | |
| 126 | + removeListener: (eventName: string, listener: (...args: Array<unknown>) => void) => void; | |
| 127 | + }; | |
| 128 | + const untypedListener = listener as unknown as (...args: Array<unknown>) => void; | |
| 129 | + return Effect.acquireRelease( | |
| 130 | + Effect.sync(() => { | |
| 131 | + eventTarget.on(eventName, untypedListener); | |
| 132 | + }), | |
| 133 | + () => | |
| 134 | + Effect.sync(() => { | |
| 135 | + eventTarget.removeListener(eventName, untypedListener); | |
| 136 | + }), | |
| 137 | + ).pipe(Effect.asVoid); | |
| 138 | + }, | |
| 139 | +} satisfies ElectronUpdaterShape); | |
| @@ -0,0 +1,51 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import type * as Electron from "electron"; | |
| 4 | +import { beforeEach, vi } from "vitest"; | |
| 5 | + | |
| 6 | +const { appFocusMock, getAllWindowsMock } = vi.hoisted(() => ({ | |
| 7 | + appFocusMock: vi.fn(), | |
| 8 | + getAllWindowsMock: vi.fn(), | |
| 9 | +})); | |
| 10 | + | |
| 11 | +vi.mock("electron", () => ({ | |
| 12 | + app: { | |
| 13 | + focus: appFocusMock, | |
| 14 | + }, | |
| 15 | + BrowserWindow: { | |
| 16 | + getAllWindows: getAllWindowsMock, | |
| 17 | + }, | |
| 18 | +})); | |
| 19 | + | |
| 20 | +import * as ElectronWindow from "./ElectronWindow.ts"; | |
| 21 | + | |
| 22 | +function makeBrowserWindow(input: { readonly destroyed: boolean }) { | |
| 23 | + return { | |
| 24 | + isDestroyed: vi.fn(() => input.destroyed), | |
| 25 | + } as unknown as Electron.BrowserWindow; | |
| 26 | +} | |
| 27 | + | |
| 28 | +describe("ElectronWindow", () => { | |
| 29 | + beforeEach(() => { | |
| 30 | + appFocusMock.mockReset(); | |
| 31 | + getAllWindowsMock.mockReset(); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it.effect("skips windows destroyed before appearance sync runs", () => | |
| 35 | + Effect.gen(function* () { | |
| 36 | + const liveWindow = makeBrowserWindow({ destroyed: false }); | |
| 37 | + const destroyedWindow = makeBrowserWindow({ destroyed: true }); | |
| 38 | + getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]); | |
| 39 | + | |
| 40 | + const syncedWindows: Electron.BrowserWindow[] = []; | |
| 41 | + const electronWindow = yield* ElectronWindow.ElectronWindow; | |
| 42 | + yield* electronWindow.syncAllAppearance((window) => | |
| 43 | + Effect.sync(() => { | |
| 44 | + syncedWindows.push(window); | |
| 45 | + }), | |
| 46 | + ); | |
| 47 | + | |
| 48 | + assert.deepEqual(syncedWindows, [liveWindow]); | |
| 49 | + }).pipe(Effect.provide(ElectronWindow.layer)), | |
| 50 | + ); | |
| 51 | +}); | |
| @@ -0,0 +1,135 @@ | ||
| 1 | +import * as Context from "effect/Context"; | |
| 2 | +import * as Data from "effect/Data"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Layer from "effect/Layer"; | |
| 5 | +import * as Option from "effect/Option"; | |
| 6 | +import * as Ref from "effect/Ref"; | |
| 7 | + | |
| 8 | +import * as Electron from "electron"; | |
| 9 | + | |
| 10 | +export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{ | |
| 11 | + readonly cause: unknown; | |
| 12 | +}> { | |
| 13 | + override get message() { | |
| 14 | + return "Failed to create Electron BrowserWindow."; | |
| 15 | + } | |
| 16 | +} | |
| 17 | + | |
| 18 | +export interface ElectronWindowShape { | |
| 19 | + readonly create: ( | |
| 20 | + options: Electron.BrowserWindowConstructorOptions, | |
| 21 | + ) => Effect.Effect<Electron.BrowserWindow, ElectronWindowCreateError>; | |
| 22 | + readonly main: Effect.Effect<Option.Option<Electron.BrowserWindow>>; | |
| 23 | + readonly currentMainOrFirst: Effect.Effect<Option.Option<Electron.BrowserWindow>>; | |
| 24 | + readonly focusedMainOrFirst: Effect.Effect<Option.Option<Electron.BrowserWindow>>; | |
| 25 | + readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect<void>; | |
| 26 | + readonly clearMain: (window: Option.Option<Electron.BrowserWindow>) => Effect.Effect<void>; | |
| 27 | + readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect<void>; | |
| 28 | + readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect<void>; | |
| 29 | + readonly destroyAll: Effect.Effect<void>; | |
| 30 | + readonly syncAllAppearance: <E, R>( | |
| 31 | + sync: (window: Electron.BrowserWindow) => Effect.Effect<void, E, R>, | |
| 32 | + ) => Effect.Effect<void, E, R>; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export class ElectronWindow extends Context.Service<ElectronWindow, ElectronWindowShape>()( | |
| 36 | + "t3/desktop/electron/Window", | |
| 37 | +) {} | |
| 38 | + | |
| 39 | +const make = Effect.gen(function* () { | |
| 40 | + const mainWindowRef = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | |
| 41 | + | |
| 42 | + const liveMain = Ref.get(mainWindowRef).pipe( | |
| 43 | + Effect.map(Option.filter((value) => !value.isDestroyed())), | |
| 44 | + ); | |
| 45 | + | |
| 46 | + const currentMainOrFirst = Effect.gen(function* () { | |
| 47 | + const main = yield* liveMain; | |
| 48 | + if (Option.isSome(main)) { | |
| 49 | + return main; | |
| 50 | + } | |
| 51 | + | |
| 52 | + return Option.fromNullishOr(Electron.BrowserWindow.getAllWindows()[0] ?? null).pipe( | |
| 53 | + Option.filter((window) => !window.isDestroyed()), | |
| 54 | + ); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + const focusedMainOrFirst = Effect.sync(() => | |
| 58 | + Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null).pipe( | |
| 59 | + Option.filter((window) => !window.isDestroyed()), | |
| 60 | + ), | |
| 61 | + ).pipe( | |
| 62 | + Effect.flatMap((focused) => | |
| 63 | + Option.isSome(focused) ? Effect.succeed(focused) : currentMainOrFirst, | |
| 64 | + ), | |
| 65 | + ); | |
| 66 | + | |
| 67 | + return ElectronWindow.of({ | |
| 68 | + create: (options) => | |
| 69 | + Effect.try({ | |
| 70 | + try: () => new Electron.BrowserWindow(options), | |
| 71 | + catch: (cause) => new ElectronWindowCreateError({ cause }), | |
| 72 | + }), | |
| 73 | + main: liveMain, | |
| 74 | + currentMainOrFirst, | |
| 75 | + focusedMainOrFirst, | |
| 76 | + setMain: (window) => Ref.set(mainWindowRef, Option.some(window)), | |
| 77 | + clearMain: (window) => | |
| 78 | + Ref.update(mainWindowRef, (current) => { | |
| 79 | + if (Option.isNone(current)) { | |
| 80 | + return current; | |
| 81 | + } | |
| 82 | + if (Option.isSome(window) && current.value !== window.value) { | |
| 83 | + return current; | |
| 84 | + } | |
| 85 | + return Option.none(); | |
| 86 | + }), | |
| 87 | + reveal: (window) => | |
| 88 | + Effect.sync(() => { | |
| 89 | + if (window.isDestroyed()) { | |
| 90 | + return; | |
| 91 | + } | |
| 92 | + | |
| 93 | + if (window.isMinimized()) { | |
| 94 | + window.restore(); | |
| 95 | + } | |
| 96 | + | |
| 97 | + if (!window.isVisible()) { | |
| 98 | + window.show(); | |
| 99 | + } | |
| 100 | + | |
| 101 | + if (process.platform === "darwin") { | |
| 102 | + Electron.app.focus({ steal: true }); | |
| 103 | + } | |
| 104 | + | |
| 105 | + window.focus(); | |
| 106 | + }), | |
| 107 | + sendAll: (channel, ...args) => | |
| 108 | + Effect.sync(() => { | |
| 109 | + for (const window of Electron.BrowserWindow.getAllWindows()) { | |
| 110 | + if (window.isDestroyed()) { | |
| 111 | + continue; | |
| 112 | + } | |
| 113 | + window.webContents.send(channel, ...args); | |
| 114 | + } | |
| 115 | + }), | |
| 116 | + destroyAll: Effect.sync(() => { | |
| 117 | + for (const window of Electron.BrowserWindow.getAllWindows()) { | |
| 118 | + window.destroy(); | |
| 119 | + } | |
| 120 | + }), | |
| 121 | + syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* <E, R>( | |
| 122 | + sync: (window: Electron.BrowserWindow) => Effect.Effect<void, E, R>, | |
| 123 | + ) { | |
| 124 | + const windows = Electron.BrowserWindow.getAllWindows(); | |
| 125 | + for (const window of windows) { | |
| 126 | + if (window.isDestroyed()) { | |
| 127 | + continue; | |
| 128 | + } | |
| 129 | + yield* sync(window); | |
| 130 | + } | |
| 131 | + }), | |
| 132 | + }); | |
| 133 | +}); | |
| 134 | + | |
| 135 | +export const layer = Layer.effect(ElectronWindow, make); | |
| @@ -0,0 +1,219 @@ | ||
| 1 | +import * as Context from "effect/Context"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Schema from "effect/Schema"; | |
| 4 | +import * as Scope from "effect/Scope"; | |
| 5 | + | |
| 6 | +export interface DesktopIpcInvokeEvent {} | |
| 7 | + | |
| 8 | +export interface DesktopIpcSyncEvent { | |
| 9 | + returnValue: unknown; | |
| 10 | +} | |
| 11 | + | |
| 12 | +export type DesktopIpcHandleListener = ( | |
| 13 | + event: DesktopIpcInvokeEvent, | |
| 14 | + raw: unknown, | |
| 15 | +) => unknown | Promise<unknown>; | |
| 16 | + | |
| 17 | +export type DesktopIpcSyncListener = (event: DesktopIpcSyncEvent) => void; | |
| 18 | + | |
| 19 | +export interface DesktopIpcMain { | |
| 20 | + removeHandler(channel: string): void; | |
| 21 | + handle(channel: string, listener: DesktopIpcHandleListener): void; | |
| 22 | + removeAllListeners(channel: string): void; | |
| 23 | + on(channel: string, listener: DesktopIpcSyncListener): void; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export interface DesktopIpcMethod<E, R> { | |
| 27 | + readonly channel: string; | |
| 28 | + readonly handler: (raw: unknown) => Effect.Effect<unknown, E, R>; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export interface DesktopSyncIpcMethod<E, R> { | |
| 32 | + readonly channel: string; | |
| 33 | + readonly handler: () => Effect.Effect<unknown, E, R>; | |
| 34 | +} | |
| 35 | + | |
| 36 | +export interface DesktopIpcShape { | |
| 37 | + readonly handle: <E, R>( | |
| 38 | + input: DesktopIpcMethod<E, R>, | |
| 39 | + ) => Effect.Effect<void, never, R | Scope.Scope>; | |
| 40 | + readonly handleSync: <E, R>( | |
| 41 | + input: DesktopSyncIpcMethod<E, R>, | |
| 42 | + ) => Effect.Effect<void, never, R | Scope.Scope>; | |
| 43 | +} | |
| 44 | + | |
| 45 | +export class DesktopIpc extends Context.Service<DesktopIpc, DesktopIpcShape>()("t3/desktop/Ipc") {} | |
| 46 | + | |
| 47 | +export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => | |
| 48 | + DesktopIpc.of({ | |
| 49 | + handle: Effect.fn("desktop.ipc.registerInvoke")(function* <E, R>({ | |
| 50 | + channel, | |
| 51 | + handler, | |
| 52 | + }: DesktopIpcMethod<E, R>) { | |
| 53 | + yield* Effect.annotateCurrentSpan({ channel }); | |
| 54 | + const context = yield* Effect.context<R>(); | |
| 55 | + const runPromise = Effect.runPromiseWith(context); | |
| 56 | + | |
| 57 | + yield* Effect.acquireRelease( | |
| 58 | + Effect.sync(() => { | |
| 59 | + ipcMain.removeHandler(channel); | |
| 60 | + ipcMain.handle(channel, (_event, raw) => | |
| 61 | + runPromise( | |
| 62 | + Effect.gen(function* () { | |
| 63 | + yield* Effect.annotateCurrentSpan({ channel }); | |
| 64 | + return yield* handler(raw); | |
| 65 | + }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")), | |
| 66 | + ), | |
| 67 | + ); | |
| 68 | + }), | |
| 69 | + () => Effect.sync(() => ipcMain.removeHandler(channel)), | |
| 70 | + ); | |
| 71 | + }), | |
| 72 | + | |
| 73 | + handleSync: Effect.fn("desktop.ipc.registerSync")(function* <E, R>({ | |
| 74 | + channel, | |
| 75 | + handler, | |
| 76 | + }: DesktopSyncIpcMethod<E, R>) { | |
| 77 | + yield* Effect.annotateCurrentSpan({ channel }); | |
| 78 | + const context = yield* Effect.context<R>(); | |
| 79 | + const runSync = Effect.runSyncWith(context); | |
| 80 | + | |
| 81 | + yield* Effect.acquireRelease( | |
| 82 | + Effect.sync(() => { | |
| 83 | + ipcMain.removeAllListeners(channel); | |
| 84 | + ipcMain.on(channel, (event) => { | |
| 85 | + event.returnValue = runSync( | |
| 86 | + Effect.gen(function* () { | |
| 87 | + yield* Effect.annotateCurrentSpan({ channel }); | |
| 88 | + return yield* handler(); | |
| 89 | + }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invokeSync")), | |
| 90 | + ); | |
| 91 | + }); | |
| 92 | + }), | |
| 93 | + () => Effect.sync(() => ipcMain.removeAllListeners(channel)), | |
| 94 | + ); | |
| 95 | + }), | |
| 96 | + }); | |
| 97 | + | |
| 98 | +/** | |
| 99 | + * Convenience helpers for creating IPC methods | |
| 100 | + */ | |
| 101 | + | |
| 102 | +export interface DesktopIpcMethodRegistration< | |
| 103 | + Payload, | |
| 104 | + EncodedPayload, | |
| 105 | + Result, | |
| 106 | + EncodedResult, | |
| 107 | + E, | |
| 108 | + R, | |
| 109 | + PayloadDecodingServices = never, | |
| 110 | + PayloadEncodingServices = never, | |
| 111 | + ResultDecodingServices = never, | |
| 112 | + ResultEncodingServices = never, | |
| 113 | +> { | |
| 114 | + readonly channel: string; | |
| 115 | + readonly payload: Schema.Codec< | |
| 116 | + Payload, | |
| 117 | + EncodedPayload, | |
| 118 | + PayloadDecodingServices, | |
| 119 | + PayloadEncodingServices | |
| 120 | + >; | |
| 121 | + readonly result: Schema.Codec< | |
| 122 | + Result, | |
| 123 | + EncodedResult, | |
| 124 | + ResultDecodingServices, | |
| 125 | + ResultEncodingServices | |
| 126 | + >; | |
| 127 | + readonly handler: (input: Payload) => Effect.Effect<Result, E, R>; | |
| 128 | +} | |
| 129 | + | |
| 130 | +export const makeIpcMethod = < | |
| 131 | + Payload, | |
| 132 | + EncodedPayload, | |
| 133 | + Result, | |
| 134 | + EncodedResult, | |
| 135 | + E, | |
| 136 | + R, | |
| 137 | + PayloadDecodingServices = never, | |
| 138 | + PayloadEncodingServices = never, | |
| 139 | + ResultDecodingServices = never, | |
| 140 | + ResultEncodingServices = never, | |
| 141 | +>( | |
| 142 | + method: DesktopIpcMethodRegistration< | |
| 143 | + Payload, | |
| 144 | + EncodedPayload, | |
| 145 | + Result, | |
| 146 | + EncodedResult, | |
| 147 | + E, | |
| 148 | + R, | |
| 149 | + PayloadDecodingServices, | |
| 150 | + PayloadEncodingServices, | |
| 151 | + ResultDecodingServices, | |
| 152 | + ResultEncodingServices | |
| 153 | + >, | |
| 154 | +): DesktopIpcMethod< | |
| 155 | + E | Schema.SchemaError, | |
| 156 | + R | PayloadDecodingServices | ResultEncodingServices | |
| 157 | +> => { | |
| 158 | + const decode = Schema.decodeUnknownEffect(method.payload); | |
| 159 | + const encode = Schema.encodeUnknownEffect(method.result); | |
| 160 | + | |
| 161 | + return { | |
| 162 | + channel: method.channel, | |
| 163 | + handler: (raw) => | |
| 164 | + decode(raw).pipe( | |
| 165 | + Effect.flatMap(method.handler), | |
| 166 | + Effect.flatMap(encode), | |
| 167 | + Effect.withSpan("desktop.ipc.method", { attributes: { channel: method.channel } }), | |
| 168 | + ), | |
| 169 | + }; | |
| 170 | +}; | |
| 171 | + | |
| 172 | +export interface DesktopSyncIpcMethodRegistration< | |
| 173 | + Result, | |
| 174 | + EncodedResult, | |
| 175 | + E, | |
| 176 | + R, | |
| 177 | + ResultDecodingServices = never, | |
| 178 | + ResultEncodingServices = never, | |
| 179 | +> { | |
| 180 | + readonly channel: string; | |
| 181 | + readonly result: Schema.Codec< | |
| 182 | + Result, | |
| 183 | + EncodedResult, | |
| 184 | + ResultDecodingServices, | |
| 185 | + ResultEncodingServices | |
| 186 | + >; | |
| 187 | + readonly handler: () => Effect.Effect<Result, E, R>; | |
| 188 | +} | |
| 189 | + | |
| 190 | +export const makeSyncIpcMethod = < | |
| 191 | + Result, | |
| 192 | + EncodedResult, | |
| 193 | + E, | |
| 194 | + R, | |
| 195 | + ResultDecodingServices = never, | |
| 196 | + ResultEncodingServices = never, | |
| 197 | +>( | |
| 198 | + method: DesktopSyncIpcMethodRegistration< | |
| 199 | + Result, | |
| 200 | + EncodedResult, | |
| 201 | + E, | |
| 202 | + R, | |
| 203 | + ResultDecodingServices, | |
| 204 | + ResultEncodingServices | |
| 205 | + >, | |
| 206 | +): DesktopSyncIpcMethod<E | Schema.SchemaError, R | ResultEncodingServices> => { | |
| 207 | + const encode = Schema.encodeUnknownEffect(method.result); | |
| 208 | + | |
| 209 | + return { | |
| 210 | + channel: method.channel, | |
| 211 | + handler: () => | |
| 212 | + method | |
| 213 | + .handler() | |
| 214 | + .pipe( | |
| 215 | + Effect.flatMap(encode), | |
| 216 | + Effect.withSpan("desktop.ipc.method", { attributes: { channel: method.channel } }), | |
| 217 | + ), | |
| 218 | + }; | |
| 219 | +}; | |
| @@ -0,0 +1,84 @@ | ||
| 1 | +import * as Effect from "effect/Effect"; | |
| 2 | + | |
| 3 | +import * as DesktopIpc from "./DesktopIpc.ts"; | |
| 4 | +import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; | |
| 5 | +import { | |
| 6 | + getSavedEnvironmentRegistry, | |
| 7 | + getSavedEnvironmentSecret, | |
| 8 | + removeSavedEnvironmentSecret, | |
| 9 | + setSavedEnvironmentRegistry, | |
| 10 | + setSavedEnvironmentSecret, | |
| 11 | +} from "./methods/savedEnvironments.ts"; | |
| 12 | +import { | |
| 13 | + getAdvertisedEndpoints, | |
| 14 | + getServerExposureState, | |
| 15 | + setServerExposureMode, | |
| 16 | + setTailscaleServeEnabled, | |
| 17 | +} from "./methods/serverExposure.ts"; | |
| 18 | +import { | |
| 19 | + bootstrapSshBearerSession, | |
| 20 | + disconnectSshEnvironment, | |
| 21 | + discoverSshHosts, | |
| 22 | + ensureSshEnvironment, | |
| 23 | + fetchSshEnvironmentDescriptor, | |
| 24 | + fetchSshSessionState, | |
| 25 | + issueSshWebSocketToken, | |
| 26 | + resolveSshPasswordPrompt, | |
| 27 | +} from "./methods/sshEnvironment.ts"; | |
| 28 | +import { | |
| 29 | + checkForUpdate, | |
| 30 | + downloadUpdate, | |
| 31 | + getUpdateState, | |
| 32 | + installUpdate, | |
| 33 | + setUpdateChannel, | |
| 34 | +} from "./methods/updates.ts"; | |
| 35 | +import { | |
| 36 | + confirm, | |
| 37 | + getAppBranding, | |
| 38 | + getLocalEnvironmentBootstrap, | |
| 39 | + openExternal, | |
| 40 | + pickFolder, | |
| 41 | + setTheme, | |
| 42 | + showContextMenu, | |
| 43 | +} from "./methods/window.ts"; | |
| 44 | + | |
| 45 | +export const installDesktopIpcHandlers = Effect.gen(function* () { | |
| 46 | + const ipc = yield* DesktopIpc.DesktopIpc; | |
| 47 | + | |
| 48 | + yield* ipc.handleSync(getAppBranding); | |
| 49 | + yield* ipc.handleSync(getLocalEnvironmentBootstrap); | |
| 50 | + | |
| 51 | + yield* ipc.handle(getClientSettings); | |
| 52 | + yield* ipc.handle(setClientSettings); | |
| 53 | + yield* ipc.handle(getSavedEnvironmentRegistry); | |
| 54 | + yield* ipc.handle(setSavedEnvironmentRegistry); | |
| 55 | + yield* ipc.handle(getSavedEnvironmentSecret); | |
| 56 | + yield* ipc.handle(setSavedEnvironmentSecret); | |
| 57 | + yield* ipc.handle(removeSavedEnvironmentSecret); | |
| 58 | + | |
| 59 | + yield* ipc.handle(discoverSshHosts); | |
| 60 | + yield* ipc.handle(ensureSshEnvironment); | |
| 61 | + yield* ipc.handle(disconnectSshEnvironment); | |
| 62 | + yield* ipc.handle(fetchSshEnvironmentDescriptor); | |
| 63 | + yield* ipc.handle(bootstrapSshBearerSession); | |
| 64 | + yield* ipc.handle(fetchSshSessionState); | |
| 65 | + yield* ipc.handle(issueSshWebSocketToken); | |
| 66 | + yield* ipc.handle(resolveSshPasswordPrompt); | |
| 67 | + | |
| 68 | + yield* ipc.handle(getServerExposureState); | |
| 69 | + yield* ipc.handle(setServerExposureMode); | |
| 70 | + yield* ipc.handle(setTailscaleServeEnabled); | |
| 71 | + yield* ipc.handle(getAdvertisedEndpoints); | |
| 72 | + | |
| 73 | + yield* ipc.handle(pickFolder); | |
| 74 | + yield* ipc.handle(confirm); | |
| 75 | + yield* ipc.handle(setTheme); | |
| 76 | + yield* ipc.handle(showContextMenu); | |
| 77 | + yield* ipc.handle(openExternal); | |
| 78 | + | |
| 79 | + yield* ipc.handle(getUpdateState); | |
| 80 | + yield* ipc.handle(setUpdateChannel); | |
| 81 | + yield* ipc.handle(downloadUpdate); | |
| 82 | + yield* ipc.handle(installUpdate); | |
| 83 | + yield* ipc.handle(checkForUpdate); | |
| 84 | +}).pipe(Effect.withSpan("desktop.ipc.installHandlers")); | |
| @@ -0,0 +1,35 @@ | ||
| 1 | +export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; | |
| 2 | +export const CONFIRM_CHANNEL = "desktop:confirm"; | |
| 3 | +export const SET_THEME_CHANNEL = "desktop:set-theme"; | |
| 4 | +export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | |
| 5 | +export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | |
| 6 | +export const MENU_ACTION_CHANNEL = "desktop:menu-action"; | |
| 7 | +export const UPDATE_STATE_CHANNEL = "desktop:update-state"; | |
| 8 | +export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; | |
| 9 | +export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; | |
| 10 | +export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; | |
| 11 | +export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | |
| 12 | +export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; | |
| 13 | +export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; | |
| 14 | +export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; | |
| 15 | +export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; | |
| 16 | +export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; | |
| 17 | +export const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; | |
| 18 | +export const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; | |
| 19 | +export const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; | |
| 20 | +export const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; | |
| 21 | +export const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; | |
| 22 | +export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; | |
| 23 | +export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; | |
| 24 | +export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; | |
| 25 | +export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; | |
| 26 | +export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; | |
| 27 | +export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; | |
| 28 | +export const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; | |
| 29 | +export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; | |
| 30 | +export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; | |
| 31 | +export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; | |
| 32 | +export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; | |
| 33 | +export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; | |
| 34 | +export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; | |
| 35 | +export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; | |
| @@ -0,0 +1,28 @@ | ||
| 1 | +import { ClientSettingsSchema } from "@t3tools/contracts"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Option from "effect/Option"; | |
| 4 | +import * as Schema from "effect/Schema"; | |
| 5 | + | |
| 6 | +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; | |
| 7 | +import * as IpcChannels from "../channels.ts"; | |
| 8 | +import { makeIpcMethod } from "../DesktopIpc.ts"; | |
| 9 | + | |
| 10 | +export const getClientSettings = makeIpcMethod({ | |
| 11 | + channel: IpcChannels.GET_CLIENT_SETTINGS_CHANNEL, | |
| 12 | + payload: Schema.Void, | |
| 13 | + result: Schema.NullOr(ClientSettingsSchema), | |
| 14 | + handler: Effect.fn("desktop.ipc.clientSettings.get")(function* () { | |
| 15 | + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; | |
| 16 | + return Option.getOrNull(yield* clientSettings.get); | |
| 17 | + }), | |
| 18 | +}); | |
| 19 | + | |
| 20 | +export const setClientSettings = makeIpcMethod({ | |
| 21 | + channel: IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, | |
| 22 | + payload: ClientSettingsSchema, | |
| 23 | + result: Schema.Void, | |
| 24 | + handler: Effect.fn("desktop.ipc.clientSettings.set")(function* (settings) { | |
| 25 | + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; | |
| 26 | + yield* clientSettings.set(settings); | |
| 27 | + }), | |
| 28 | +}); | |
| @@ -0,0 +1,76 @@ | ||
| 1 | +import { EnvironmentId, PersistedSavedEnvironmentRecordSchema } from "@t3tools/contracts"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Option from "effect/Option"; | |
| 4 | +import * as Schema from "effect/Schema"; | |
| 5 | + | |
| 6 | +import * as DesktopSavedEnvironments from "../../settings/DesktopSavedEnvironments.ts"; | |
| 7 | +import * as IpcChannels from "../channels.ts"; | |
| 8 | +import { makeIpcMethod } from "../DesktopIpc.ts"; | |
| 9 | + | |
| 10 | +const SavedEnvironmentRegistryPayload = Schema.Array(PersistedSavedEnvironmentRecordSchema); | |
| 11 | +const NonBlankString = Schema.String.check( | |
| 12 | + Schema.makeFilter((value) => | |
| 13 | + value.trim().length > 0 ? undefined : "Expected a non-empty string", | |
| 14 | + ), | |
| 15 | +); | |
| 16 | + | |
| 17 | +const SetSavedEnvironmentSecretInput = Schema.Struct({ | |
| 18 | + environmentId: EnvironmentId, | |
| 19 | + secret: NonBlankString, | |
| 20 | +}); | |
| 21 | + | |
| 22 | +export const getSavedEnvironmentRegistry = makeIpcMethod({ | |
| 23 | + channel: IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, | |
| 24 | + payload: Schema.Void, | |
| 25 | + result: SavedEnvironmentRegistryPayload, | |
| 26 | + handler: Effect.fn("desktop.ipc.savedEnvironments.getRegistry")(function* () { | |
| 27 | + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | |
| 28 | + return yield* savedEnvironments.getRegistry; | |
| 29 | + }), | |
| 30 | +}); | |
| 31 | + | |
| 32 | +export const setSavedEnvironmentRegistry = makeIpcMethod({ | |
| 33 | + channel: IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, | |
| 34 | + payload: SavedEnvironmentRegistryPayload, | |
| 35 | + result: Schema.Void, | |
| 36 | + handler: Effect.fn("desktop.ipc.savedEnvironments.setRegistry")(function* (records) { | |
| 37 | + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | |
| 38 | + yield* savedEnvironments.setRegistry(records); | |
| 39 | + }), | |
| 40 | +}); | |
| 41 | + | |
| 42 | +export const getSavedEnvironmentSecret = makeIpcMethod({ | |
| 43 | + channel: IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, | |
| 44 | + payload: EnvironmentId, | |
| 45 | + result: Schema.NullOr(Schema.String), | |
| 46 | + handler: Effect.fn("desktop.ipc.savedEnvironments.getSecret")(function* (environmentId) { | |
| 47 | + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | |
| 48 | + return Option.getOrNull(yield* savedEnvironments.getSecret(environmentId)); | |
| 49 | + }), | |
| 50 | +}); | |
| 51 | + | |
| 52 | +export const setSavedEnvironmentSecret = makeIpcMethod({ | |
| 53 | + channel: IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, | |
| 54 | + payload: SetSavedEnvironmentSecretInput, | |
| 55 | + result: Schema.Boolean, | |
| 56 | + handler: Effect.fn("desktop.ipc.savedEnvironments.setSecret")(function* ({ | |
| 57 | + environmentId, | |
| 58 | + secret, | |
| 59 | + }) { | |
| 60 | + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | |
| 61 | + return yield* savedEnvironments.setSecret({ | |
| 62 | + environmentId, | |
| 63 | + secret, | |
| 64 | + }); | |
| 65 | + }), | |
| 66 | +}); | |
| 67 | + | |
| 68 | +export const removeSavedEnvironmentSecret = makeIpcMethod({ | |
| 69 | + channel: IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, | |
| 70 | + payload: EnvironmentId, | |
| 71 | + result: Schema.Void, | |
| 72 | + handler: Effect.fn("desktop.ipc.savedEnvironments.removeSecret")(function* (environmentId) { | |
| 73 | + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | |
| 74 | + yield* savedEnvironments.removeSecret(environmentId); | |
| 75 | + }), | |
| 76 | +}); | |
| @@ -0,0 +1,69 @@ | ||
| 1 | +import { | |
| 2 | + AdvertisedEndpoint, | |
| 3 | + DesktopServerExposureModeSchema, | |
| 4 | + DesktopServerExposureStateSchema, | |
| 5 | +} from "@t3tools/contracts"; | |
| 6 | +import * as Effect from "effect/Effect"; | |
| 7 | +import * as Schema from "effect/Schema"; | |
| 8 | + | |
| 9 | +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; | |
| 10 | +import * as DesktopServerExposure from "../../backend/DesktopServerExposure.ts"; | |
| 11 | +import * as IpcChannels from "../channels.ts"; | |
| 12 | +import { makeIpcMethod } from "../DesktopIpc.ts"; | |
| 13 | + | |
| 14 | +const SetTailscaleServeEnabledInput = Schema.Struct({ | |
| 15 | + enabled: Schema.Boolean, | |
| 16 | + port: Schema.optionalKey(Schema.Number), | |
| 17 | +}); | |
| 18 | + | |
| 19 | +export const getServerExposureState = makeIpcMethod({ | |
| 20 | + channel: IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL, | |
| 21 | + payload: Schema.Void, | |
| 22 | + result: DesktopServerExposureStateSchema, | |
| 23 | + handler: Effect.fn("desktop.ipc.serverExposure.getState")(function* () { | |
| 24 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 25 | + return yield* serverExposure.getState; | |
| 26 | + }), | |
| 27 | +}); | |
| 28 | + | |
| 29 | +export const setServerExposureMode = makeIpcMethod({ | |
| 30 | + channel: IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, | |
| 31 | + payload: DesktopServerExposureModeSchema, | |
| 32 | + result: DesktopServerExposureStateSchema, | |
| 33 | + handler: Effect.fn("desktop.ipc.serverExposure.setMode")(function* (mode) { | |
| 34 | + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; | |
| 35 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 36 | + const change = yield* serverExposure.setMode(mode); | |
| 37 | + if (change.requiresRelaunch) { | |
| 38 | + yield* lifecycle.relaunch(`serverExposureMode=${mode}`); | |
| 39 | + } | |
| 40 | + return change.state; | |
| 41 | + }), | |
| 42 | +}); | |
| 43 | + | |
| 44 | +export const setTailscaleServeEnabled = makeIpcMethod({ | |
| 45 | + channel: IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, | |
| 46 | + payload: SetTailscaleServeEnabledInput, | |
| 47 | + result: DesktopServerExposureStateSchema, | |
| 48 | + handler: Effect.fn("desktop.ipc.serverExposure.setTailscaleServeEnabled")(function* (input) { | |
| 49 | + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; | |
| 50 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 51 | + const change = yield* serverExposure.setTailscaleServeEnabled(input); | |
| 52 | + if (change.requiresRelaunch) { | |
| 53 | + yield* lifecycle.relaunch( | |
| 54 | + change.state.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled", | |
| 55 | + ); | |
| 56 | + } | |
| 57 | + return change.state; | |
| 58 | + }), | |
| 59 | +}); | |
| 60 | + | |
| 61 | +export const getAdvertisedEndpoints = makeIpcMethod({ | |
| 62 | + channel: IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL, | |
| 63 | + payload: Schema.Void, | |
| 64 | + result: Schema.Array(AdvertisedEndpoint), | |
| 65 | + handler: Effect.fn("desktop.ipc.serverExposure.getAdvertisedEndpoints")(function* () { | |
| 66 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 67 | + return yield* serverExposure.getAdvertisedEndpoints; | |
| 68 | + }), | |
| 69 | +}); | |
| @@ -0,0 +1,127 @@ | ||
| 1 | +import { | |
| 2 | + DesktopDiscoveredSshHostSchema, | |
| 3 | + DesktopSshBearerBootstrapInputSchema, | |
| 4 | + DesktopSshBearerRequestInputSchema, | |
| 5 | + DesktopSshEnvironmentEnsureInputSchema, | |
| 6 | + DesktopSshEnvironmentEnsureResultSchema, | |
| 7 | + DesktopSshEnvironmentTargetSchema, | |
| 8 | + DesktopSshHttpBaseUrlInputSchema, | |
| 9 | + DesktopSshPasswordPromptCancelledType, | |
| 10 | + DesktopSshPasswordPromptResolutionInputSchema, | |
| 11 | + ExecutionEnvironmentDescriptor, | |
| 12 | + AuthBearerBootstrapResult, | |
| 13 | + AuthSessionState, | |
| 14 | + AuthWebSocketTokenResult, | |
| 15 | +} from "@t3tools/contracts"; | |
| 16 | +import * as Effect from "effect/Effect"; | |
| 17 | +import * as Schema from "effect/Schema"; | |
| 18 | + | |
| 19 | +import * as IpcChannels from "../channels.ts"; | |
| 20 | +import { makeIpcMethod } from "../DesktopIpc.ts"; | |
| 21 | +import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; | |
| 22 | +import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; | |
| 23 | +import * as DesktopSshRemoteApi from "../../ssh/DesktopSshRemoteApi.ts"; | |
| 24 | + | |
| 25 | +export const discoverSshHosts = makeIpcMethod({ | |
| 26 | + channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL, | |
| 27 | + payload: Schema.Void, | |
| 28 | + result: Schema.Array(DesktopDiscoveredSshHostSchema), | |
| 29 | + handler: Effect.fn("desktop.ipc.sshEnvironment.discoverHosts")(function* () { | |
| 30 | + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; | |
| 31 | + return yield* sshEnvironment.discoverHosts(); | |
| 32 | + }), | |
| 33 | +}); | |
| 34 | + | |
| 35 | +export const ensureSshEnvironment = makeIpcMethod({ | |
| 36 | + channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, | |
| 37 | + payload: DesktopSshEnvironmentEnsureInputSchema, | |
| 38 | + result: DesktopSshEnvironmentEnsureResultSchema, | |
| 39 | + handler: Effect.fn("desktop.ipc.sshEnvironment.ensureEnvironment")(function* ({ | |
| 40 | + target, | |
| 41 | + options, | |
| 42 | + }) { | |
| 43 | + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; | |
| 44 | + return yield* sshEnvironment.ensureEnvironment(target, options).pipe( | |
| 45 | + Effect.catch((error) => | |
| 46 | + DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error) | |
| 47 | + ? Effect.succeed({ | |
| 48 | + type: DesktopSshPasswordPromptCancelledType, | |
| 49 | + message: error.message, | |
| 50 | + }) | |
| 51 | + : Effect.fail(error), | |
| 52 | + ), | |
| 53 | + ); | |
| 54 | + }), | |
| 55 | +}); | |
| 56 | + | |
| 57 | +export const disconnectSshEnvironment = makeIpcMethod({ | |
| 58 | + channel: IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, | |
| 59 | + payload: DesktopSshEnvironmentTargetSchema, | |
| 60 | + result: Schema.Void, | |
| 61 | + handler: Effect.fn("desktop.ipc.sshEnvironment.disconnectEnvironment")(function* (target) { | |
| 62 | + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; | |
| 63 | + yield* sshEnvironment.disconnectEnvironment(target); | |
| 64 | + }), | |
| 65 | +}); | |
| 66 | + | |
| 67 | +export const fetchSshEnvironmentDescriptor = makeIpcMethod({ | |
| 68 | + channel: IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, | |
| 69 | + payload: DesktopSshHttpBaseUrlInputSchema, | |
| 70 | + result: ExecutionEnvironmentDescriptor, | |
| 71 | + handler: Effect.fn("desktop.ipc.sshEnvironment.fetchDescriptor")(function* ({ httpBaseUrl }) { | |
| 72 | + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | |
| 73 | + return yield* remoteApi.fetchEnvironmentDescriptor({ httpBaseUrl }); | |
| 74 | + }), | |
| 75 | +}); | |
| 76 | + | |
| 77 | +export const bootstrapSshBearerSession = makeIpcMethod({ | |
| 78 | + channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, | |
| 79 | + payload: DesktopSshBearerBootstrapInputSchema, | |
| 80 | + result: AuthBearerBootstrapResult, | |
| 81 | + handler: Effect.fn("desktop.ipc.sshEnvironment.bootstrapBearerSession")(function* ({ | |
| 82 | + httpBaseUrl, | |
| 83 | + credential, | |
| 84 | + }) { | |
| 85 | + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | |
| 86 | + return yield* remoteApi.bootstrapBearerSession({ httpBaseUrl, credential }); | |
| 87 | + }), | |
| 88 | +}); | |
| 89 | + | |
| 90 | +export const fetchSshSessionState = makeIpcMethod({ | |
| 91 | + channel: IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, | |
| 92 | + payload: DesktopSshBearerRequestInputSchema, | |
| 93 | + result: AuthSessionState, | |
| 94 | + handler: Effect.fn("desktop.ipc.sshEnvironment.fetchSessionState")(function* ({ | |
| 95 | + httpBaseUrl, | |
| 96 | + bearerToken, | |
| 97 | + }) { | |
| 98 | + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | |
| 99 | + return yield* remoteApi.fetchSessionState({ httpBaseUrl, bearerToken }); | |
| 100 | + }), | |
| 101 | +}); | |
| 102 | + | |
| 103 | +export const issueSshWebSocketToken = makeIpcMethod({ | |
| 104 | + channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, | |
| 105 | + payload: DesktopSshBearerRequestInputSchema, | |
| 106 | + result: AuthWebSocketTokenResult, | |
| 107 | + handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketToken")(function* ({ | |
| 108 | + httpBaseUrl, | |
| 109 | + bearerToken, | |
| 110 | + }) { | |
| 111 | + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | |
| 112 | + return yield* remoteApi.issueWebSocketToken({ httpBaseUrl, bearerToken }); | |
| 113 | + }), | |
| 114 | +}); | |
| 115 | + | |
| 116 | +export const resolveSshPasswordPrompt = makeIpcMethod({ | |
| 117 | + channel: IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, | |
| 118 | + payload: DesktopSshPasswordPromptResolutionInputSchema, | |
| 119 | + result: Schema.Void, | |
| 120 | + handler: Effect.fn("desktop.ipc.sshEnvironment.resolvePasswordPrompt")(function* ({ | |
| 121 | + requestId, | |
| 122 | + password, | |
| 123 | + }) { | |
| 124 | + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; | |
| 125 | + yield* prompts.resolve({ requestId, password }); | |
| 126 | + }), | |
| 127 | +}); | |
| @@ -0,0 +1,62 @@ | ||
| 1 | +import { | |
| 2 | + DesktopUpdateActionResultSchema, | |
| 3 | + DesktopUpdateChannelSchema, | |
| 4 | + DesktopUpdateCheckResultSchema, | |
| 5 | + DesktopUpdateStateSchema, | |
| 6 | +} from "@t3tools/contracts"; | |
| 7 | +import * as Effect from "effect/Effect"; | |
| 8 | +import * as Schema from "effect/Schema"; | |
| 9 | + | |
| 10 | +import * as DesktopUpdates from "../../updates/DesktopUpdates.ts"; | |
| 11 | +import * as IpcChannels from "../channels.ts"; | |
| 12 | +import { makeIpcMethod } from "../DesktopIpc.ts"; | |
| 13 | + | |
| 14 | +export const getUpdateState = makeIpcMethod({ | |
| 15 | + channel: IpcChannels.UPDATE_GET_STATE_CHANNEL, | |
| 16 | + payload: Schema.Void, | |
| 17 | + result: DesktopUpdateStateSchema, | |
| 18 | + handler: Effect.fn("desktop.ipc.updates.getState")(function* () { | |
| 19 | + const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 20 | + return yield* updates.getState; | |
| 21 | + }), | |
| 22 | +}); | |
| 23 | + | |
| 24 | +export const setUpdateChannel = makeIpcMethod({ | |
| 25 | + channel: IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, | |
| 26 | + payload: DesktopUpdateChannelSchema, | |
| 27 | + result: DesktopUpdateStateSchema, | |
| 28 | + handler: Effect.fn("desktop.ipc.updates.setChannel")(function* (channel) { | |
| 29 | + const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 30 | + return yield* updates.setChannel(channel); | |
| 31 | + }), | |
| 32 | +}); | |
| 33 | + | |
| 34 | +export const downloadUpdate = makeIpcMethod({ | |
| 35 | + channel: IpcChannels.UPDATE_DOWNLOAD_CHANNEL, | |
| 36 | + payload: Schema.Void, | |
| 37 | + result: DesktopUpdateActionResultSchema, | |
| 38 | + handler: Effect.fn("desktop.ipc.updates.download")(function* () { | |
| 39 | + const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 40 | + return yield* updates.download; | |
| 41 | + }), | |
| 42 | +}); | |
| 43 | + | |
| 44 | +export const installUpdate = makeIpcMethod({ | |
| 45 | + channel: IpcChannels.UPDATE_INSTALL_CHANNEL, | |
| 46 | + payload: Schema.Void, | |
| 47 | + result: DesktopUpdateActionResultSchema, | |
| 48 | + handler: Effect.fn("desktop.ipc.updates.install")(function* () { | |
| 49 | + const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 50 | + return yield* updates.install; | |
| 51 | + }), | |
| 52 | +}); | |
| 53 | + | |
| 54 | +export const checkForUpdate = makeIpcMethod({ | |
| 55 | + channel: IpcChannels.UPDATE_CHECK_CHANNEL, | |
| 56 | + payload: Schema.Void, | |
| 57 | + result: DesktopUpdateCheckResultSchema, | |
| 58 | + handler: Effect.fn("desktop.ipc.updates.check")(function* () { | |
| 59 | + const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 60 | + return yield* updates.check("web-ui"); | |
| 61 | + }), | |
| 62 | +}); | |
| @@ -0,0 +1,135 @@ | ||
| 1 | +import { | |
| 2 | + ContextMenuItemSchema, | |
| 3 | + DesktopAppBrandingSchema, | |
| 4 | + DesktopEnvironmentBootstrapSchema, | |
| 5 | + DesktopThemeSchema, | |
| 6 | + PickFolderOptionsSchema, | |
| 7 | +} from "@t3tools/contracts"; | |
| 8 | +import * as Effect from "effect/Effect"; | |
| 9 | +import * as Option from "effect/Option"; | |
| 10 | +import * as Schema from "effect/Schema"; | |
| 11 | + | |
| 12 | +import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; | |
| 13 | +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; | |
| 14 | +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; | |
| 15 | +import * as ElectronMenu from "../../electron/ElectronMenu.ts"; | |
| 16 | +import * as ElectronShell from "../../electron/ElectronShell.ts"; | |
| 17 | +import * as ElectronTheme from "../../electron/ElectronTheme.ts"; | |
| 18 | +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | |
| 19 | +import * as IpcChannels from "../channels.ts"; | |
| 20 | +import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; | |
| 21 | + | |
| 22 | +const ContextMenuPosition = Schema.Struct({ | |
| 23 | + x: Schema.Number, | |
| 24 | + y: Schema.Number, | |
| 25 | +}); | |
| 26 | + | |
| 27 | +const ContextMenuInput = Schema.Struct({ | |
| 28 | + items: Schema.Array(ContextMenuItemSchema), | |
| 29 | + position: Schema.optionalKey(ContextMenuPosition), | |
| 30 | +}); | |
| 31 | + | |
| 32 | +function toWebSocketBaseUrl(httpBaseUrl: URL): string { | |
| 33 | + const url = new URL(httpBaseUrl.href); | |
| 34 | + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; | |
| 35 | + return url.href; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export const getAppBranding = makeSyncIpcMethod({ | |
| 39 | + channel: IpcChannels.GET_APP_BRANDING_CHANNEL, | |
| 40 | + result: Schema.NullOr(DesktopAppBrandingSchema), | |
| 41 | + handler: Effect.fn("desktop.ipc.window.getAppBranding")(function* () { | |
| 42 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 43 | + return environment.branding; | |
| 44 | + }), | |
| 45 | +}); | |
| 46 | + | |
| 47 | +export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ | |
| 48 | + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, | |
| 49 | + result: Schema.NullOr(DesktopEnvironmentBootstrapSchema), | |
| 50 | + handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstrap")(function* () { | |
| 51 | + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 52 | + const config = yield* backendManager.currentConfig; | |
| 53 | + return Option.match(config, { | |
| 54 | + onNone: () => null, | |
| 55 | + onSome: ({ bootstrap, httpBaseUrl }) => ({ | |
| 56 | + label: "Local environment", | |
| 57 | + httpBaseUrl: httpBaseUrl.href, | |
| 58 | + wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), | |
| 59 | + ...(bootstrap.desktopBootstrapToken | |
| 60 | + ? { bootstrapToken: bootstrap.desktopBootstrapToken } | |
| 61 | + : {}), | |
| 62 | + }), | |
| 63 | + }); | |
| 64 | + }), | |
| 65 | +}); | |
| 66 | + | |
| 67 | +export const pickFolder = makeIpcMethod({ | |
| 68 | + channel: IpcChannels.PICK_FOLDER_CHANNEL, | |
| 69 | + payload: Schema.UndefinedOr(PickFolderOptionsSchema), | |
| 70 | + result: Schema.NullOr(Schema.String), | |
| 71 | + handler: Effect.fn("desktop.ipc.window.pickFolder")(function* (options) { | |
| 72 | + const dialog = yield* ElectronDialog.ElectronDialog; | |
| 73 | + const electronWindow = yield* ElectronWindow.ElectronWindow; | |
| 74 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 75 | + const selectedPath = yield* dialog.pickFolder({ | |
| 76 | + owner: yield* electronWindow.focusedMainOrFirst, | |
| 77 | + defaultPath: environment.resolvePickFolderDefaultPath(options), | |
| 78 | + }); | |
| 79 | + return Option.getOrNull(selectedPath); | |
| 80 | + }), | |
| 81 | +}); | |
| 82 | + | |
| 83 | +export const confirm = makeIpcMethod({ | |
| 84 | + channel: IpcChannels.CONFIRM_CHANNEL, | |
| 85 | + payload: Schema.String, | |
| 86 | + result: Schema.Boolean, | |
| 87 | + handler: Effect.fn("desktop.ipc.window.confirm")(function* (message) { | |
| 88 | + const dialog = yield* ElectronDialog.ElectronDialog; | |
| 89 | + const electronWindow = yield* ElectronWindow.ElectronWindow; | |
| 90 | + return yield* electronWindow.focusedMainOrFirst.pipe( | |
| 91 | + Effect.flatMap((owner) => dialog.confirm({ owner, message })), | |
| 92 | + ); | |
| 93 | + }), | |
| 94 | +}); | |
| 95 | + | |
| 96 | +export const setTheme = makeIpcMethod({ | |
| 97 | + channel: IpcChannels.SET_THEME_CHANNEL, | |
| 98 | + payload: DesktopThemeSchema, | |
| 99 | + result: Schema.Void, | |
| 100 | + handler: Effect.fn("desktop.ipc.window.setTheme")(function* (theme) { | |
| 101 | + const electronTheme = yield* ElectronTheme.ElectronTheme; | |
| 102 | + yield* electronTheme.setSource(theme); | |
| 103 | + }), | |
| 104 | +}); | |
| 105 | + | |
| 106 | +export const showContextMenu = makeIpcMethod({ | |
| 107 | + channel: IpcChannels.CONTEXT_MENU_CHANNEL, | |
| 108 | + payload: ContextMenuInput, | |
| 109 | + result: Schema.NullOr(Schema.String), | |
| 110 | + handler: Effect.fn("desktop.ipc.window.showContextMenu")(function* (input) { | |
| 111 | + const electronMenu = yield* ElectronMenu.ElectronMenu; | |
| 112 | + const electronWindow = yield* ElectronWindow.ElectronWindow; | |
| 113 | + const window = yield* electronWindow.focusedMainOrFirst; | |
| 114 | + if (Option.isNone(window)) { | |
| 115 | + return null; | |
| 116 | + } | |
| 117 | + | |
| 118 | + const selectedItemId = yield* electronMenu.showContextMenu({ | |
| 119 | + window: window.value, | |
| 120 | + items: input.items, | |
| 121 | + position: Option.fromNullishOr(input.position), | |
| 122 | + }); | |
| 123 | + return Option.getOrNull(selectedItemId); | |
| 124 | + }), | |
| 125 | +}); | |
| 126 | + | |
| 127 | +export const openExternal = makeIpcMethod({ | |
| 128 | + channel: IpcChannels.OPEN_EXTERNAL_CHANNEL, | |
| 129 | + payload: Schema.String, | |
| 130 | + result: Schema.Boolean, | |
| 131 | + handler: Effect.fn("desktop.ipc.window.openExternal")(function* (url) { | |
| 132 | + const shell = yield* ElectronShell.ElectronShell; | |
| 133 | + return yield* shell.openExternal(url); | |
| 134 | + }), | |
| 135 | +}); | |
| @@ -1,2298 +1,149 @@ | ||
| 1 | −import * as ChildProcess from "node:child_process"; | |
| 2 | −import * as Crypto from "node:crypto"; | |
| 3 | −import * as FS from "node:fs"; | |
| 4 | −import * as OS from "node:os"; | |
| 5 | −import * as Path from "node:path"; | |
| 6 | − | |
| 7 | −import { | |
| 8 | − app, | |
| 9 | − BrowserWindow, | |
| 10 | − type BrowserWindowConstructorOptions, | |
| 11 | − clipboard, | |
| 12 | − dialog, | |
| 13 | − ipcMain, | |
| 14 | − Menu, | |
| 15 | − nativeImage, | |
| 16 | − nativeTheme, | |
| 17 | − protocol, | |
| 18 | − safeStorage, | |
| 19 | − shell, | |
| 20 | −} from "electron"; | |
| 21 | −import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron"; | |
| 22 | −import type { | |
| 23 | − ClientSettings, | |
| 24 | − DesktopTheme, | |
| 25 | − DesktopAppBranding, | |
| 26 | − DesktopServerExposureMode, | |
| 27 | − DesktopServerExposureState, | |
| 28 | − DesktopUpdateChannel, | |
| 29 | − PersistedSavedEnvironmentRecord, | |
| 30 | − DesktopUpdateActionResult, | |
| 31 | − DesktopUpdateCheckResult, | |
| 32 | − DesktopUpdateState, | |
| 33 | −} from "@t3tools/contracts"; | |
| 34 | −import { autoUpdater } from "electron-updater"; | |
| 35 | − | |
| 36 | −import type { ContextMenuItem } from "@t3tools/contracts"; | |
| 37 | −import { RotatingFileSink } from "@t3tools/shared/logging"; | |
| 38 | −import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; | |
| 1 | +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; | |
| 2 | +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | |
| 3 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | +import * as NodeOS from "node:os"; | |
| 5 | +import * as Effect from "effect/Effect"; | |
| 6 | +import * as Layer from "effect/Layer"; | |
| 7 | +import * as Option from "effect/Option"; | |
| 8 | + | |
| 9 | +import * as Electron from "electron"; | |
| 10 | + | |
| 11 | +import * as NetService from "@t3tools/shared/Net"; | |
| 12 | +import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; | |
| 39 | 13 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; |
| 40 | −import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts"; | |
| 41 | −import { | |
| 42 | − type DesktopSettings, | |
| 43 | − DEFAULT_DESKTOP_SETTINGS, | |
| 44 | − readDesktopSettings, | |
| 45 | − setDesktopServerExposurePreference, | |
| 46 | − setDesktopTailscaleServePreference, | |
| 47 | − setDesktopUpdateChannelPreference, | |
| 48 | − writeDesktopSettings, | |
| 49 | −} from "./desktopSettings.ts"; | |
| 50 | −import { | |
| 51 | − readClientSettings, | |
| 52 | − readSavedEnvironmentRegistry, | |
| 53 | − readSavedEnvironmentSecret, | |
| 54 | − removeSavedEnvironmentSecret, | |
| 55 | − writeClientSettings, | |
| 56 | − writeSavedEnvironmentRegistry, | |
| 57 | − writeSavedEnvironmentSecret, | |
| 58 | −} from "./clientPersistence.ts"; | |
| 59 | −import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts"; | |
| 60 | −import { showDesktopConfirmDialog } from "./confirmDialog.ts"; | |
| 61 | −import { | |
| 62 | − resolveDesktopCoreAdvertisedEndpoints, | |
| 63 | − resolveDesktopServerExposure, | |
| 64 | −} from "./serverExposure.ts"; | |
| 65 | −import { DesktopSshEnvironmentBridge, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts"; | |
| 66 | −import { syncShellEnvironment } from "./syncShellEnvironment.ts"; | |
| 67 | −import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; | |
| 68 | −import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; | |
| 69 | −import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; | |
| 70 | −import { ServerListeningDetector } from "./serverListeningDetector.ts"; | |
| 71 | −import { | |
| 72 | − createInitialDesktopUpdateState, | |
| 73 | − reduceDesktopUpdateStateOnCheckFailure, | |
| 74 | − reduceDesktopUpdateStateOnCheckStart, | |
| 75 | − reduceDesktopUpdateStateOnDownloadComplete, | |
| 76 | − reduceDesktopUpdateStateOnDownloadFailure, | |
| 77 | − reduceDesktopUpdateStateOnDownloadProgress, | |
| 78 | − reduceDesktopUpdateStateOnDownloadStart, | |
| 79 | − reduceDesktopUpdateStateOnInstallFailure, | |
| 80 | − reduceDesktopUpdateStateOnNoUpdate, | |
| 81 | − reduceDesktopUpdateStateOnUpdateAvailable, | |
| 82 | −} from "./updateMachine.ts"; | |
| 83 | −import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; | |
| 84 | −import { resolveDesktopAppBranding } from "./appBranding.ts"; | |
| 85 | −import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; | |
| 86 | −import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; | |
| 87 | − | |
| 88 | −syncShellEnvironment(); | |
| 89 | − | |
| 90 | −const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; | |
| 91 | −const CONFIRM_CHANNEL = "desktop:confirm"; | |
| 92 | −const SET_THEME_CHANNEL = "desktop:set-theme"; | |
| 93 | −const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | |
| 94 | −const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | |
| 95 | −const MENU_ACTION_CHANNEL = "desktop:menu-action"; | |
| 96 | −const UPDATE_STATE_CHANNEL = "desktop:update-state"; | |
| 97 | −const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; | |
| 98 | −const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; | |
| 99 | −const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; | |
| 100 | −const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | |
| 101 | −const UPDATE_CHECK_CHANNEL = "desktop:update-check"; | |
| 102 | −const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; | |
| 103 | −const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; | |
| 104 | −const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; | |
| 105 | −const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; | |
| 106 | −const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; | |
| 107 | −const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; | |
| 108 | −const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; | |
| 109 | −const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; | |
| 110 | −const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; | |
| 111 | −const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; | |
| 112 | −const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; | |
| 113 | −const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; | |
| 114 | −const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; | |
| 115 | −const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); | |
| 116 | −const STATE_DIR = Path.join(BASE_DIR, "userdata"); | |
| 117 | −const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); | |
| 118 | −const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json"); | |
| 119 | −const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json"); | |
| 120 | −const DESKTOP_SCHEME = "t3"; | |
| 121 | −const ROOT_DIR = Path.resolve(__dirname, "../../.."); | |
| 122 | −const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); | |
| 123 | −// Dev-only SSH launcher override. Set this to an absolute path on the SSH host | |
| 124 | −// for a built server entry, for example: | |
| 125 | −// "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs" | |
| 126 | −const DEV_REMOTE_T3_SERVER_ENTRY_PATH = | |
| 127 | − process.env.T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH?.trim() ?? ""; | |
| 128 | −const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({ | |
| 129 | − isDevelopment, | |
| 130 | − appVersion: app.getVersion(), | |
| 131 | −}); | |
| 132 | −const APP_DISPLAY_NAME = desktopAppBranding.displayName; | |
| 133 | −const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; | |
| 134 | −const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop"; | |
| 135 | −const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code"; | |
| 136 | −const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code"; | |
| 137 | −const LEGACY_USER_DATA_DIR_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | |
| 138 | −const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; | |
| 139 | −const COMMIT_HASH_DISPLAY_LENGTH = 12; | |
| 140 | −const LOG_DIR = Path.join(STATE_DIR, "logs"); | |
| 141 | −const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; | |
| 142 | −const LOG_FILE_MAX_FILES = 10; | |
| 143 | −const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); | |
| 144 | −const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json"); | |
| 145 | −const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; | |
| 146 | −const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; | |
| 147 | − | |
| 148 | −function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined { | |
| 149 | − if (typeof rawOptions !== "object" || rawOptions === null) { | |
| 150 | − return undefined; | |
| 151 | − } | |
| 152 | − | |
| 153 | − const { initialPath } = rawOptions as { initialPath?: unknown }; | |
| 154 | − if (typeof initialPath !== "string") { | |
| 155 | − return undefined; | |
| 156 | − } | |
| 157 | − | |
| 158 | − const trimmedPath = initialPath.trim(); | |
| 159 | − if (trimmedPath.length === 0) { | |
| 160 | − return undefined; | |
| 161 | − } | |
| 162 | − | |
| 163 | − if (trimmedPath === "~") { | |
| 164 | − return OS.homedir(); | |
| 165 | − } | |
| 166 | − | |
| 167 | − if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) { | |
| 168 | − return Path.join(OS.homedir(), trimmedPath.slice(2)); | |
| 169 | − } | |
| 170 | − | |
| 171 | − return Path.resolve(trimmedPath); | |
| 172 | −} | |
| 173 | −const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; | |
| 174 | −const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; | |
| 175 | −const TITLEBAR_HEIGHT = 40; | |
| 176 | −const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux | |
| 177 | −const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; | |
| 178 | −const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; | |
| 179 | − | |
| 180 | −function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { | |
| 181 | − const normalizedItems: ContextMenuItem[] = []; | |
| 182 | − | |
| 183 | − for (const sourceItem of source) { | |
| 184 | − if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { | |
| 185 | − continue; | |
| 186 | − } | |
| 187 | − | |
| 188 | − const normalizedItem: ContextMenuItem = { | |
| 189 | − id: sourceItem.id, | |
| 190 | − label: sourceItem.label, | |
| 191 | − destructive: sourceItem.destructive === true, | |
| 192 | − disabled: sourceItem.disabled === true, | |
| 193 | − }; | |
| 194 | − | |
| 195 | − if (sourceItem.children) { | |
| 196 | − const normalizedChildren = normalizeContextMenuItems(sourceItem.children); | |
| 197 | − if (normalizedChildren.length === 0) { | |
| 198 | − continue; | |
| 199 | − } | |
| 200 | − normalizedItem.children = normalizedChildren; | |
| 201 | − } | |
| 202 | − | |
| 203 | − normalizedItems.push(normalizedItem); | |
| 204 | − } | |
| 205 | − | |
| 206 | − return normalizedItems; | |
| 207 | −} | |
| 208 | − | |
| 209 | −type WindowTitleBarOptions = Pick< | |
| 210 | − BrowserWindowConstructorOptions, | |
| 211 | − "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" | |
| 212 | −>; | |
| 213 | − | |
| 214 | −type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; | |
| 215 | −type LinuxDesktopNamedApp = Electron.App & { | |
| 216 | − setDesktopName?: (desktopName: string) => void; | |
| 217 | −}; | |
| 218 | − | |
| 219 | −let mainWindow: BrowserWindow | null = null; | |
| 220 | −let backendProcess: ChildProcess.ChildProcess | null = null; | |
| 221 | −let backendPort = 0; | |
| 222 | −let backendBindHost = DESKTOP_LOOPBACK_HOST; | |
| 223 | −let backendBootstrapToken = ""; | |
| 224 | −let backendHttpUrl = ""; | |
| 225 | −let backendWsUrl = ""; | |
| 226 | −let backendEndpointUrl: string | null = null; | |
| 227 | −let backendAdvertisedHost: string | null = null; | |
| 228 | −let backendReadinessAbortController: AbortController | null = null; | |
| 229 | −let backendInitialWindowOpenInFlight: Promise<void> | null = null; | |
| 230 | −let backendListeningDetector: ServerListeningDetector | null = null; | |
| 231 | −let restartAttempt = 0; | |
| 232 | −let restartTimer: ReturnType<typeof setTimeout> | null = null; | |
| 233 | −let isQuitting = false; | |
| 234 | −let desktopProtocolRegistered = false; | |
| 235 | −let aboutCommitHashCache: string | null | undefined; | |
| 236 | −let desktopLogSink: RotatingFileSink | null = null; | |
| 237 | −let backendLogSink: RotatingFileSink | null = null; | |
| 238 | −let restoreStdIoCapture: (() => void) | null = null; | |
| 239 | −let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); | |
| 240 | −let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion()); | |
| 241 | −let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; | |
| 242 | − | |
| 243 | −let destructiveMenuIconCache: Electron.NativeImage | null | undefined; | |
| 244 | −const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>(); | |
| 245 | −const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ | |
| 246 | − platform: process.platform, | |
| 247 | − processArch: process.arch, | |
| 248 | − runningUnderArm64Translation: app.runningUnderARM64Translation === true, | |
| 249 | −}); | |
| 250 | −const initialUpdateState = (): DesktopUpdateState => | |
| 251 | − createInitialDesktopUpdateState( | |
| 252 | − app.getVersion(), | |
| 253 | − desktopRuntimeInfo, | |
| 254 | − desktopSettings.updateChannel, | |
| 255 | − ); | |
| 256 | − | |
| 257 | −function logTimestamp(): string { | |
| 258 | − return new Date().toISOString(); | |
| 259 | −} | |
| 260 | − | |
| 261 | −function logScope(scope: string): string { | |
| 262 | − return `${scope} run=${APP_RUN_ID}`; | |
| 263 | −} | |
| 264 | − | |
| 265 | −function sanitizeLogValue(value: string): string { | |
| 266 | − return value.replace(/\s+/g, " ").trim(); | |
| 267 | −} | |
| 268 | − | |
| 269 | −function readPersistedBackendObservabilitySettings(): { | |
| 270 | − readonly otlpTracesUrl: string | undefined; | |
| 271 | − readonly otlpMetricsUrl: string | undefined; | |
| 272 | −} { | |
| 273 | − try { | |
| 274 | − if (!FS.existsSync(SERVER_SETTINGS_PATH)) { | |
| 275 | − return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; | |
| 276 | − } | |
| 277 | − return parsePersistedServerObservabilitySettings(FS.readFileSync(SERVER_SETTINGS_PATH, "utf8")); | |
| 278 | − } catch (error) { | |
| 279 | − console.warn("[desktop] failed to read persisted backend observability settings", error); | |
| 280 | − return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; | |
| 281 | − } | |
| 282 | −} | |
| 283 | − | |
| 284 | −function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined { | |
| 285 | − if (!rawPort) { | |
| 286 | − return undefined; | |
| 287 | − } | |
| 288 | − | |
| 289 | − const parsedPort = Number.parseInt(rawPort, 10); | |
| 290 | − if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) { | |
| 291 | − return undefined; | |
| 292 | − } | |
| 293 | − | |
| 294 | − return parsedPort; | |
| 295 | −} | |
| 296 | − | |
| 297 | −function resolveDesktopDevServerUrl(): string { | |
| 298 | − const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); | |
| 299 | − if (!devServerUrl) { | |
| 300 | − throw new Error("VITE_DEV_SERVER_URL is required in desktop development."); | |
| 301 | − } | |
| 302 | − | |
| 303 | − return devServerUrl; | |
| 304 | −} | |
| 305 | − | |
| 306 | −function backendChildEnv(): NodeJS.ProcessEnv { | |
| 307 | − const env = { ...process.env }; | |
| 308 | − delete env.T3CODE_PORT; | |
| 309 | − delete env.T3CODE_MODE; | |
| 310 | − delete env.T3CODE_NO_BROWSER; | |
| 311 | − delete env.T3CODE_HOST; | |
| 312 | − delete env.T3CODE_DESKTOP_WS_URL; | |
| 313 | − delete env.T3CODE_DESKTOP_LAN_ACCESS; | |
| 314 | − delete env.T3CODE_DESKTOP_LAN_HOST; | |
| 315 | − delete env.T3CODE_DESKTOP_HTTPS_ENDPOINTS; | |
| 316 | − delete env.T3CODE_TAILSCALE_SERVE; | |
| 317 | − delete env.T3CODE_TAILSCALE_SERVE_PORT; | |
| 318 | − return env; | |
| 319 | −} | |
| 320 | − | |
| 321 | −function getDesktopServerExposureState(): DesktopServerExposureState { | |
| 322 | − return { | |
| 323 | − mode: desktopServerExposureMode, | |
| 324 | − endpointUrl: backendEndpointUrl, | |
| 325 | − advertisedHost: backendAdvertisedHost, | |
| 326 | − tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, | |
| 327 | − tailscaleServePort: desktopSettings.tailscaleServePort, | |
| 328 | − }; | |
| 329 | −} | |
| 330 | − | |
| 331 | −async function getDesktopAdvertisedEndpoints() { | |
| 332 | − const exposure = resolveDesktopServerExposure({ | |
| 333 | − mode: desktopServerExposureMode, | |
| 334 | − port: backendPort, | |
| 335 | − networkInterfaces: OS.networkInterfaces(), | |
| 336 | − ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}), | |
| 337 | − }); | |
| 338 | − const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ | |
| 339 | − port: backendPort, | |
| 340 | − exposure, | |
| 341 | − customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(), | |
| 342 | − }); | |
| 343 | − const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({ | |
| 344 | − port: backendPort, | |
| 345 | − serveEnabled: desktopSettings.tailscaleServeEnabled, | |
| 346 | − servePort: desktopSettings.tailscaleServePort, | |
| 347 | − networkInterfaces: OS.networkInterfaces(), | |
| 348 | − }); | |
| 349 | − return [...coreEndpoints, ...tailscaleEndpoints]; | |
| 350 | −} | |
| 351 | − | |
| 352 | −function getDesktopSecretStorage() { | |
| 353 | − return { | |
| 354 | − isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), | |
| 355 | − encryptString: (value: string) => safeStorage.encryptString(value), | |
| 356 | − decryptString: (value: Buffer) => safeStorage.decryptString(value), | |
| 357 | − } as const; | |
| 358 | −} | |
| 359 | 14 | |
| 360 | −function resolveAdvertisedHostOverride(): string | undefined { | |
| 361 | − const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); | |
| 362 | − return override && override.length > 0 ? override : undefined; | |
| 363 | −} | |
| 364 | − | |
| 365 | −function resolveCustomHttpsEndpointUrls(): readonly string[] { | |
| 366 | − return (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "") | |
| 367 | − .split(",") | |
| 368 | − .map((entry) => entry.trim()) | |
| 369 | − .filter((entry) => entry.length > 0); | |
| 370 | −} | |
| 371 | − | |
| 372 | −async function applyDesktopServerExposureMode( | |
| 373 | − mode: DesktopServerExposureMode, | |
| 374 | − options?: { | |
| 375 | − readonly persist?: boolean; | |
| 376 | − readonly rejectIfUnavailable?: boolean; | |
| 377 | − }, | |
| 378 | −): Promise<DesktopServerExposureState> { | |
| 379 | − const advertisedHostOverride = resolveAdvertisedHostOverride(); | |
| 380 | − const requestedMode = mode; | |
| 381 | − let exposure = resolveDesktopServerExposure({ | |
| 382 | − mode, | |
| 383 | − port: backendPort, | |
| 384 | − networkInterfaces: OS.networkInterfaces(), | |
| 385 | − ...(advertisedHostOverride ? { advertisedHostOverride } : {}), | |
| 386 | − }); | |
| 387 | − | |
| 388 | − if (requestedMode === "network-accessible" && exposure.endpointUrl === null) { | |
| 389 | − if (options?.rejectIfUnavailable) { | |
| 390 | − throw new Error("No reachable network address is available for this desktop right now."); | |
| 391 | − } | |
| 392 | − exposure = resolveDesktopServerExposure({ | |
| 393 | − mode: "local-only", | |
| 394 | − port: backendPort, | |
| 395 | − networkInterfaces: OS.networkInterfaces(), | |
| 396 | − ...(advertisedHostOverride ? { advertisedHostOverride } : {}), | |
| 397 | − }); | |
| 398 | − } | |
| 399 | − | |
| 400 | − desktopServerExposureMode = exposure.mode; | |
| 401 | − desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode); | |
| 402 | − backendBindHost = exposure.bindHost; | |
| 403 | − backendHttpUrl = exposure.localHttpUrl; | |
| 404 | − backendWsUrl = exposure.localWsUrl; | |
| 405 | − backendEndpointUrl = exposure.endpointUrl; | |
| 406 | − backendAdvertisedHost = exposure.advertisedHost; | |
| 407 | − | |
| 408 | − if (options?.persist) { | |
| 409 | − writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); | |
| 410 | − } | |
| 411 | − | |
| 412 | − return getDesktopServerExposureState(); | |
| 413 | −} | |
| 414 | − | |
| 415 | −async function applyDesktopTailscaleServeEnabled( | |
| 416 | − nextSettings: DesktopSettings, | |
| 417 | −): Promise<DesktopServerExposureState> { | |
| 418 | − desktopSettings = nextSettings; | |
| 419 | − writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); | |
| 420 | − relaunchDesktopApp( | |
| 421 | − desktopSettings.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled", | |
| 422 | − ); | |
| 423 | − return getDesktopServerExposureState(); | |
| 424 | −} | |
| 425 | − | |
| 426 | −function relaunchDesktopApp(reason: string): void { | |
| 427 | − writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`); | |
| 428 | − setImmediate(() => { | |
| 429 | − isQuitting = true; | |
| 430 | − clearUpdatePollTimer(); | |
| 431 | − cancelBackendReadinessWait(); | |
| 432 | − void stopBackendAndWaitForExit() | |
| 433 | − .catch((error) => { | |
| 434 | − writeDesktopLogHeader( | |
| 435 | − `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, | |
| 436 | − ); | |
| 437 | − }) | |
| 438 | − .then(() => desktopSshEnvironmentBridge.dispose().catch(() => undefined)) | |
| 439 | − .finally(() => { | |
| 440 | − restoreStdIoCapture?.(); | |
| 441 | − if (isDevelopment) { | |
| 442 | − app.exit(75); | |
| 443 | − return; | |
| 444 | − } | |
| 445 | − app.relaunch({ | |
| 446 | − execPath: process.execPath, | |
| 447 | − args: process.argv.slice(1), | |
| 448 | − }); | |
| 449 | − app.exit(0); | |
| 450 | − }); | |
| 451 | − }); | |
| 452 | −} | |
| 453 | − | |
| 454 | −function writeDesktopLogHeader(message: string): void { | |
| 455 | − if (!desktopLogSink) return; | |
| 456 | − desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); | |
| 457 | −} | |
| 458 | − | |
| 459 | −function writeBackendSessionBoundary(phase: "START" | "END", details: string): void { | |
| 460 | − if (!backendLogSink) return; | |
| 461 | − const normalizedDetails = sanitizeLogValue(details); | |
| 462 | − backendLogSink.write( | |
| 463 | − `[${logTimestamp()}] ---- APP SESSION ${phase} run=${APP_RUN_ID} ${normalizedDetails} ----\n`, | |
| 464 | − ); | |
| 465 | −} | |
| 466 | − | |
| 467 | −function formatErrorMessage(error: unknown): string { | |
| 468 | − if (error instanceof Error) { | |
| 469 | − return error.message; | |
| 470 | − } | |
| 471 | − return String(error); | |
| 472 | −} | |
| 473 | − | |
| 474 | −function getSafeExternalUrl(rawUrl: unknown): string | null { | |
| 475 | − if (typeof rawUrl !== "string" || rawUrl.length === 0) { | |
| 476 | − return null; | |
| 477 | − } | |
| 478 | − | |
| 479 | − let parsedUrl: URL; | |
| 480 | − try { | |
| 481 | − parsedUrl = new URL(rawUrl); | |
| 482 | − } catch { | |
| 483 | − return null; | |
| 484 | − } | |
| 485 | − | |
| 486 | − if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { | |
| 487 | − return null; | |
| 488 | − } | |
| 489 | − | |
| 490 | − return parsedUrl.toString(); | |
| 491 | −} | |
| 492 | − | |
| 493 | −function getSafeTheme(rawTheme: unknown): DesktopTheme | null { | |
| 494 | − if (rawTheme === "light" || rawTheme === "dark" || rawTheme === "system") { | |
| 495 | − return rawTheme; | |
| 496 | − } | |
| 497 | − | |
| 498 | − return null; | |
| 499 | −} | |
| 500 | − | |
| 501 | −async function waitForBackendHttpReady( | |
| 502 | − baseUrl: string, | |
| 503 | − options?: Parameters<typeof waitForHttpReady>[1], | |
| 504 | −): Promise<void> { | |
| 505 | − cancelBackendReadinessWait(); | |
| 506 | − const controller = new AbortController(); | |
| 507 | − backendReadinessAbortController = controller; | |
| 508 | − | |
| 509 | − try { | |
| 510 | − await waitForHttpReady(baseUrl, { | |
| 511 | − ...options, | |
| 512 | − signal: controller.signal, | |
| 513 | − }); | |
| 514 | − } finally { | |
| 515 | − if (backendReadinessAbortController === controller) { | |
| 516 | − backendReadinessAbortController = null; | |
| 517 | − } | |
| 518 | − } | |
| 519 | −} | |
| 520 | − | |
| 521 | −function cancelBackendReadinessWait(): void { | |
| 522 | − backendReadinessAbortController?.abort(); | |
| 523 | − backendReadinessAbortController = null; | |
| 524 | −} | |
| 525 | − | |
| 526 | −async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> { | |
| 527 | − return await waitForBackendStartupReady({ | |
| 528 | − listeningPromise: backendListeningDetector?.promise ?? null, | |
| 529 | − waitForHttpReady: () => | |
| 530 | − waitForBackendHttpReady(baseUrl, { | |
| 531 | − timeoutMs: 60_000, | |
| 532 | − }), | |
| 533 | − cancelHttpWait: cancelBackendReadinessWait, | |
| 534 | − }); | |
| 535 | −} | |
| 536 | − | |
| 537 | −function ensureInitialBackendWindowOpen(): void { | |
| 538 | − const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; | |
| 539 | − if (isDevelopment || existingWindow !== null || backendInitialWindowOpenInFlight !== null) { | |
| 540 | − return; | |
| 541 | − } | |
| 542 | − | |
| 543 | − const nextOpen = waitForBackendWindowReady(backendHttpUrl) | |
| 544 | − .then((source) => { | |
| 545 | − writeDesktopLogHeader(`bootstrap backend ready source=${source}`); | |
| 546 | − if (mainWindow ?? BrowserWindow.getAllWindows()[0]) { | |
| 547 | − return; | |
| 548 | − } | |
| 549 | − mainWindow = createWindow(); | |
| 550 | − writeDesktopLogHeader("bootstrap main window created"); | |
| 551 | − }) | |
| 552 | − .catch((error) => { | |
| 553 | − if (isBackendReadinessAborted(error)) { | |
| 554 | − return; | |
| 555 | − } | |
| 556 | − writeDesktopLogHeader( | |
| 557 | − `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, | |
| 558 | − ); | |
| 559 | − console.warn("[desktop] backend readiness check timed out during packaged bootstrap", error); | |
| 560 | − }) | |
| 561 | − .finally(() => { | |
| 562 | − if (backendInitialWindowOpenInFlight === nextOpen) { | |
| 563 | − backendInitialWindowOpenInFlight = null; | |
| 564 | − } | |
| 565 | − }); | |
| 566 | − | |
| 567 | − backendInitialWindowOpenInFlight = nextOpen; | |
| 568 | −} | |
| 569 | − | |
| 570 | −function writeDesktopStreamChunk( | |
| 571 | − streamName: "stdout" | "stderr", | |
| 572 | − chunk: unknown, | |
| 573 | − encoding: BufferEncoding | undefined, | |
| 574 | −): void { | |
| 575 | − if (!desktopLogSink) return; | |
| 576 | − const buffer = Buffer.isBuffer(chunk) | |
| 577 | − ? chunk | |
| 578 | − : Buffer.from(String(chunk), typeof chunk === "string" ? encoding : undefined); | |
| 579 | − desktopLogSink.write(`[${logTimestamp()}] [${logScope(streamName)}] `); | |
| 580 | − desktopLogSink.write(buffer); | |
| 581 | − if (buffer.length === 0 || buffer[buffer.length - 1] !== 0x0a) { | |
| 582 | − desktopLogSink.write("\n"); | |
| 583 | − } | |
| 584 | −} | |
| 585 | − | |
| 586 | −function installStdIoCapture(): void { | |
| 587 | − if (!app.isPackaged || desktopLogSink === null || restoreStdIoCapture !== null) { | |
| 588 | − return; | |
| 589 | − } | |
| 590 | − | |
| 591 | − const originalStdoutWrite = process.stdout.write.bind(process.stdout); | |
| 592 | − const originalStderrWrite = process.stderr.write.bind(process.stderr); | |
| 593 | − | |
| 594 | − const patchWrite = | |
| 595 | − (streamName: "stdout" | "stderr", originalWrite: typeof process.stdout.write) => | |
| 596 | − ( | |
| 597 | − chunk: string | Uint8Array, | |
| 598 | − encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), | |
| 599 | − callback?: (error?: Error | null) => void, | |
| 600 | − ): boolean => { | |
| 601 | − const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined; | |
| 602 | − writeDesktopStreamChunk(streamName, chunk, encoding); | |
| 603 | − if (typeof encodingOrCallback === "function") { | |
| 604 | − return originalWrite(chunk, encodingOrCallback); | |
| 605 | − } | |
| 606 | − if (callback !== undefined) { | |
| 607 | − return originalWrite(chunk, encoding, callback); | |
| 608 | − } | |
| 609 | − if (encoding !== undefined) { | |
| 610 | − return originalWrite(chunk, encoding); | |
| 611 | − } | |
| 612 | − return originalWrite(chunk); | |
| 613 | − }; | |
| 614 | − | |
| 615 | − process.stdout.write = patchWrite("stdout", originalStdoutWrite); | |
| 616 | − process.stderr.write = patchWrite("stderr", originalStderrWrite); | |
| 617 | − | |
| 618 | − restoreStdIoCapture = () => { | |
| 619 | − process.stdout.write = originalStdoutWrite; | |
| 620 | − process.stderr.write = originalStderrWrite; | |
| 621 | − restoreStdIoCapture = null; | |
| 622 | − }; | |
| 623 | −} | |
| 624 | − | |
| 625 | −function initializePackagedLogging(): void { | |
| 626 | − if (!app.isPackaged) return; | |
| 627 | − try { | |
| 628 | − desktopLogSink = new RotatingFileSink({ | |
| 629 | − filePath: Path.join(LOG_DIR, "desktop-main.log"), | |
| 630 | − maxBytes: LOG_FILE_MAX_BYTES, | |
| 631 | − maxFiles: LOG_FILE_MAX_FILES, | |
| 632 | − }); | |
| 633 | − backendLogSink = new RotatingFileSink({ | |
| 634 | − filePath: Path.join(LOG_DIR, "server-child.log"), | |
| 635 | − maxBytes: LOG_FILE_MAX_BYTES, | |
| 636 | − maxFiles: LOG_FILE_MAX_FILES, | |
| 637 | − }); | |
| 638 | − installStdIoCapture(); | |
| 639 | − writeDesktopLogHeader(`runtime log capture enabled logDir=${LOG_DIR}`); | |
| 640 | − } catch (error) { | |
| 641 | − // Logging setup should never block app startup. | |
| 642 | − console.error("[desktop] failed to initialize packaged logging", error); | |
| 643 | − } | |
| 644 | −} | |
| 645 | − | |
| 646 | −function captureBackendOutput(child: ChildProcess.ChildProcess): void { | |
| 647 | − const attachStream = (stream: NodeJS.ReadableStream | null | undefined): void => { | |
| 648 | − stream?.on("data", (chunk: unknown) => { | |
| 649 | − const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8"); | |
| 650 | − backendLogSink?.write(buffer); | |
| 651 | − backendListeningDetector?.push(buffer); | |
| 652 | − }); | |
| 653 | − }; | |
| 654 | − | |
| 655 | − attachStream(child.stdout); | |
| 656 | − attachStream(child.stderr); | |
| 657 | −} | |
| 658 | − | |
| 659 | −initializePackagedLogging(); | |
| 660 | − | |
| 661 | −if (process.platform === "linux") { | |
| 662 | − app.commandLine.appendSwitch("class", LINUX_WM_CLASS); | |
| 663 | −} | |
| 664 | − | |
| 665 | −function getDestructiveMenuIcon(): Electron.NativeImage | undefined { | |
| 666 | − if (process.platform !== "darwin") return undefined; | |
| 667 | − if (destructiveMenuIconCache !== undefined) { | |
| 668 | − return destructiveMenuIconCache ?? undefined; | |
| 669 | − } | |
| 670 | − try { | |
| 671 | − const icon = nativeImage.createFromNamedImage("trash").resize({ | |
| 672 | − width: 14, | |
| 673 | − height: 14, | |
| 674 | − }); | |
| 675 | − if (icon.isEmpty()) { | |
| 676 | − destructiveMenuIconCache = null; | |
| 677 | − return undefined; | |
| 678 | − } | |
| 679 | − icon.setTemplateImage(true); | |
| 680 | − destructiveMenuIconCache = icon; | |
| 681 | − return icon; | |
| 682 | − } catch { | |
| 683 | − destructiveMenuIconCache = null; | |
| 684 | − return undefined; | |
| 685 | − } | |
| 686 | −} | |
| 687 | −let updatePollTimer: ReturnType<typeof setInterval> | null = null; | |
| 688 | −let updateStartupTimer: ReturnType<typeof setTimeout> | null = null; | |
| 689 | −let updateCheckInFlight = false; | |
| 690 | −let updateDownloadInFlight = false; | |
| 691 | −let updateInstallInFlight = false; | |
| 692 | −let updaterConfigured = false; | |
| 693 | −let updateState: DesktopUpdateState = initialUpdateState(); | |
| 694 | − | |
| 695 | −const desktopSshEnvironmentBridge = new DesktopSshEnvironmentBridge({ | |
| 696 | − getMainWindow: () => mainWindow, | |
| 697 | − resolveCliRunner: (): RemoteT3RunnerOptions => { | |
| 698 | − if (isDevelopment && DEV_REMOTE_T3_SERVER_ENTRY_PATH.length > 0) { | |
| 699 | − return { nodeScriptPath: DEV_REMOTE_T3_SERVER_ENTRY_PATH }; | |
| 700 | − } | |
| 701 | − return { | |
| 702 | − packageSpec: resolveRemoteT3CliPackageSpec({ | |
| 703 | − appVersion: app.getVersion(), | |
| 704 | − updateChannel: desktopSettings.updateChannel, | |
| 705 | − isDevelopment, | |
| 706 | − }), | |
| 707 | − }; | |
| 708 | − }, | |
| 709 | −}); | |
| 710 | − | |
| 711 | −function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { | |
| 712 | − if (updateInstallInFlight) return "install"; | |
| 713 | − if (updateDownloadInFlight) return "download"; | |
| 714 | − if (updateCheckInFlight) return "check"; | |
| 715 | − return updateState.errorContext; | |
| 716 | −} | |
| 717 | − | |
| 718 | −protocol.registerSchemesAsPrivileged([ | |
| 719 | − { | |
| 720 | − scheme: DESKTOP_SCHEME, | |
| 721 | − privileges: { | |
| 722 | − standard: true, | |
| 723 | − secure: true, | |
| 724 | − supportFetchAPI: true, | |
| 725 | − corsEnabled: true, | |
| 726 | − }, | |
| 727 | − }, | |
| 728 | −]); | |
| 729 | − | |
| 730 | −function resolveAppRoot(): string { | |
| 731 | − if (!app.isPackaged) { | |
| 732 | − return ROOT_DIR; | |
| 733 | − } | |
| 734 | − return app.getAppPath(); | |
| 735 | −} | |
| 736 | − | |
| 737 | −/** Read the baked-in app-update.yml config (if applicable). */ | |
| 738 | −function readAppUpdateYml(): Record<string, string> | null { | |
| 739 | − try { | |
| 740 | − // electron-updater reads from process.resourcesPath in packaged builds, | |
| 741 | − // or dev-app-update.yml via app.getAppPath() in dev. | |
| 742 | − const ymlPath = app.isPackaged | |
| 743 | − ? Path.join(process.resourcesPath, "app-update.yml") | |
| 744 | − : Path.join(app.getAppPath(), "dev-app-update.yml"); | |
| 745 | − const raw = FS.readFileSync(ymlPath, "utf-8"); | |
| 746 | − // The YAML is simple key-value pairs — avoid pulling in a YAML parser by | |
| 747 | − // doing a line-based parse (fields: provider, owner, repo, releaseType, …). | |
| 748 | − const entries: Record<string, string> = {}; | |
| 749 | − for (const line of raw.split("\n")) { | |
| 750 | − const match = line.match(/^(\w+):\s*(.+)$/); | |
| 751 | − if (match?.[1] && match[2]) entries[match[1]] = match[2].trim(); | |
| 752 | − } | |
| 753 | − return entries.provider ? entries : null; | |
| 754 | − } catch { | |
| 755 | − return null; | |
| 756 | − } | |
| 757 | −} | |
| 758 | − | |
| 759 | −function normalizeCommitHash(value: unknown): string | null { | |
| 760 | − if (typeof value !== "string") { | |
| 761 | − return null; | |
| 762 | − } | |
| 763 | − const trimmed = value.trim(); | |
| 764 | − if (!COMMIT_HASH_PATTERN.test(trimmed)) { | |
| 765 | − return null; | |
| 766 | − } | |
| 767 | − return trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase(); | |
| 768 | −} | |
| 769 | − | |
| 770 | −function resolveEmbeddedCommitHash(): string | null { | |
| 771 | − const packageJsonPath = Path.join(resolveAppRoot(), "package.json"); | |
| 772 | − if (!FS.existsSync(packageJsonPath)) { | |
| 773 | − return null; | |
| 774 | − } | |
| 775 | − | |
| 776 | − try { | |
| 777 | − const raw = FS.readFileSync(packageJsonPath, "utf8"); | |
| 778 | − const parsed = JSON.parse(raw) as { t3codeCommitHash?: unknown }; | |
| 779 | − return normalizeCommitHash(parsed.t3codeCommitHash); | |
| 780 | − } catch { | |
| 781 | − return null; | |
| 782 | − } | |
| 783 | −} | |
| 784 | − | |
| 785 | −function resolveAboutCommitHash(): string | null { | |
| 786 | − if (aboutCommitHashCache !== undefined) { | |
| 787 | − return aboutCommitHashCache; | |
| 788 | − } | |
| 789 | − | |
| 790 | − const envCommitHash = normalizeCommitHash(process.env.T3CODE_COMMIT_HASH); | |
| 791 | − if (envCommitHash) { | |
| 792 | − aboutCommitHashCache = envCommitHash; | |
| 793 | − return aboutCommitHashCache; | |
| 794 | − } | |
| 795 | − | |
| 796 | − // Only packaged builds are required to expose commit metadata. | |
| 797 | − if (!app.isPackaged) { | |
| 798 | − aboutCommitHashCache = null; | |
| 799 | − return aboutCommitHashCache; | |
| 800 | − } | |
| 801 | − | |
| 802 | − aboutCommitHashCache = resolveEmbeddedCommitHash(); | |
| 803 | − | |
| 804 | − return aboutCommitHashCache; | |
| 805 | −} | |
| 806 | − | |
| 807 | −function resolveBackendEntry(): string { | |
| 808 | − return Path.join(resolveAppRoot(), "apps/server/dist/bin.mjs"); | |
| 809 | −} | |
| 810 | − | |
| 811 | −function resolveBackendCwd(): string { | |
| 812 | − if (!app.isPackaged) { | |
| 813 | − return resolveAppRoot(); | |
| 814 | − } | |
| 815 | − return OS.homedir(); | |
| 816 | −} | |
| 817 | − | |
| 818 | −function resolveDesktopStaticDir(): string | null { | |
| 819 | − const appRoot = resolveAppRoot(); | |
| 820 | − const candidates = [ | |
| 821 | − Path.join(appRoot, "apps/server/dist/client"), | |
| 822 | − Path.join(appRoot, "apps/web/dist"), | |
| 823 | − ]; | |
| 824 | − | |
| 825 | − for (const candidate of candidates) { | |
| 826 | − if (FS.existsSync(Path.join(candidate, "index.html"))) { | |
| 827 | − return candidate; | |
| 828 | − } | |
| 829 | − } | |
| 830 | − | |
| 831 | − return null; | |
| 832 | −} | |
| 833 | − | |
| 834 | −function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string { | |
| 835 | − const url = new URL(requestUrl); | |
| 836 | − const rawPath = decodeURIComponent(url.pathname); | |
| 837 | − const normalizedPath = Path.posix.normalize(rawPath).replace(/^\/+/, ""); | |
| 838 | − if (normalizedPath.includes("..")) { | |
| 839 | − return Path.join(staticRoot, "index.html"); | |
| 840 | − } | |
| 841 | − | |
| 842 | − const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html"; | |
| 843 | − const resolvedPath = Path.join(staticRoot, requestedPath); | |
| 844 | − | |
| 845 | − if (Path.extname(resolvedPath)) { | |
| 846 | − return resolvedPath; | |
| 847 | − } | |
| 848 | − | |
| 849 | − const nestedIndex = Path.join(resolvedPath, "index.html"); | |
| 850 | − if (FS.existsSync(nestedIndex)) { | |
| 851 | − return nestedIndex; | |
| 852 | − } | |
| 853 | − | |
| 854 | − return Path.join(staticRoot, "index.html"); | |
| 855 | −} | |
| 856 | − | |
| 857 | −function isStaticAssetRequest(requestUrl: string): boolean { | |
| 858 | − try { | |
| 859 | − const url = new URL(requestUrl); | |
| 860 | − return Path.extname(url.pathname).length > 0; | |
| 861 | − } catch { | |
| 862 | − return false; | |
| 863 | − } | |
| 864 | −} | |
| 865 | − | |
| 866 | −function handleFatalStartupError(stage: string, error: unknown): void { | |
| 867 | − const message = formatErrorMessage(error); | |
| 868 | − const detail = | |
| 869 | − error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; | |
| 870 | − writeDesktopLogHeader(`fatal startup error stage=${stage} message=${message}`); | |
| 871 | − console.error(`[desktop] fatal startup error (${stage})`, error); | |
| 872 | − if (!isQuitting) { | |
| 873 | − isQuitting = true; | |
| 874 | − dialog.showErrorBox("T3 Code failed to start", `Stage: ${stage}\n${message}${detail}`); | |
| 875 | − } | |
| 876 | − stopBackend(); | |
| 877 | − restoreStdIoCapture?.(); | |
| 878 | − app.quit(); | |
| 879 | −} | |
| 880 | − | |
| 881 | −function registerDesktopProtocol(): void { | |
| 882 | − if (isDevelopment || desktopProtocolRegistered) return; | |
| 883 | − | |
| 884 | − const staticRoot = resolveDesktopStaticDir(); | |
| 885 | − if (!staticRoot) { | |
| 886 | − throw new Error( | |
| 887 | − "Desktop static bundle missing. Build apps/server (with bundled client) first.", | |
| 888 | − ); | |
| 889 | − } | |
| 890 | − | |
| 891 | − const staticRootResolved = Path.resolve(staticRoot); | |
| 892 | − const staticRootPrefix = `${staticRootResolved}${Path.sep}`; | |
| 893 | − const fallbackIndex = Path.join(staticRootResolved, "index.html"); | |
| 894 | − | |
| 895 | − protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => { | |
| 896 | − try { | |
| 897 | − const candidate = resolveDesktopStaticPath(staticRootResolved, request.url); | |
| 898 | − const resolvedCandidate = Path.resolve(candidate); | |
| 899 | − const isInRoot = | |
| 900 | − resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); | |
| 901 | − const isAssetRequest = isStaticAssetRequest(request.url); | |
| 902 | − | |
| 903 | − if (!isInRoot || !FS.existsSync(resolvedCandidate)) { | |
| 904 | − if (isAssetRequest) { | |
| 905 | − callback({ error: -6 }); | |
| 906 | − return; | |
| 907 | − } | |
| 908 | − callback({ path: fallbackIndex }); | |
| 909 | − return; | |
| 910 | − } | |
| 911 | − | |
| 912 | − callback({ path: resolvedCandidate }); | |
| 913 | − } catch { | |
| 914 | − callback({ path: fallbackIndex }); | |
| 915 | − } | |
| 916 | − }); | |
| 917 | − | |
| 918 | − desktopProtocolRegistered = true; | |
| 919 | −} | |
| 920 | − | |
| 921 | −function dispatchMenuAction(action: string): void { | |
| 922 | − const existingWindow = | |
| 923 | − BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; | |
| 924 | − const targetWindow = existingWindow ?? createWindow(); | |
| 925 | − if (!existingWindow) { | |
| 926 | − mainWindow = targetWindow; | |
| 927 | − } | |
| 928 | − | |
| 929 | − const send = () => { | |
| 930 | − if (targetWindow.isDestroyed()) return; | |
| 931 | − targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); | |
| 932 | − revealWindow(targetWindow); | |
| 933 | − }; | |
| 934 | − | |
| 935 | − if (targetWindow.webContents.isLoadingMainFrame()) { | |
| 936 | − targetWindow.webContents.once("did-finish-load", send); | |
| 937 | − return; | |
| 938 | − } | |
| 939 | − | |
| 940 | − send(); | |
| 941 | −} | |
| 942 | − | |
| 943 | −function handleCheckForUpdatesMenuClick(): void { | |
| 944 | − const hasUpdateFeedConfig = | |
| 945 | − readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); | |
| 946 | − const disabledReason = getAutoUpdateDisabledReason({ | |
| 947 | − isDevelopment, | |
| 948 | − isPackaged: app.isPackaged, | |
| 949 | − platform: process.platform, | |
| 950 | − appImage: process.env.APPIMAGE, | |
| 951 | − disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", | |
| 952 | − hasUpdateFeedConfig, | |
| 953 | − }); | |
| 954 | − if (disabledReason) { | |
| 955 | − console.info("[desktop-updater] Manual update check requested, but updates are disabled."); | |
| 956 | − void dialog.showMessageBox({ | |
| 957 | − type: "info", | |
| 958 | − title: "Updates unavailable", | |
| 959 | − message: "Automatic updates are not available right now.", | |
| 960 | − detail: disabledReason, | |
| 961 | − buttons: ["OK"], | |
| 962 | − }); | |
| 963 | − return; | |
| 964 | − } | |
| 965 | − | |
| 966 | − if (!BrowserWindow.getAllWindows().length) { | |
| 967 | − mainWindow = createWindow(); | |
| 968 | − } | |
| 969 | − void checkForUpdatesFromMenu(); | |
| 970 | −} | |
| 971 | − | |
| 972 | −async function checkForUpdatesFromMenu(): Promise<void> { | |
| 973 | − await checkForUpdates("menu"); | |
| 974 | − | |
| 975 | − if (updateState.status === "up-to-date") { | |
| 976 | − void dialog.showMessageBox({ | |
| 977 | − type: "info", | |
| 978 | − title: "You're up to date!", | |
| 979 | − message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, | |
| 980 | − buttons: ["OK"], | |
| 981 | − }); | |
| 982 | − } else if (updateState.status === "error") { | |
| 983 | − void dialog.showMessageBox({ | |
| 984 | − type: "warning", | |
| 985 | − title: "Update check failed", | |
| 986 | − message: "Could not check for updates.", | |
| 987 | − detail: updateState.message ?? "An unknown error occurred. Please try again later.", | |
| 988 | − buttons: ["OK"], | |
| 989 | − }); | |
| 990 | − } | |
| 991 | −} | |
| 992 | − | |
| 993 | −function configureApplicationMenu(): void { | |
| 994 | − const template: MenuItemConstructorOptions[] = []; | |
| 995 | − | |
| 996 | − if (process.platform === "darwin") { | |
| 997 | − template.push({ | |
| 998 | − label: app.name, | |
| 999 | − submenu: [ | |
| 1000 | − { role: "about" }, | |
| 1001 | − { | |
| 1002 | − label: "Check for Updates...", | |
| 1003 | − click: () => handleCheckForUpdatesMenuClick(), | |
| 1004 | − }, | |
| 1005 | − { type: "separator" }, | |
| 1006 | − { | |
| 1007 | − label: "Settings...", | |
| 1008 | − accelerator: "CmdOrCtrl+,", | |
| 1009 | − click: () => dispatchMenuAction("open-settings"), | |
| 1010 | − }, | |
| 1011 | − { type: "separator" }, | |
| 1012 | − { role: "services" }, | |
| 1013 | − { type: "separator" }, | |
| 1014 | − { role: "hide" }, | |
| 1015 | − { role: "hideOthers" }, | |
| 1016 | − { role: "unhide" }, | |
| 1017 | − { type: "separator" }, | |
| 1018 | − { role: "quit" }, | |
| 1019 | − ], | |
| 1020 | − }); | |
| 1021 | − } | |
| 1022 | − | |
| 1023 | − template.push( | |
| 1024 | − { | |
| 1025 | − label: "File", | |
| 1026 | − submenu: [ | |
| 1027 | − ...(process.platform === "darwin" | |
| 1028 | − ? [] | |
| 1029 | − : [ | |
| 1030 | − { | |
| 1031 | − label: "Settings...", | |
| 1032 | − accelerator: "CmdOrCtrl+,", | |
| 1033 | − click: () => dispatchMenuAction("open-settings"), | |
| 1034 | − }, | |
| 1035 | − { type: "separator" as const }, | |
| 1036 | − ]), | |
| 1037 | − { role: process.platform === "darwin" ? "close" : "quit" }, | |
| 1038 | − ], | |
| 1039 | − }, | |
| 1040 | − { role: "editMenu" }, | |
| 1041 | − { | |
| 1042 | − label: "View", | |
| 1043 | − submenu: [ | |
| 1044 | − { role: "reload" }, | |
| 1045 | − { role: "forceReload" }, | |
| 1046 | − { role: "toggleDevTools" }, | |
| 1047 | − { type: "separator" }, | |
| 1048 | − { role: "resetZoom" }, | |
| 1049 | − { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, | |
| 1050 | − { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, | |
| 1051 | − { role: "zoomOut" }, | |
| 1052 | − { type: "separator" }, | |
| 1053 | − { role: "togglefullscreen" }, | |
| 1054 | − ], | |
| 1055 | − }, | |
| 1056 | − { role: "windowMenu" }, | |
| 1057 | − { | |
| 1058 | − role: "help", | |
| 1059 | − submenu: [ | |
| 1060 | − { | |
| 1061 | − label: "Check for Updates...", | |
| 1062 | − click: () => handleCheckForUpdatesMenuClick(), | |
| 1063 | − }, | |
| 1064 | − ], | |
| 1065 | − }, | |
| 1066 | − ); | |
| 1067 | − | |
| 1068 | − Menu.setApplicationMenu(Menu.buildFromTemplate(template)); | |
| 1069 | −} | |
| 1070 | − | |
| 1071 | −function resolveResourcePath(fileName: string): string | null { | |
| 1072 | − const candidates = [ | |
| 1073 | − Path.join(__dirname, "../resources", fileName), | |
| 1074 | − Path.join(__dirname, "../prod-resources", fileName), | |
| 1075 | − Path.join(process.resourcesPath, "resources", fileName), | |
| 1076 | − Path.join(process.resourcesPath, fileName), | |
| 1077 | − ]; | |
| 1078 | − | |
| 1079 | − for (const candidate of candidates) { | |
| 1080 | − if (FS.existsSync(candidate)) { | |
| 1081 | − return candidate; | |
| 1082 | − } | |
| 1083 | − } | |
| 1084 | − | |
| 1085 | − return null; | |
| 1086 | −} | |
| 1087 | − | |
| 1088 | −function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { | |
| 1089 | − if (isDevelopment && process.platform === "darwin" && ext === "png") { | |
| 1090 | − const developmentDockIconPath = Path.join( | |
| 1091 | − ROOT_DIR, | |
| 1092 | − "assets", | |
| 1093 | − "dev", | |
| 1094 | − "blueprint-macos-1024.png", | |
| 15 | +import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts"; | |
| 16 | +import * as DesktopIpc from "./ipc/DesktopIpc.ts"; | |
| 17 | +import * as ElectronApp from "./electron/ElectronApp.ts"; | |
| 18 | +import * as ElectronDialog from "./electron/ElectronDialog.ts"; | |
| 19 | +import * as ElectronMenu from "./electron/ElectronMenu.ts"; | |
| 20 | +import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; | |
| 21 | +import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts"; | |
| 22 | +import * as ElectronShell from "./electron/ElectronShell.ts"; | |
| 23 | +import * as ElectronTheme from "./electron/ElectronTheme.ts"; | |
| 24 | +import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; | |
| 25 | +import * as ElectronWindow from "./electron/ElectronWindow.ts"; | |
| 26 | +import * as DesktopApp from "./app/DesktopApp.ts"; | |
| 27 | +import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; | |
| 28 | +import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; | |
| 29 | +import * as DesktopAssets from "./app/DesktopAssets.ts"; | |
| 30 | +import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; | |
| 31 | +import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; | |
| 32 | +import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; | |
| 33 | +import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; | |
| 34 | +import * as DesktopObservability from "./app/DesktopObservability.ts"; | |
| 35 | +import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; | |
| 36 | +import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; | |
| 37 | +import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; | |
| 38 | +import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; | |
| 39 | +import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; | |
| 40 | +import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; | |
| 41 | +import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; | |
| 42 | +import * as DesktopSshRemoteApi from "./ssh/DesktopSshRemoteApi.ts"; | |
| 43 | +import * as DesktopState from "./app/DesktopState.ts"; | |
| 44 | +import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; | |
| 45 | +import * as DesktopWindow from "./window/DesktopWindow.ts"; | |
| 46 | + | |
| 47 | +const desktopEnvironmentLayer = Layer.unwrap( | |
| 48 | + Effect.gen(function* () { | |
| 49 | + const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( | |
| 50 | + Effect.flatMap((app) => app.metadata), | |
| 1095 | 51 | ); |
| 1096 | − if (FS.existsSync(developmentDockIconPath)) { | |
| 1097 | − return developmentDockIconPath; | |
| 1098 | − } | |
| 1099 | − } | |
| 1100 | − | |
| 1101 | − return resolveResourcePath(`icon.${ext}`); | |
| 1102 | −} | |
| 1103 | − | |
| 1104 | −/** | |
| 1105 | − * Resolve the Electron userData directory path. | |
| 1106 | − * | |
| 1107 | − * Electron derives the default userData path from `productName` in | |
| 1108 | − * package.json, which currently produces directories with spaces and | |
| 1109 | − * parentheses (e.g. `~/.config/T3 Code (Alpha)` on Linux). This is | |
| 1110 | − * unfriendly for shell usage and violates Linux naming conventions. | |
| 1111 | − * | |
| 1112 | − * We override it to a clean lowercase name (`t3code`). If the legacy | |
| 1113 | − * directory already exists we keep using it so existing users don't | |
| 1114 | − * lose their Chromium profile data (localStorage, cookies, sessions). | |
| 1115 | − */ | |
| 1116 | −function resolveUserDataPath(): string { | |
| 1117 | − const appDataBase = | |
| 1118 | − process.platform === "win32" | |
| 1119 | − ? process.env.APPDATA || Path.join(OS.homedir(), "AppData", "Roaming") | |
| 1120 | − : process.platform === "darwin" | |
| 1121 | − ? Path.join(OS.homedir(), "Library", "Application Support") | |
| 1122 | − : process.env.XDG_CONFIG_HOME || Path.join(OS.homedir(), ".config"); | |
| 1123 | − | |
| 1124 | − const legacyPath = Path.join(appDataBase, LEGACY_USER_DATA_DIR_NAME); | |
| 1125 | − if (FS.existsSync(legacyPath)) { | |
| 1126 | − return legacyPath; | |
| 1127 | − } | |
| 1128 | − | |
| 1129 | − return Path.join(appDataBase, USER_DATA_DIR_NAME); | |
| 1130 | −} | |
| 1131 | − | |
| 1132 | −function configureAppIdentity(): void { | |
| 1133 | − app.setName(APP_DISPLAY_NAME); | |
| 1134 | − const commitHash = resolveAboutCommitHash(); | |
| 1135 | − app.setAboutPanelOptions({ | |
| 1136 | − applicationName: APP_DISPLAY_NAME, | |
| 1137 | − applicationVersion: app.getVersion(), | |
| 1138 | − version: commitHash ?? "unknown", | |
| 1139 | − }); | |
| 1140 | − | |
| 1141 | − if (process.platform === "win32") { | |
| 1142 | − app.setAppUserModelId(APP_USER_MODEL_ID); | |
| 1143 | − } | |
| 1144 | − | |
| 1145 | − if (process.platform === "linux") { | |
| 1146 | − (app as LinuxDesktopNamedApp).setDesktopName?.(LINUX_DESKTOP_ENTRY_NAME); | |
| 1147 | − } | |
| 1148 | − | |
| 1149 | − if (process.platform === "darwin" && app.dock) { | |
| 1150 | − const iconPath = resolveIconPath("png"); | |
| 1151 | − if (iconPath) { | |
| 1152 | − app.dock.setIcon(iconPath); | |
| 1153 | − } | |
| 1154 | − } | |
| 1155 | −} | |
| 1156 | − | |
| 1157 | −function clearUpdatePollTimer(): void { | |
| 1158 | − if (updateStartupTimer) { | |
| 1159 | − clearTimeout(updateStartupTimer); | |
| 1160 | − updateStartupTimer = null; | |
| 1161 | − } | |
| 1162 | − if (updatePollTimer) { | |
| 1163 | − clearInterval(updatePollTimer); | |
| 1164 | − updatePollTimer = null; | |
| 1165 | − } | |
| 1166 | −} | |
| 1167 | − | |
| 1168 | −function revealWindow(window: BrowserWindow): void { | |
| 1169 | − if (window.isDestroyed()) { | |
| 1170 | − return; | |
| 1171 | − } | |
| 1172 | − | |
| 1173 | − if (window.isMinimized()) { | |
| 1174 | − window.restore(); | |
| 1175 | − } | |
| 1176 | − | |
| 1177 | − if (!window.isVisible()) { | |
| 1178 | − window.show(); | |
| 1179 | − } | |
| 1180 | − | |
| 1181 | − if (process.platform === "darwin") { | |
| 1182 | − app.focus({ steal: true }); | |
| 1183 | − } | |
| 1184 | − | |
| 1185 | − window.focus(); | |
| 1186 | −} | |
| 1187 | − | |
| 1188 | −function emitUpdateState(): void { | |
| 1189 | − for (const window of BrowserWindow.getAllWindows()) { | |
| 1190 | − if (window.isDestroyed()) continue; | |
| 1191 | − window.webContents.send(UPDATE_STATE_CHANNEL, updateState); | |
| 1192 | − } | |
| 1193 | −} | |
| 1194 | − | |
| 1195 | −function setUpdateState(patch: Partial<DesktopUpdateState>): void { | |
| 1196 | − updateState = { ...updateState, ...patch }; | |
| 1197 | − emitUpdateState(); | |
| 1198 | −} | |
| 1199 | − | |
| 1200 | −function createBaseUpdateState( | |
| 1201 | − channel: DesktopUpdateChannel, | |
| 1202 | − enabled: boolean, | |
| 1203 | −): DesktopUpdateState { | |
| 1204 | − return { | |
| 1205 | − ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo, channel), | |
| 1206 | − enabled, | |
| 1207 | − status: enabled ? "idle" : "disabled", | |
| 1208 | − }; | |
| 1209 | −} | |
| 1210 | − | |
| 1211 | −function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): void { | |
| 1212 | − autoUpdater.channel = channel; | |
| 1213 | − autoUpdater.allowPrerelease = channel === "nightly"; | |
| 1214 | − autoUpdater.allowDowngrade = channel === "nightly"; | |
| 1215 | − console.info( | |
| 1216 | − `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}, allowDowngrade=${channel === "nightly"}).`, | |
| 1217 | − ); | |
| 1218 | −} | |
| 1219 | − | |
| 1220 | −function shouldEnableAutoUpdates(): boolean { | |
| 1221 | − const hasUpdateFeedConfig = | |
| 1222 | − readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); | |
| 1223 | − return ( | |
| 1224 | − getAutoUpdateDisabledReason({ | |
| 1225 | − isDevelopment, | |
| 1226 | − isPackaged: app.isPackaged, | |
| 52 | + return DesktopEnvironment.layer({ | |
| 53 | + dirname: __dirname, | |
| 54 | + homeDirectory: NodeOS.homedir(), | |
| 1227 | 55 | platform: process.platform, |
| 1228 | − appImage: process.env.APPIMAGE, | |
| 1229 | − disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", | |
| 1230 | − hasUpdateFeedConfig, | |
| 1231 | − }) === null | |
| 1232 | − ); | |
| 1233 | −} | |
| 1234 | − | |
| 1235 | −async function checkForUpdates(reason: string): Promise<boolean> { | |
| 1236 | − if (isQuitting || !updaterConfigured || updateCheckInFlight) return false; | |
| 1237 | − if (updateState.status === "downloading" || updateState.status === "downloaded") { | |
| 1238 | − console.info( | |
| 1239 | − `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`, | |
| 1240 | − ); | |
| 1241 | − return false; | |
| 1242 | − } | |
| 1243 | − updateCheckInFlight = true; | |
| 1244 | − setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString())); | |
| 1245 | − console.info(`[desktop-updater] Checking for updates (${reason})...`); | |
| 1246 | − | |
| 1247 | − try { | |
| 1248 | − await autoUpdater.checkForUpdates(); | |
| 1249 | − return true; | |
| 1250 | − } catch (error: unknown) { | |
| 1251 | − const message = error instanceof Error ? error.message : String(error); | |
| 1252 | − setUpdateState( | |
| 1253 | − reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()), | |
| 1254 | − ); | |
| 1255 | − console.error(`[desktop-updater] Failed to check for updates: ${message}`); | |
| 1256 | − return true; | |
| 1257 | − } finally { | |
| 1258 | − updateCheckInFlight = false; | |
| 1259 | − } | |
| 1260 | −} | |
| 1261 | − | |
| 1262 | −async function downloadAvailableUpdate(): Promise<{ | |
| 1263 | − accepted: boolean; | |
| 1264 | − completed: boolean; | |
| 1265 | −}> { | |
| 1266 | − if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") { | |
| 1267 | − return { accepted: false, completed: false }; | |
| 1268 | − } | |
| 1269 | − updateDownloadInFlight = true; | |
| 1270 | − setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState)); | |
| 1271 | − autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); | |
| 1272 | − console.info("[desktop-updater] Downloading update..."); | |
| 1273 | − | |
| 1274 | − try { | |
| 1275 | − await autoUpdater.downloadUpdate(); | |
| 1276 | − return { accepted: true, completed: true }; | |
| 1277 | − } catch (error: unknown) { | |
| 1278 | − const message = error instanceof Error ? error.message : String(error); | |
| 1279 | − setUpdateState(reduceDesktopUpdateStateOnDownloadFailure(updateState, message)); | |
| 1280 | − console.error(`[desktop-updater] Failed to download update: ${message}`); | |
| 1281 | − return { accepted: true, completed: false }; | |
| 1282 | − } finally { | |
| 1283 | − updateDownloadInFlight = false; | |
| 1284 | − } | |
| 1285 | −} | |
| 1286 | − | |
| 1287 | −async function installDownloadedUpdate(): Promise<{ | |
| 1288 | − accepted: boolean; | |
| 1289 | − completed: boolean; | |
| 1290 | −}> { | |
| 1291 | − if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") { | |
| 1292 | − return { accepted: false, completed: false }; | |
| 1293 | − } | |
| 1294 | − | |
| 1295 | − isQuitting = true; | |
| 1296 | − updateInstallInFlight = true; | |
| 1297 | − clearUpdatePollTimer(); | |
| 1298 | − try { | |
| 1299 | − await stopBackendAndWaitForExit(); | |
| 1300 | − // Destroy all windows before launching the NSIS installer to avoid the installer finding live windows it needs to close. | |
| 1301 | − for (const win of BrowserWindow.getAllWindows()) { | |
| 1302 | − win.destroy(); | |
| 1303 | − } | |
| 1304 | − // `quitAndInstall()` only starts the handoff to the updater. The actual | |
| 1305 | − // install may still fail asynchronously, so keep the action incomplete | |
| 1306 | − // until we either quit or receive an updater error. | |
| 1307 | − autoUpdater.quitAndInstall(true, true); | |
| 1308 | − return { accepted: true, completed: false }; | |
| 1309 | − } catch (error: unknown) { | |
| 1310 | − const message = formatErrorMessage(error); | |
| 1311 | − updateInstallInFlight = false; | |
| 1312 | − isQuitting = false; | |
| 1313 | − setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); | |
| 1314 | − console.error(`[desktop-updater] Failed to install update: ${message}`); | |
| 1315 | − return { accepted: true, completed: false }; | |
| 1316 | − } | |
| 1317 | −} | |
| 1318 | − | |
| 1319 | −function configureAutoUpdater(): void { | |
| 1320 | − const githubToken = | |
| 1321 | − process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || ""; | |
| 1322 | − if (githubToken) { | |
| 1323 | − // When a token is provided, re-configure the feed with `private: true` so | |
| 1324 | − // electron-updater uses the GitHub API (api.github.com) instead of the | |
| 1325 | − // public Atom feed (github.com/…/releases.atom) which rejects Bearer auth. | |
| 1326 | − const appUpdateYml = readAppUpdateYml(); | |
| 1327 | − if (appUpdateYml?.provider === "github") { | |
| 1328 | − autoUpdater.setFeedURL({ | |
| 1329 | − ...appUpdateYml, | |
| 1330 | − provider: "github" as const, | |
| 1331 | − private: true, | |
| 1332 | − token: githubToken, | |
| 1333 | − }); | |
| 1334 | − } | |
| 1335 | − } | |
| 1336 | − | |
| 1337 | − if (process.env.T3CODE_DESKTOP_MOCK_UPDATES) { | |
| 1338 | − autoUpdater.setFeedURL({ | |
| 1339 | − provider: "generic", | |
| 1340 | − url: `http://localhost:${process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000}`, | |
| 56 | + processArch: process.arch, | |
| 57 | + ...metadata, | |
| 1341 | 58 | }); |
| 1342 | − } | |
| 1343 | − | |
| 1344 | − const enabled = shouldEnableAutoUpdates(); | |
| 1345 | − setUpdateState(createBaseUpdateState(desktopSettings.updateChannel, enabled)); | |
| 1346 | − if (!enabled) { | |
| 1347 | − return; | |
| 1348 | − } | |
| 1349 | − updaterConfigured = true; | |
| 1350 | − | |
| 1351 | − autoUpdater.autoDownload = false; | |
| 1352 | − autoUpdater.autoInstallOnAppQuit = false; | |
| 1353 | − applyAutoUpdaterChannel(desktopSettings.updateChannel); | |
| 1354 | − autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); | |
| 1355 | − let lastLoggedDownloadMilestone = -1; | |
| 1356 | − | |
| 1357 | − if (isArm64HostRunningIntelBuild(desktopRuntimeInfo)) { | |
| 1358 | − console.info( | |
| 1359 | − "[desktop-updater] Apple Silicon host detected while running Intel build; updates will switch to arm64 packages.", | |
| 1360 | − ); | |
| 1361 | − } | |
| 1362 | − | |
| 1363 | − autoUpdater.on("checking-for-update", () => { | |
| 1364 | − console.info("[desktop-updater] Looking for updates..."); | |
| 1365 | − }); | |
| 1366 | − autoUpdater.on("update-available", (info) => { | |
| 1367 | − if (!doesVersionMatchDesktopUpdateChannel(info.version, updateState.channel)) { | |
| 1368 | − console.info( | |
| 1369 | − `[desktop-updater] Ignoring ${info.version} because it does not match the selected '${updateState.channel}' channel.`, | |
| 1370 | − ); | |
| 1371 | − setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); | |
| 1372 | − lastLoggedDownloadMilestone = -1; | |
| 1373 | − return; | |
| 1374 | − } | |
| 1375 | − | |
| 1376 | − setUpdateState( | |
| 1377 | − reduceDesktopUpdateStateOnUpdateAvailable( | |
| 1378 | − updateState, | |
| 1379 | − info.version, | |
| 1380 | − new Date().toISOString(), | |
| 1381 | − ), | |
| 1382 | − ); | |
| 1383 | − lastLoggedDownloadMilestone = -1; | |
| 1384 | − console.info(`[desktop-updater] Update available: ${info.version}`); | |
| 1385 | − }); | |
| 1386 | − autoUpdater.on("update-not-available", () => { | |
| 1387 | − setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); | |
| 1388 | − lastLoggedDownloadMilestone = -1; | |
| 1389 | − console.info("[desktop-updater] No updates available."); | |
| 1390 | − }); | |
| 1391 | − autoUpdater.on("error", (error) => { | |
| 1392 | − const message = formatErrorMessage(error); | |
| 1393 | − if (updateInstallInFlight) { | |
| 1394 | − updateInstallInFlight = false; | |
| 1395 | − isQuitting = false; | |
| 1396 | − setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); | |
| 1397 | − console.error(`[desktop-updater] Updater error: ${message}`); | |
| 1398 | − return; | |
| 1399 | − } | |
| 1400 | − if (!updateCheckInFlight && !updateDownloadInFlight) { | |
| 1401 | − setUpdateState({ | |
| 1402 | − status: "error", | |
| 1403 | − message, | |
| 1404 | − checkedAt: new Date().toISOString(), | |
| 1405 | − downloadPercent: null, | |
| 1406 | − errorContext: resolveUpdaterErrorContext(), | |
| 1407 | − canRetry: updateState.availableVersion !== null || updateState.downloadedVersion !== null, | |
| 1408 | − }); | |
| 1409 | − } | |
| 1410 | − console.error(`[desktop-updater] Updater error: ${message}`); | |
| 1411 | − }); | |
| 1412 | − autoUpdater.on("download-progress", (progress) => { | |
| 1413 | − const percent = Math.floor(progress.percent); | |
| 1414 | − if ( | |
| 1415 | − shouldBroadcastDownloadProgress(updateState, progress.percent) || | |
| 1416 | − updateState.message !== null | |
| 1417 | − ) { | |
| 1418 | − setUpdateState(reduceDesktopUpdateStateOnDownloadProgress(updateState, progress.percent)); | |
| 1419 | − } | |
| 1420 | − const milestone = percent - (percent % 10); | |
| 1421 | − if (milestone > lastLoggedDownloadMilestone) { | |
| 1422 | − lastLoggedDownloadMilestone = milestone; | |
| 1423 | − console.info(`[desktop-updater] Download progress: ${percent}%`); | |
| 1424 | − } | |
| 1425 | − }); | |
| 1426 | − autoUpdater.on("update-downloaded", (info) => { | |
| 1427 | − setUpdateState(reduceDesktopUpdateStateOnDownloadComplete(updateState, info.version)); | |
| 1428 | − console.info(`[desktop-updater] Update downloaded: ${info.version}`); | |
| 1429 | − }); | |
| 1430 | − | |
| 1431 | − clearUpdatePollTimer(); | |
| 59 | + }), | |
| 60 | +); | |
| 1432 | 61 | |
| 1433 | − updateStartupTimer = setTimeout(() => { | |
| 1434 | − updateStartupTimer = null; | |
| 1435 | − void checkForUpdates("startup"); | |
| 1436 | − }, AUTO_UPDATE_STARTUP_DELAY_MS); | |
| 1437 | − updateStartupTimer.unref(); | |
| 1438 | − | |
| 1439 | − updatePollTimer = setInterval(() => { | |
| 1440 | − void checkForUpdates("poll"); | |
| 1441 | − }, AUTO_UPDATE_POLL_INTERVAL_MS); | |
| 1442 | − updatePollTimer.unref(); | |
| 1443 | −} | |
| 1444 | −function scheduleBackendRestart(reason: string): void { | |
| 1445 | − if (isQuitting || restartTimer) return; | |
| 1446 | − | |
| 1447 | − const delayMs = Math.min(500 * 2 ** restartAttempt, 10_000); | |
| 1448 | − restartAttempt += 1; | |
| 1449 | − console.error(`[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`); | |
| 1450 | − | |
| 1451 | − restartTimer = setTimeout(() => { | |
| 1452 | − restartTimer = null; | |
| 1453 | − startBackend(); | |
| 1454 | − }, delayMs); | |
| 1455 | −} | |
| 1456 | − | |
| 1457 | −function startBackend(): void { | |
| 1458 | − if (isQuitting || backendProcess) return; | |
| 1459 | − | |
| 1460 | − backendObservabilitySettings = readPersistedBackendObservabilitySettings(); | |
| 1461 | − const backendEntry = resolveBackendEntry(); | |
| 1462 | − if (!FS.existsSync(backendEntry)) { | |
| 1463 | − scheduleBackendRestart(`missing server entry at ${backendEntry}`); | |
| 1464 | − return; | |
| 1465 | − } | |
| 1466 | − | |
| 1467 | − const captureBackendLogs = !isDevelopment; | |
| 1468 | − const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], { | |
| 1469 | − cwd: resolveBackendCwd(), | |
| 1470 | − // In Electron main, process.execPath points to the Electron binary. | |
| 1471 | − // Run the child in Node mode so this backend process does not become a GUI app instance. | |
| 1472 | − env: { | |
| 1473 | − ...backendChildEnv(), | |
| 1474 | − ELECTRON_RUN_AS_NODE: "1", | |
| 1475 | − }, | |
| 1476 | − stdio: captureBackendLogs | |
| 1477 | − ? ["ignore", "pipe", "pipe", "pipe"] | |
| 1478 | − : ["ignore", "inherit", "inherit", "pipe"], | |
| 1479 | − }); | |
| 1480 | − const bootstrapStream = child.stdio[3]; | |
| 1481 | − if (bootstrapStream && "write" in bootstrapStream) { | |
| 1482 | − bootstrapStream.write( | |
| 1483 | − `${JSON.stringify({ | |
| 1484 | − mode: "desktop", | |
| 1485 | − noBrowser: true, | |
| 1486 | − port: backendPort, | |
| 1487 | − t3Home: BASE_DIR, | |
| 1488 | − host: backendBindHost, | |
| 1489 | − desktopBootstrapToken: backendBootstrapToken, | |
| 1490 | − tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, | |
| 1491 | − tailscaleServePort: desktopSettings.tailscaleServePort, | |
| 1492 | − ...(backendObservabilitySettings.otlpTracesUrl | |
| 1493 | − ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } | |
| 1494 | − : {}), | |
| 1495 | − ...(backendObservabilitySettings.otlpMetricsUrl | |
| 1496 | − ? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl } | |
| 1497 | − : {}), | |
| 1498 | − })}\n`, | |
| 1499 | − ); | |
| 1500 | − bootstrapStream.end(); | |
| 1501 | − } else { | |
| 1502 | − child.kill("SIGTERM"); | |
| 1503 | − scheduleBackendRestart("missing desktop bootstrap pipe"); | |
| 1504 | − return; | |
| 62 | +const resolveDesktopSshCliRunner = ( | |
| 63 | + environment: DesktopEnvironment.DesktopEnvironmentShape, | |
| 64 | + settings: DesktopSettingsValue, | |
| 65 | +): RemoteT3RunnerOptions => { | |
| 66 | + const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); | |
| 67 | + if (environment.isDevelopment && devRemoteEntryPath !== undefined) { | |
| 68 | + return { nodeScriptPath: devRemoteEntryPath }; | |
| 1505 | 69 | } |
| 1506 | − const listeningDetector = new ServerListeningDetector(); | |
| 1507 | − backendListeningDetector = listeningDetector; | |
| 1508 | − backendProcess = child; | |
| 1509 | − let backendSessionClosed = false; | |
| 1510 | − const closeBackendSession = (details: string) => { | |
| 1511 | − if (backendSessionClosed) return; | |
| 1512 | − backendSessionClosed = true; | |
| 1513 | − writeBackendSessionBoundary("END", details); | |
| 1514 | − }; | |
| 1515 | − writeBackendSessionBoundary( | |
| 1516 | − "START", | |
| 1517 | − `pid=${child.pid ?? "unknown"} port=${backendPort} cwd=${resolveBackendCwd()}`, | |
| 1518 | − ); | |
| 1519 | − captureBackendOutput(child); | |
| 1520 | − | |
| 1521 | − child.once("spawn", () => { | |
| 1522 | − restartAttempt = 0; | |
| 1523 | − }); | |
| 1524 | − | |
| 1525 | − child.on("error", (error) => { | |
| 1526 | − if (backendListeningDetector === listeningDetector) { | |
| 1527 | − listeningDetector.fail(error); | |
| 1528 | − backendListeningDetector = null; | |
| 1529 | − } | |
| 1530 | − const wasExpected = expectedBackendExitChildren.has(child); | |
| 1531 | − if (backendProcess === child) { | |
| 1532 | − backendProcess = null; | |
| 1533 | − } | |
| 1534 | − closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`); | |
| 1535 | − if (wasExpected) { | |
| 1536 | − return; | |
| 1537 | − } | |
| 1538 | − scheduleBackendRestart(error.message); | |
| 1539 | − }); | |
| 1540 | − | |
| 1541 | − child.on("exit", (code, signal) => { | |
| 1542 | − if (backendListeningDetector === listeningDetector) { | |
| 1543 | − listeningDetector.fail( | |
| 1544 | − new Error( | |
| 1545 | − `backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`, | |
| 1546 | − ), | |
| 1547 | − ); | |
| 1548 | − backendListeningDetector = null; | |
| 1549 | − } | |
| 1550 | − const wasExpected = expectedBackendExitChildren.has(child); | |
| 1551 | − if (backendProcess === child) { | |
| 1552 | − backendProcess = null; | |
| 1553 | − } | |
| 1554 | − closeBackendSession( | |
| 1555 | − `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`, | |
| 1556 | − ); | |
| 1557 | − if (isQuitting || wasExpected) return; | |
| 1558 | − const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; | |
| 1559 | − scheduleBackendRestart(reason); | |
| 1560 | − }); | |
| 1561 | − | |
| 1562 | − ensureInitialBackendWindowOpen(); | |
| 1563 | −} | |
| 1564 | − | |
| 1565 | −function stopBackend(): void { | |
| 1566 | − cancelBackendReadinessWait(); | |
| 1567 | − backendListeningDetector = null; | |
| 1568 | − if (restartTimer) { | |
| 1569 | − clearTimeout(restartTimer); | |
| 1570 | − restartTimer = null; | |
| 1571 | − } | |
| 1572 | − | |
| 1573 | − const child = backendProcess; | |
| 1574 | − backendProcess = null; | |
| 1575 | − if (!child) return; | |
| 1576 | − | |
| 1577 | − if (child.exitCode === null && child.signalCode === null) { | |
| 1578 | − expectedBackendExitChildren.add(child); | |
| 1579 | − child.kill("SIGTERM"); | |
| 1580 | − setTimeout(() => { | |
| 1581 | − if (child.exitCode === null && child.signalCode === null) { | |
| 1582 | − child.kill("SIGKILL"); | |
| 1583 | − } | |
| 1584 | − }, 2_000).unref(); | |
| 1585 | − } | |
| 1586 | −} | |
| 1587 | − | |
| 1588 | −async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { | |
| 1589 | − cancelBackendReadinessWait(); | |
| 1590 | − if (restartTimer) { | |
| 1591 | − clearTimeout(restartTimer); | |
| 1592 | − restartTimer = null; | |
| 1593 | − } | |
| 1594 | − | |
| 1595 | − const child = backendProcess; | |
| 1596 | − backendProcess = null; | |
| 1597 | − if (!child) return; | |
| 1598 | − const backendChild = child; | |
| 1599 | − if (backendChild.exitCode !== null || backendChild.signalCode !== null) return; | |
| 1600 | − expectedBackendExitChildren.add(backendChild); | |
| 1601 | − | |
| 1602 | − await new Promise<void>((resolve) => { | |
| 1603 | − let settled = false; | |
| 1604 | − let forceKillTimer: ReturnType<typeof setTimeout> | null = null; | |
| 1605 | − let exitTimeoutTimer: ReturnType<typeof setTimeout> | null = null; | |
| 1606 | − | |
| 1607 | − function settle(): void { | |
| 1608 | − if (settled) return; | |
| 1609 | − settled = true; | |
| 1610 | − backendChild.off("exit", onExit); | |
| 1611 | − if (forceKillTimer) { | |
| 1612 | − clearTimeout(forceKillTimer); | |
| 1613 | − } | |
| 1614 | − if (exitTimeoutTimer) { | |
| 1615 | − clearTimeout(exitTimeoutTimer); | |
| 1616 | − } | |
| 1617 | − resolve(); | |
| 1618 | − } | |
| 1619 | − | |
| 1620 | − function onExit(): void { | |
| 1621 | − settle(); | |
| 1622 | − } | |
| 1623 | − | |
| 1624 | − backendChild.once("exit", onExit); | |
| 1625 | − backendChild.kill("SIGTERM"); | |
| 1626 | − | |
| 1627 | − forceKillTimer = setTimeout(() => { | |
| 1628 | − if (backendChild.exitCode === null && backendChild.signalCode === null) { | |
| 1629 | − backendChild.kill("SIGKILL"); | |
| 1630 | − } | |
| 1631 | − }, 2_000); | |
| 1632 | − forceKillTimer.unref(); | |
| 1633 | − | |
| 1634 | − exitTimeoutTimer = setTimeout(() => { | |
| 1635 | − settle(); | |
| 1636 | − }, timeoutMs); | |
| 1637 | − exitTimeoutTimer.unref(); | |
| 1638 | − }); | |
| 1639 | −} | |
| 1640 | − | |
| 1641 | −function registerIpcHandlers(): void { | |
| 1642 | − ipcMain.removeAllListeners(GET_APP_BRANDING_CHANNEL); | |
| 1643 | − ipcMain.on(GET_APP_BRANDING_CHANNEL, (event) => { | |
| 1644 | − event.returnValue = desktopAppBranding; | |
| 1645 | − }); | |
| 1646 | − | |
| 1647 | − ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); | |
| 1648 | − ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { | |
| 1649 | − event.returnValue = { | |
| 1650 | − label: "Local environment", | |
| 1651 | − httpBaseUrl: backendHttpUrl || null, | |
| 1652 | − wsBaseUrl: backendWsUrl || null, | |
| 1653 | − bootstrapToken: backendBootstrapToken || undefined, | |
| 1654 | − } as const; | |
| 1655 | − }); | |
| 1656 | − | |
| 1657 | − ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL); | |
| 1658 | − ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => readClientSettings(CLIENT_SETTINGS_PATH)); | |
| 1659 | − | |
| 1660 | − ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL); | |
| 1661 | − ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => { | |
| 1662 | − if (typeof rawSettings !== "object" || rawSettings === null) { | |
| 1663 | − throw new Error("Invalid client settings payload."); | |
| 1664 | − } | |
| 1665 | − | |
| 1666 | − writeClientSettings(CLIENT_SETTINGS_PATH, rawSettings as ClientSettings); | |
| 1667 | − }); | |
| 1668 | − | |
| 1669 | − ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); | |
| 1670 | − ipcMain.handle(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async () => | |
| 1671 | − readSavedEnvironmentRegistry(SAVED_ENVIRONMENT_REGISTRY_PATH), | |
| 1672 | − ); | |
| 1673 | − | |
| 1674 | − ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); | |
| 1675 | − ipcMain.handle(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async (_event, rawRecords: unknown) => { | |
| 1676 | − if (!Array.isArray(rawRecords)) { | |
| 1677 | − throw new Error("Invalid saved environment registry payload."); | |
| 1678 | − } | |
| 1679 | − | |
| 1680 | − writeSavedEnvironmentRegistry( | |
| 1681 | − SAVED_ENVIRONMENT_REGISTRY_PATH, | |
| 1682 | − rawRecords as readonly PersistedSavedEnvironmentRecord[], | |
| 1683 | − ); | |
| 1684 | − }); | |
| 1685 | − | |
| 1686 | − ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL); | |
| 1687 | − ipcMain.handle( | |
| 1688 | − GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, | |
| 1689 | − async (_event, rawEnvironmentId: unknown) => { | |
| 1690 | − if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { | |
| 1691 | − return null; | |
| 1692 | − } | |
| 1693 | − | |
| 1694 | − return readSavedEnvironmentSecret({ | |
| 1695 | − registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, | |
| 1696 | − environmentId: rawEnvironmentId, | |
| 1697 | − secretStorage: getDesktopSecretStorage(), | |
| 1698 | − }); | |
| 1699 | − }, | |
| 1700 | − ); | |
| 1701 | − | |
| 1702 | − ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL); | |
| 1703 | − ipcMain.handle( | |
| 1704 | − SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, | |
| 1705 | − async (_event, rawEnvironmentId: unknown, rawSecret: unknown) => { | |
| 1706 | − if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { | |
| 1707 | − throw new Error("Invalid saved environment id."); | |
| 1708 | − } | |
| 1709 | − if (typeof rawSecret !== "string" || rawSecret.trim().length === 0) { | |
| 1710 | − throw new Error("Invalid saved environment secret."); | |
| 1711 | − } | |
| 1712 | − | |
| 1713 | − return writeSavedEnvironmentSecret({ | |
| 1714 | − registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, | |
| 1715 | − environmentId: rawEnvironmentId, | |
| 1716 | − secret: rawSecret, | |
| 1717 | − secretStorage: getDesktopSecretStorage(), | |
| 1718 | − }); | |
| 1719 | − }, | |
| 1720 | − ); | |
| 1721 | − | |
| 1722 | − ipcMain.removeHandler(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL); | |
| 1723 | − ipcMain.handle( | |
| 1724 | − REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, | |
| 1725 | − async (_event, rawEnvironmentId: unknown) => { | |
| 1726 | − if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { | |
| 1727 | − return; | |
| 1728 | − } | |
| 1729 | − | |
| 1730 | − removeSavedEnvironmentSecret({ | |
| 1731 | − registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, | |
| 1732 | − environmentId: rawEnvironmentId, | |
| 1733 | − }); | |
| 1734 | − }, | |
| 1735 | − ); | |
| 1736 | − | |
| 1737 | − desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain); | |
| 1738 | − | |
| 1739 | − ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); | |
| 1740 | − ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); | |
| 1741 | − | |
| 1742 | − ipcMain.removeHandler(SET_SERVER_EXPOSURE_MODE_CHANNEL); | |
| 1743 | − ipcMain.handle(SET_SERVER_EXPOSURE_MODE_CHANNEL, async (_event, rawMode: unknown) => { | |
| 1744 | − if (rawMode !== "local-only" && rawMode !== "network-accessible") { | |
| 1745 | − throw new Error("Invalid desktop server exposure input."); | |
| 1746 | − } | |
| 1747 | − | |
| 1748 | − const nextMode = rawMode as DesktopServerExposureMode; | |
| 1749 | − if (nextMode === desktopServerExposureMode) { | |
| 1750 | − return getDesktopServerExposureState(); | |
| 1751 | − } | |
| 1752 | − | |
| 1753 | − const nextState = await applyDesktopServerExposureMode(nextMode, { | |
| 1754 | − persist: true, | |
| 1755 | − rejectIfUnavailable: true, | |
| 1756 | − }); | |
| 1757 | − relaunchDesktopApp(`serverExposureMode=${nextMode}`); | |
| 1758 | − return nextState; | |
| 1759 | − }); | |
| 1760 | − | |
| 1761 | − ipcMain.removeHandler(SET_TAILSCALE_SERVE_ENABLED_CHANNEL); | |
| 1762 | − ipcMain.handle(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, async (_event, rawInput: unknown) => { | |
| 1763 | − if (typeof rawInput !== "object" || rawInput === null) { | |
| 1764 | − throw new Error("Invalid Tailscale Serve input."); | |
| 1765 | − } | |
| 1766 | − const input = rawInput as { | |
| 1767 | − readonly enabled?: unknown; | |
| 1768 | − readonly port?: unknown; | |
| 1769 | − }; | |
| 1770 | − if (typeof input.enabled !== "boolean") { | |
| 1771 | − throw new Error("Invalid Tailscale Serve input."); | |
| 1772 | − } | |
| 1773 | − const nextSettings = setDesktopTailscaleServePreference(desktopSettings, { | |
| 1774 | − enabled: input.enabled, | |
| 1775 | − ...(typeof input.port === "number" ? { port: input.port } : {}), | |
| 1776 | − }); | |
| 1777 | − if (nextSettings === desktopSettings) { | |
| 1778 | − return getDesktopServerExposureState(); | |
| 1779 | − } | |
| 1780 | − return applyDesktopTailscaleServeEnabled(nextSettings); | |
| 1781 | − }); | |
| 1782 | − | |
| 1783 | − ipcMain.removeHandler(GET_ADVERTISED_ENDPOINTS_CHANNEL); | |
| 1784 | − ipcMain.handle(GET_ADVERTISED_ENDPOINTS_CHANNEL, async () => getDesktopAdvertisedEndpoints()); | |
| 1785 | − | |
| 1786 | − ipcMain.removeHandler(PICK_FOLDER_CHANNEL); | |
| 1787 | − ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { | |
| 1788 | − const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; | |
| 1789 | − const defaultPath = resolvePickFolderDefaultPath(rawOptions); | |
| 1790 | − const openDialogOptions: OpenDialogOptions = { | |
| 1791 | − properties: ["openDirectory", "createDirectory"], | |
| 1792 | − ...(defaultPath ? { defaultPath } : {}), | |
| 1793 | − }; | |
| 1794 | − const result = owner | |
| 1795 | − ? await dialog.showOpenDialog(owner, openDialogOptions) | |
| 1796 | − : await dialog.showOpenDialog(openDialogOptions); | |
| 1797 | − if (result.canceled) return null; | |
| 1798 | − return result.filePaths[0] ?? null; | |
| 1799 | − }); | |
| 1800 | − | |
| 1801 | − ipcMain.removeHandler(CONFIRM_CHANNEL); | |
| 1802 | − ipcMain.handle(CONFIRM_CHANNEL, async (_event, message: unknown) => { | |
| 1803 | − if (typeof message !== "string") { | |
| 1804 | − return false; | |
| 1805 | − } | |
| 1806 | − | |
| 1807 | − const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; | |
| 1808 | − return showDesktopConfirmDialog(message, owner); | |
| 1809 | − }); | |
| 1810 | − | |
| 1811 | − ipcMain.removeHandler(SET_THEME_CHANNEL); | |
| 1812 | − ipcMain.handle(SET_THEME_CHANNEL, async (_event, rawTheme: unknown) => { | |
| 1813 | − const theme = getSafeTheme(rawTheme); | |
| 1814 | − if (!theme) { | |
| 1815 | − return; | |
| 1816 | − } | |
| 1817 | − | |
| 1818 | − nativeTheme.themeSource = theme; | |
| 1819 | − }); | |
| 1820 | − | |
| 1821 | − ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); | |
| 1822 | − ipcMain.handle( | |
| 1823 | − CONTEXT_MENU_CHANNEL, | |
| 1824 | − async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { | |
| 1825 | − const normalizedItems = normalizeContextMenuItems(items); | |
| 1826 | − if (normalizedItems.length === 0) { | |
| 1827 | − return null; | |
| 1828 | − } | |
| 1829 | − | |
| 1830 | − const popupPosition = | |
| 1831 | − position && | |
| 1832 | − Number.isFinite(position.x) && | |
| 1833 | − Number.isFinite(position.y) && | |
| 1834 | − position.x >= 0 && | |
| 1835 | − position.y >= 0 | |
| 1836 | − ? { | |
| 1837 | − x: Math.floor(position.x), | |
| 1838 | − y: Math.floor(position.y), | |
| 1839 | − } | |
| 1840 | − : null; | |
| 1841 | − | |
| 1842 | − const window = BrowserWindow.getFocusedWindow() ?? mainWindow; | |
| 1843 | − if (!window) return null; | |
| 1844 | − | |
| 1845 | − return new Promise<string | null>((resolve) => { | |
| 1846 | − const buildTemplate = ( | |
| 1847 | − entries: readonly ContextMenuItem[], | |
| 1848 | − ): MenuItemConstructorOptions[] => { | |
| 1849 | − const template: MenuItemConstructorOptions[] = []; | |
| 1850 | − let hasInsertedDestructiveSeparator = false; | |
| 1851 | − for (const item of entries) { | |
| 1852 | − if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { | |
| 1853 | − template.push({ type: "separator" }); | |
| 1854 | − hasInsertedDestructiveSeparator = true; | |
| 1855 | − } | |
| 1856 | − const itemOption: MenuItemConstructorOptions = { | |
| 1857 | − label: item.label, | |
| 1858 | − enabled: !item.disabled, | |
| 1859 | − }; | |
| 1860 | − if (item.children && item.children.length > 0) { | |
| 1861 | − itemOption.submenu = buildTemplate(item.children); | |
| 1862 | − } else { | |
| 1863 | − itemOption.click = () => resolve(item.id); | |
| 1864 | − } | |
| 1865 | − if (item.destructive && (!item.children || item.children.length === 0)) { | |
| 1866 | − const destructiveIcon = getDestructiveMenuIcon(); | |
| 1867 | − if (destructiveIcon) { | |
| 1868 | − itemOption.icon = destructiveIcon; | |
| 1869 | − } | |
| 1870 | − } | |
| 1871 | − template.push(itemOption); | |
| 1872 | − } | |
| 1873 | − return template; | |
| 1874 | − }; | |
| 1875 | − | |
| 1876 | − const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems)); | |
| 1877 | − menu.popup({ | |
| 1878 | − window, | |
| 1879 | − ...popupPosition, | |
| 1880 | − callback: () => resolve(null), | |
| 1881 | − }); | |
| 1882 | − }); | |
| 1883 | − }, | |
| 1884 | − ); | |
| 1885 | − | |
| 1886 | − ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); | |
| 1887 | − ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { | |
| 1888 | − const externalUrl = getSafeExternalUrl(rawUrl); | |
| 1889 | − if (!externalUrl) { | |
| 1890 | − return false; | |
| 1891 | − } | |
| 1892 | − | |
| 1893 | − try { | |
| 1894 | − await shell.openExternal(externalUrl); | |
| 1895 | − return true; | |
| 1896 | − } catch { | |
| 1897 | − return false; | |
| 1898 | − } | |
| 1899 | − }); | |
| 1900 | − | |
| 1901 | − ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL); | |
| 1902 | − ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState); | |
| 1903 | − | |
| 1904 | − ipcMain.removeHandler(UPDATE_SET_CHANNEL_CHANNEL); | |
| 1905 | − ipcMain.handle(UPDATE_SET_CHANNEL_CHANNEL, async (_event, rawChannel: unknown) => { | |
| 1906 | − if (rawChannel !== "latest" && rawChannel !== "nightly") { | |
| 1907 | − throw new Error("Invalid desktop update channel input."); | |
| 1908 | − } | |
| 1909 | − if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) { | |
| 1910 | − throw new Error("Cannot change update tracks while an update action is in progress."); | |
| 1911 | − } | |
| 1912 | − | |
| 1913 | − const nextChannel = rawChannel as DesktopUpdateChannel; | |
| 1914 | − | |
| 1915 | − desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel); | |
| 1916 | − writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); | |
| 1917 | − | |
| 1918 | − if (nextChannel === updateState.channel) { | |
| 1919 | − return updateState; | |
| 1920 | − } | |
| 1921 | − | |
| 1922 | − const enabled = shouldEnableAutoUpdates(); | |
| 1923 | − setUpdateState(createBaseUpdateState(nextChannel, enabled)); | |
| 1924 | − | |
| 1925 | − if (!enabled || !updaterConfigured) { | |
| 1926 | − return updateState; | |
| 1927 | − } | |
| 1928 | − | |
| 1929 | − applyAutoUpdaterChannel(nextChannel); | |
| 1930 | − const allowDowngrade = autoUpdater.allowDowngrade; | |
| 1931 | − // An explicit channel switch should allow the immediate nightly->stable rollback path. | |
| 1932 | − autoUpdater.allowDowngrade = true; | |
| 1933 | − try { | |
| 1934 | − await checkForUpdates("channel-change"); | |
| 1935 | − } finally { | |
| 1936 | − autoUpdater.allowDowngrade = allowDowngrade; | |
| 1937 | − } | |
| 1938 | − return updateState; | |
| 1939 | − }); | |
| 1940 | − | |
| 1941 | − ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL); | |
| 1942 | − ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => { | |
| 1943 | − const result = await downloadAvailableUpdate(); | |
| 1944 | − return { | |
| 1945 | − accepted: result.accepted, | |
| 1946 | − completed: result.completed, | |
| 1947 | − state: updateState, | |
| 1948 | − } satisfies DesktopUpdateActionResult; | |
| 1949 | − }); | |
| 1950 | − | |
| 1951 | − ipcMain.removeHandler(UPDATE_INSTALL_CHANNEL); | |
| 1952 | − ipcMain.handle(UPDATE_INSTALL_CHANNEL, async () => { | |
| 1953 | − if (isQuitting) { | |
| 1954 | − return { | |
| 1955 | − accepted: false, | |
| 1956 | − completed: false, | |
| 1957 | − state: updateState, | |
| 1958 | − } satisfies DesktopUpdateActionResult; | |
| 1959 | − } | |
| 1960 | − const result = await installDownloadedUpdate(); | |
| 1961 | − return { | |
| 1962 | − accepted: result.accepted, | |
| 1963 | − completed: result.completed, | |
| 1964 | − state: updateState, | |
| 1965 | − } satisfies DesktopUpdateActionResult; | |
| 1966 | − }); | |
| 1967 | − | |
| 1968 | − ipcMain.removeHandler(UPDATE_CHECK_CHANNEL); | |
| 1969 | − ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => { | |
| 1970 | − if (!updaterConfigured) { | |
| 1971 | − return { | |
| 1972 | − checked: false, | |
| 1973 | − state: updateState, | |
| 1974 | − } satisfies DesktopUpdateCheckResult; | |
| 1975 | − } | |
| 1976 | − const checked = await checkForUpdates("web-ui"); | |
| 1977 | − return { | |
| 1978 | − checked, | |
| 1979 | − state: updateState, | |
| 1980 | − } satisfies DesktopUpdateCheckResult; | |
| 1981 | − }); | |
| 1982 | −} | |
| 1983 | − | |
| 1984 | −function getIconOption(): { icon: string } | Record<string, never> { | |
| 1985 | − if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle | |
| 1986 | − const ext = process.platform === "win32" ? "ico" : "png"; | |
| 1987 | − const iconPath = resolveIconPath(ext); | |
| 1988 | − return iconPath ? { icon: iconPath } : {}; | |
| 1989 | −} | |
| 1990 | − | |
| 1991 | −function getInitialWindowBackgroundColor(): string { | |
| 1992 | − return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; | |
| 1993 | −} | |
| 1994 | − | |
| 1995 | −function getWindowTitleBarOptions(): WindowTitleBarOptions { | |
| 1996 | − if (process.platform === "darwin") { | |
| 1997 | − return { | |
| 1998 | − titleBarStyle: "hiddenInset", | |
| 1999 | − trafficLightPosition: { x: 16, y: 18 }, | |
| 2000 | − }; | |
| 2001 | − } | |
| 2002 | − | |
| 2003 | 70 | return { |
| 2004 | − titleBarStyle: "hidden", | |
| 2005 | − titleBarOverlay: { | |
| 2006 | − color: TITLEBAR_COLOR, | |
| 2007 | − height: TITLEBAR_HEIGHT, | |
| 2008 | − symbolColor: nativeTheme.shouldUseDarkColors | |
| 2009 | − ? TITLEBAR_DARK_SYMBOL_COLOR | |
| 2010 | − : TITLEBAR_LIGHT_SYMBOL_COLOR, | |
| 2011 | − }, | |
| 71 | + packageSpec: resolveRemoteT3CliPackageSpec({ | |
| 72 | + appVersion: environment.appVersion, | |
| 73 | + updateChannel: settings.updateChannel, | |
| 74 | + isDevelopment: environment.isDevelopment, | |
| 75 | + }), | |
| 2012 | 76 | }; |
| 2013 | −} | |
| 2014 | − | |
| 2015 | −function syncWindowAppearance(window: BrowserWindow): void { | |
| 2016 | − if (window.isDestroyed()) { | |
| 2017 | − return; | |
| 2018 | − } | |
| 2019 | − | |
| 2020 | − window.setBackgroundColor(getInitialWindowBackgroundColor()); | |
| 2021 | − const { titleBarOverlay } = getWindowTitleBarOptions(); | |
| 2022 | − if (typeof titleBarOverlay === "object") { | |
| 2023 | − window.setTitleBarOverlay(titleBarOverlay); | |
| 2024 | − } | |
| 2025 | −} | |
| 2026 | − | |
| 2027 | −function syncAllWindowAppearance(): void { | |
| 2028 | − for (const window of BrowserWindow.getAllWindows()) { | |
| 2029 | − syncWindowAppearance(window); | |
| 2030 | − } | |
| 2031 | −} | |
| 2032 | − | |
| 2033 | −nativeTheme.on("updated", syncAllWindowAppearance); | |
| 2034 | − | |
| 2035 | −function createWindow(): BrowserWindow { | |
| 2036 | − const window = new BrowserWindow({ | |
| 2037 | − width: 1100, | |
| 2038 | − height: 780, | |
| 2039 | − minWidth: 840, | |
| 2040 | − minHeight: 620, | |
| 2041 | − show: false, | |
| 2042 | − autoHideMenuBar: true, | |
| 2043 | − backgroundColor: getInitialWindowBackgroundColor(), | |
| 2044 | − ...getIconOption(), | |
| 2045 | − title: APP_DISPLAY_NAME, | |
| 2046 | − ...getWindowTitleBarOptions(), | |
| 2047 | − webPreferences: { | |
| 2048 | − preload: Path.join(__dirname, "preload.cjs"), | |
| 2049 | − contextIsolation: true, | |
| 2050 | − nodeIntegration: false, | |
| 2051 | − sandbox: true, | |
| 2052 | − }, | |
| 2053 | − }); | |
| 2054 | − | |
| 2055 | − window.webContents.on("context-menu", (event, params) => { | |
| 2056 | − event.preventDefault(); | |
| 2057 | − | |
| 2058 | − const menuTemplate: MenuItemConstructorOptions[] = []; | |
| 2059 | − | |
| 2060 | − if (params.misspelledWord) { | |
| 2061 | − for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { | |
| 2062 | − menuTemplate.push({ | |
| 2063 | − label: suggestion, | |
| 2064 | − click: () => window.webContents.replaceMisspelling(suggestion), | |
| 2065 | − }); | |
| 2066 | − } | |
| 2067 | − if (params.dictionarySuggestions.length === 0) { | |
| 2068 | − menuTemplate.push({ label: "No suggestions", enabled: false }); | |
| 2069 | − } | |
| 2070 | − menuTemplate.push({ type: "separator" }); | |
| 2071 | − } | |
| 2072 | − | |
| 2073 | − const externalUrl = getSafeExternalUrl(params.linkURL); | |
| 2074 | − if (externalUrl) { | |
| 2075 | − menuTemplate.push( | |
| 2076 | − { | |
| 2077 | − label: "Copy Link", | |
| 2078 | − click: () => clipboard.writeText(params.linkURL), | |
| 2079 | − }, | |
| 2080 | − { type: "separator" }, | |
| 2081 | − ); | |
| 2082 | − } | |
| 2083 | − | |
| 2084 | − if (params.mediaType === "image") { | |
| 2085 | − menuTemplate.push({ | |
| 2086 | − label: "Copy Image", | |
| 2087 | − click: () => window.webContents.copyImageAt(params.x, params.y), | |
| 2088 | − }); | |
| 2089 | − menuTemplate.push({ type: "separator" }); | |
| 2090 | − } | |
| 2091 | − | |
| 2092 | − menuTemplate.push( | |
| 2093 | − { role: "cut", enabled: params.editFlags.canCut }, | |
| 2094 | − { role: "copy", enabled: params.editFlags.canCopy }, | |
| 2095 | − { role: "paste", enabled: params.editFlags.canPaste }, | |
| 2096 | − { role: "selectAll", enabled: params.editFlags.canSelectAll }, | |
| 2097 | − ); | |
| 2098 | − | |
| 2099 | − Menu.buildFromTemplate(menuTemplate).popup({ window }); | |
| 2100 | − }); | |
| 2101 | − | |
| 2102 | − window.webContents.setWindowOpenHandler(({ url }) => { | |
| 2103 | − const externalUrl = getSafeExternalUrl(url); | |
| 2104 | − if (externalUrl) { | |
| 2105 | − void shell.openExternal(externalUrl); | |
| 2106 | − } | |
| 2107 | − return { action: "deny" }; | |
| 2108 | − }); | |
| 2109 | − | |
| 2110 | − window.on("page-title-updated", (event) => { | |
| 2111 | − event.preventDefault(); | |
| 2112 | − window.setTitle(APP_DISPLAY_NAME); | |
| 2113 | − }); | |
| 2114 | − window.webContents.on("did-finish-load", () => { | |
| 2115 | − window.setTitle(APP_DISPLAY_NAME); | |
| 2116 | − emitUpdateState(); | |
| 2117 | − }); | |
| 2118 | − | |
| 2119 | − // On Linux/Wayland with `show: false`, Electron's `ready-to-show` only | |
| 2120 | − // fires after `show()` is called, deadlocking the standard "wait for | |
| 2121 | − // ready, then show" pattern. Add `did-finish-load` as a Linux-only | |
| 2122 | − // fallback so the window still surfaces once the renderer has loaded | |
| 2123 | − // the page. Other platforms keep the no-flash `ready-to-show` path, | |
| 2124 | − // since `did-finish-load` typically fires before the first paint there. | |
| 2125 | − const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; | |
| 2126 | − if (process.platform === "linux") { | |
| 2127 | − revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); | |
| 2128 | − } | |
| 2129 | − bindFirstRevealTrigger(revealSubscribers, () => revealWindow(window)); | |
| 2130 | − | |
| 2131 | − if (isDevelopment) { | |
| 2132 | − void window.loadURL(resolveDesktopDevServerUrl()); | |
| 2133 | − window.webContents.openDevTools({ mode: "detach" }); | |
| 2134 | − } else { | |
| 2135 | − void window.loadURL(backendHttpUrl); | |
| 2136 | − } | |
| 2137 | − | |
| 2138 | − window.on("closed", () => { | |
| 2139 | − desktopSshEnvironmentBridge.cancelPendingPasswordPrompts( | |
| 2140 | − "SSH authentication was cancelled because the app window closed.", | |
| 2141 | − ); | |
| 2142 | − if (mainWindow === window) { | |
| 2143 | − mainWindow = null; | |
| 2144 | − } | |
| 2145 | − }); | |
| 2146 | − | |
| 2147 | − return window; | |
| 2148 | −} | |
| 2149 | − | |
| 2150 | −// Override Electron's userData path before the `ready` event so that | |
| 2151 | −// Chromium session data uses a filesystem-friendly directory name. | |
| 2152 | −// Must be called synchronously at the top level — before `app.whenReady()`. | |
| 2153 | −app.setPath("userData", resolveUserDataPath()); | |
| 2154 | − | |
| 2155 | −configureAppIdentity(); | |
| 2156 | − | |
| 2157 | −async function bootstrap(): Promise<void> { | |
| 2158 | − writeDesktopLogHeader("bootstrap start"); | |
| 2159 | − const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT); | |
| 2160 | − if (isDevelopment && configuredBackendPort === undefined) { | |
| 2161 | − throw new Error("T3CODE_PORT is required in desktop development."); | |
| 2162 | − } | |
| 2163 | − | |
| 2164 | − backendPort = | |
| 2165 | − configuredBackendPort ?? | |
| 2166 | − (await resolveDesktopBackendPort({ | |
| 2167 | − host: DESKTOP_LOOPBACK_HOST, | |
| 2168 | − startPort: DEFAULT_DESKTOP_BACKEND_PORT, | |
| 2169 | − requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS, | |
| 2170 | − })); | |
| 2171 | − writeDesktopLogHeader( | |
| 2172 | − configuredBackendPort === undefined | |
| 2173 | − ? `selected backend port via sequential scan startPort=${DEFAULT_DESKTOP_BACKEND_PORT} port=${backendPort}` | |
| 2174 | − : `using configured backend port port=${backendPort}`, | |
| 2175 | − ); | |
| 2176 | − backendBootstrapToken = Crypto.randomBytes(24).toString("hex"); | |
| 2177 | − if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) { | |
| 2178 | − writeDesktopLogHeader( | |
| 2179 | − `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`, | |
| 2180 | − ); | |
| 2181 | − } | |
| 2182 | − const serverExposureState = await applyDesktopServerExposureMode( | |
| 2183 | − desktopSettings.serverExposureMode, | |
| 2184 | − { | |
| 2185 | − persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode, | |
| 2186 | − }, | |
| 2187 | − ); | |
| 2188 | − writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`); | |
| 2189 | − if (serverExposureState.endpointUrl) { | |
| 2190 | − writeDesktopLogHeader( | |
| 2191 | − `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`, | |
| 2192 | − ); | |
| 2193 | − } else if (desktopSettings.serverExposureMode === "network-accessible") { | |
| 2194 | − writeDesktopLogHeader( | |
| 2195 | − "bootstrap fell back to local-only because no advertised network host was available", | |
| 2196 | − ); | |
| 2197 | − } | |
| 2198 | − | |
| 2199 | − registerIpcHandlers(); | |
| 2200 | − writeDesktopLogHeader("bootstrap ipc handlers registered"); | |
| 2201 | − startBackend(); | |
| 2202 | − writeDesktopLogHeader("bootstrap backend start requested"); | |
| 2203 | − | |
| 2204 | − if (isDevelopment) { | |
| 2205 | − mainWindow = createWindow(); | |
| 2206 | − writeDesktopLogHeader("bootstrap main window created"); | |
| 2207 | − void waitForBackendWindowReady(backendHttpUrl) | |
| 2208 | − .then((source) => { | |
| 2209 | − writeDesktopLogHeader(`bootstrap backend ready source=${source}`); | |
| 2210 | − }) | |
| 2211 | − .catch((error) => { | |
| 2212 | − if (isBackendReadinessAborted(error)) { | |
| 2213 | − return; | |
| 2214 | − } | |
| 2215 | − writeDesktopLogHeader( | |
| 2216 | − `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, | |
| 2217 | − ); | |
| 2218 | − console.warn("[desktop] backend readiness check timed out during dev bootstrap", error); | |
| 2219 | − }); | |
| 2220 | − return; | |
| 2221 | − } | |
| 2222 | − | |
| 2223 | − ensureInitialBackendWindowOpen(); | |
| 2224 | −} | |
| 2225 | − | |
| 2226 | −app.on("before-quit", () => { | |
| 2227 | − isQuitting = true; | |
| 2228 | − updateInstallInFlight = false; | |
| 2229 | − writeDesktopLogHeader("before-quit received"); | |
| 2230 | − clearUpdatePollTimer(); | |
| 2231 | − cancelBackendReadinessWait(); | |
| 2232 | − stopBackend(); | |
| 2233 | − void desktopSshEnvironmentBridge.dispose().catch(() => undefined); | |
| 2234 | − restoreStdIoCapture?.(); | |
| 2235 | −}); | |
| 2236 | − | |
| 2237 | −app | |
| 2238 | − .whenReady() | |
| 2239 | − .then(() => { | |
| 2240 | − writeDesktopLogHeader("app ready"); | |
| 2241 | − configureAppIdentity(); | |
| 2242 | − configureApplicationMenu(); | |
| 2243 | − registerDesktopProtocol(); | |
| 2244 | − configureAutoUpdater(); | |
| 2245 | − void bootstrap().catch((error) => { | |
| 2246 | − if (isBackendReadinessAborted(error) && isQuitting) { | |
| 2247 | − return; | |
| 2248 | − } | |
| 2249 | − handleFatalStartupError("bootstrap", error); | |
| 2250 | − }); | |
| 77 | +}; | |
| 2251 | 78 | |
| 2252 | − app.on("activate", () => { | |
| 2253 | − const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; | |
| 2254 | − if (existingWindow) { | |
| 2255 | − revealWindow(existingWindow); | |
| 2256 | − return; | |
| 2257 | − } | |
| 2258 | − if (isDevelopment) { | |
| 2259 | − mainWindow = createWindow(); | |
| 2260 | − return; | |
| 2261 | − } | |
| 2262 | − ensureInitialBackendWindowOpen(); | |
| 79 | +const desktopSshEnvironmentLayer = Layer.unwrap( | |
| 80 | + Effect.gen(function* () { | |
| 81 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 82 | + const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 83 | + return DesktopSshEnvironment.layer({ | |
| 84 | + resolveCliRunner: settings.get.pipe( | |
| 85 | + Effect.map((currentSettings) => resolveDesktopSshCliRunner(environment, currentSettings)), | |
| 86 | + ), | |
| 2263 | 87 | }); |
| 2264 | − }) | |
| 2265 | − .catch((error) => { | |
| 2266 | − handleFatalStartupError("whenReady", error); | |
| 2267 | − }); | |
| 2268 | − | |
| 2269 | −app.on("window-all-closed", () => { | |
| 2270 | − if (process.platform !== "darwin" && !isQuitting) { | |
| 2271 | − app.quit(); | |
| 2272 | − } | |
| 2273 | −}); | |
| 2274 | − | |
| 2275 | −if (process.platform !== "win32") { | |
| 2276 | − process.on("SIGINT", () => { | |
| 2277 | − if (isQuitting) return; | |
| 2278 | − isQuitting = true; | |
| 2279 | − writeDesktopLogHeader("SIGINT received"); | |
| 2280 | − clearUpdatePollTimer(); | |
| 2281 | − cancelBackendReadinessWait(); | |
| 2282 | − stopBackend(); | |
| 2283 | − void desktopSshEnvironmentBridge.dispose().catch(() => undefined); | |
| 2284 | − restoreStdIoCapture?.(); | |
| 2285 | − app.quit(); | |
| 2286 | − }); | |
| 2287 | − | |
| 2288 | − process.on("SIGTERM", () => { | |
| 2289 | − if (isQuitting) return; | |
| 2290 | − isQuitting = true; | |
| 2291 | − writeDesktopLogHeader("SIGTERM received"); | |
| 2292 | − clearUpdatePollTimer(); | |
| 2293 | − stopBackend(); | |
| 2294 | − void desktopSshEnvironmentBridge.dispose().catch(() => undefined); | |
| 2295 | − restoreStdIoCapture?.(); | |
| 2296 | − app.quit(); | |
| 2297 | − }); | |
| 2298 | −} | |
| 88 | + }), | |
| 89 | +); | |
| 90 | + | |
| 91 | +const electronLayer = Layer.mergeAll( | |
| 92 | + ElectronApp.layer, | |
| 93 | + ElectronDialog.layer, | |
| 94 | + ElectronMenu.layer, | |
| 95 | + ElectronProtocol.layer, | |
| 96 | + DesktopSecretStorage.layer, | |
| 97 | + ElectronShell.layer, | |
| 98 | + ElectronTheme.layer, | |
| 99 | + ElectronUpdater.layer, | |
| 100 | + ElectronWindow.layer, | |
| 101 | + Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(Electron.ipcMain)), | |
| 102 | +); | |
| 103 | + | |
| 104 | +const desktopFoundationLayer = Layer.mergeAll( | |
| 105 | + DesktopState.layer, | |
| 106 | + DesktopLifecycle.layerShutdown, | |
| 107 | + DesktopAppSettings.layer, | |
| 108 | + DesktopClientSettings.layer, | |
| 109 | + DesktopSavedEnvironments.layer, | |
| 110 | + DesktopAssets.layer, | |
| 111 | + DesktopObservability.layer, | |
| 112 | +).pipe(Layer.provideMerge(desktopEnvironmentLayer)); | |
| 113 | + | |
| 114 | +const desktopSshLayer = Layer.mergeAll(desktopSshEnvironmentLayer, DesktopSshRemoteApi.layer).pipe( | |
| 115 | + Layer.provideMerge(DesktopSshPasswordPrompts.layer()), | |
| 116 | +); | |
| 117 | + | |
| 118 | +const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( | |
| 119 | + Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer), | |
| 120 | + Layer.provideMerge(desktopFoundationLayer), | |
| 121 | +); | |
| 122 | + | |
| 123 | +const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopServerExposureLayer)); | |
| 124 | + | |
| 125 | +const desktopBackendLayer = DesktopBackendManager.layer.pipe( | |
| 126 | + Layer.provideMerge(DesktopAppIdentity.layer), | |
| 127 | + Layer.provideMerge(DesktopBackendConfiguration.layer), | |
| 128 | + Layer.provideMerge(desktopWindowLayer), | |
| 129 | +); | |
| 130 | + | |
| 131 | +const desktopApplicationLayer = Layer.mergeAll( | |
| 132 | + DesktopLifecycle.layer, | |
| 133 | + DesktopApplicationMenu.layer, | |
| 134 | + DesktopShellEnvironment.layer, | |
| 135 | + desktopSshLayer, | |
| 136 | +).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); | |
| 137 | + | |
| 138 | +const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( | |
| 139 | + Layer.flatMap(() => | |
| 140 | + desktopApplicationLayer.pipe( | |
| 141 | + Layer.provideMerge(NodeServices.layer), | |
| 142 | + Layer.provideMerge(NodeHttpClient.layerUndici), | |
| 143 | + Layer.provideMerge(NetService.layer), | |
| 144 | + Layer.provideMerge(electronLayer), | |
| 145 | + ), | |
| 146 | + ), | |
| 147 | +); | |
| 148 | + | |
| 149 | +DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); | |
| @@ -1,48 +1,14 @@ | ||
| 1 | −import { contextBridge, ipcRenderer } from "electron"; | |
| 2 | 1 | import type { DesktopBridge } from "@t3tools/contracts"; |
| 2 | +import { contextBridge, ipcRenderer } from "electron"; | |
| 3 | 3 | |
| 4 | −const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; | |
| 5 | −const CONFIRM_CHANNEL = "desktop:confirm"; | |
| 6 | −const SET_THEME_CHANNEL = "desktop:set-theme"; | |
| 7 | −const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | |
| 8 | −const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | |
| 9 | −const MENU_ACTION_CHANNEL = "desktop:menu-action"; | |
| 10 | −const UPDATE_STATE_CHANNEL = "desktop:update-state"; | |
| 11 | −const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; | |
| 12 | −const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; | |
| 13 | −const UPDATE_CHECK_CHANNEL = "desktop:update-check"; | |
| 14 | −const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; | |
| 15 | −const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | |
| 16 | −const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; | |
| 17 | −const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; | |
| 18 | −const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; | |
| 19 | −const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; | |
| 20 | −const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; | |
| 21 | −const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; | |
| 22 | −const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; | |
| 23 | −const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; | |
| 24 | −const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; | |
| 25 | −const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; | |
| 26 | −const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; | |
| 27 | −const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; | |
| 28 | −const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; | |
| 29 | −const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; | |
| 30 | −const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; | |
| 31 | −const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; | |
| 32 | −const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; | |
| 33 | −const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; | |
| 34 | −const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; | |
| 35 | −const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; | |
| 36 | −const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; | |
| 37 | −const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; | |
| 38 | −const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; | |
| 4 | +import * as IpcChannels from "./ipc/channels.ts"; | |
| 39 | 5 | |
| 40 | 6 | function unwrapEnsureSshEnvironmentResult(result: unknown) { |
| 41 | 7 | if ( |
| 42 | 8 | typeof result === "object" && |
| 43 | 9 | result !== null && |
| 44 | 10 | "type" in result && |
| 45 | − result.type === SSH_PASSWORD_PROMPT_CANCELLED_RESULT | |
| 11 | + result.type === IpcChannels.SSH_PASSWORD_PROMPT_CANCELLED_RESULT | |
| 46 | 12 | ) { |
| 47 | 13 | const message = |
| 48 | 14 | "message" in result && typeof result.message === "string" |
| @@ -55,93 +21,107 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) { · 6 unmodified lines | ||
| 55 | 21 | |
| 56 | 22 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 57 | 23 | getAppBranding: () => { |
| 58 | − const result = ipcRenderer.sendSync(GET_APP_BRANDING_CHANNEL); | |
| 24 | + const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL); | |
| 59 | 25 | if (typeof result !== "object" || result === null) { |
| 60 | 26 | return null; |
| 61 | 27 | } |
| 62 | 28 | return result as ReturnType<DesktopBridge["getAppBranding"]>; |
| 63 | 29 | }, |
| 64 | 30 | getLocalEnvironmentBootstrap: () => { |
| 65 | − const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); | |
| 31 | + const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); | |
| 66 | 32 | if (typeof result !== "object" || result === null) { |
| 67 | 33 | return null; |
| 68 | 34 | } |
| 69 | 35 | return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>; |
| 70 | 36 | }, |
| 71 | − getClientSettings: () => ipcRenderer.invoke(GET_CLIENT_SETTINGS_CHANNEL), | |
| 72 | − setClientSettings: (settings) => ipcRenderer.invoke(SET_CLIENT_SETTINGS_CHANNEL, settings), | |
| 73 | − getSavedEnvironmentRegistry: () => ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), | |
| 37 | + getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), | |
| 38 | + setClientSettings: (settings) => | |
| 39 | + ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), | |
| 40 | + getSavedEnvironmentRegistry: () => | |
| 41 | + ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), | |
| 74 | 42 | setSavedEnvironmentRegistry: (records) => |
| 75 | − ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records), | |
| 43 | + ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records), | |
| 76 | 44 | getSavedEnvironmentSecret: (environmentId) => |
| 77 | − ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), | |
| 45 | + ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), | |
| 78 | 46 | setSavedEnvironmentSecret: (environmentId, secret) => |
| 79 | − ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret), | |
| 47 | + ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }), | |
| 80 | 48 | removeSavedEnvironmentSecret: (environmentId) => |
| 81 | − ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), | |
| 82 | − discoverSshHosts: () => ipcRenderer.invoke(DISCOVER_SSH_HOSTS_CHANNEL), | |
| 49 | + ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), | |
| 50 | + discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), | |
| 83 | 51 | ensureSshEnvironment: async (target, options) => |
| 84 | 52 | unwrapEnsureSshEnvironmentResult( |
| 85 | − await ipcRenderer.invoke(ENSURE_SSH_ENVIRONMENT_CHANNEL, target, options), | |
| 53 | + await ipcRenderer.invoke(IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, { | |
| 54 | + target, | |
| 55 | + ...(options === undefined ? {} : { options }), | |
| 56 | + }), | |
| 86 | 57 | ), |
| 87 | 58 | disconnectSshEnvironment: (target) => |
| 88 | − ipcRenderer.invoke(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target), | |
| 59 | + ipcRenderer.invoke(IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target), | |
| 89 | 60 | fetchSshEnvironmentDescriptor: (httpBaseUrl) => |
| 90 | − ipcRenderer.invoke(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, httpBaseUrl), | |
| 61 | + ipcRenderer.invoke(IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, { httpBaseUrl }), | |
| 91 | 62 | bootstrapSshBearerSession: (httpBaseUrl, credential) => |
| 92 | − ipcRenderer.invoke(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, httpBaseUrl, credential), | |
| 63 | + ipcRenderer.invoke(IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, { | |
| 64 | + httpBaseUrl, | |
| 65 | + credential, | |
| 66 | + }), | |
| 93 | 67 | fetchSshSessionState: (httpBaseUrl, bearerToken) => |
| 94 | − ipcRenderer.invoke(FETCH_SSH_SESSION_STATE_CHANNEL, httpBaseUrl, bearerToken), | |
| 68 | + ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }), | |
| 95 | 69 | issueSshWebSocketToken: (httpBaseUrl, bearerToken) => |
| 96 | − ipcRenderer.invoke(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, httpBaseUrl, bearerToken), | |
| 70 | + ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }), | |
| 97 | 71 | onSshPasswordPrompt: (listener) => { |
| 98 | 72 | const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { |
| 99 | 73 | if (typeof request !== "object" || request === null) return; |
| 100 | 74 | listener(request as Parameters<typeof listener>[0]); |
| 101 | 75 | }; |
| 102 | 76 | |
| 103 | − ipcRenderer.on(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); | |
| 77 | + ipcRenderer.on(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); | |
| 104 | 78 | return () => { |
| 105 | − ipcRenderer.removeListener(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); | |
| 79 | + ipcRenderer.removeListener(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); | |
| 106 | 80 | }; |
| 107 | 81 | }, |
| 108 | 82 | resolveSshPasswordPrompt: (requestId, password) => |
| 109 | − ipcRenderer.invoke(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, requestId, password), | |
| 110 | − getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), | |
| 111 | − setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), | |
| 83 | + ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }), | |
| 84 | + getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL), | |
| 85 | + setServerExposureMode: (mode) => | |
| 86 | + ipcRenderer.invoke(IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), | |
| 112 | 87 | setTailscaleServeEnabled: (input) => |
| 113 | − ipcRenderer.invoke(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), | |
| 114 | − getAdvertisedEndpoints: () => ipcRenderer.invoke(GET_ADVERTISED_ENDPOINTS_CHANNEL), | |
| 115 | − pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), | |
| 116 | − confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), | |
| 117 | − setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), | |
| 118 | − showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), | |
| 119 | − openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), | |
| 88 | + ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), | |
| 89 | + getAdvertisedEndpoints: () => ipcRenderer.invoke(IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL), | |
| 90 | + pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), | |
| 91 | + confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), | |
| 92 | + setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), | |
| 93 | + showContextMenu: (items, position) => | |
| 94 | + ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { | |
| 95 | + items, | |
| 96 | + ...(position === undefined ? {} : { position }), | |
| 97 | + }), | |
| 98 | + openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), | |
| 120 | 99 | onMenuAction: (listener) => { |
| 121 | 100 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { |
| 122 | 101 | if (typeof action !== "string") return; |
| 123 | 102 | listener(action); |
| 124 | 103 | }; |
| 125 | 104 | |
| 126 | − ipcRenderer.on(MENU_ACTION_CHANNEL, wrappedListener); | |
| 105 | + ipcRenderer.on(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); | |
| 127 | 106 | return () => { |
| 128 | − ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener); | |
| 107 | + ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); | |
| 129 | 108 | }; |
| 130 | 109 | }, |
| 131 | − getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), | |
| 132 | − setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel), | |
| 133 | − checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), | |
| 134 | − downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL), | |
| 135 | − installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL), | |
| 110 | + getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), | |
| 111 | + setUpdateChannel: (channel) => | |
| 112 | + ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), | |
| 113 | + checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), | |
| 114 | + downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), | |
| 115 | + installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), | |
| 136 | 116 | onUpdateState: (listener) => { |
| 137 | 117 | const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { |
| 138 | 118 | if (typeof state !== "object" || state === null) return; |
| 139 | 119 | listener(state as Parameters<typeof listener>[0]); |
| 140 | 120 | }; |
| 141 | 121 | |
| 142 | − ipcRenderer.on(UPDATE_STATE_CHANNEL, wrappedListener); | |
| 122 | + ipcRenderer.on(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); | |
| 143 | 123 | return () => { |
| 144 | − ipcRenderer.removeListener(UPDATE_STATE_CHANNEL, wrappedListener); | |
| 124 | + ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); | |
| 145 | 125 | }; |
| 146 | 126 | }, |
| 147 | 127 | } satisfies DesktopBridge); |
| @@ -1,74 +0,0 @@ | ||
| 1 | −import fs from "node:fs"; | |
| 2 | −import os from "node:os"; | |
| 3 | −import path from "node:path"; | |
| 4 | − | |
| 5 | −import { RotatingFileSink } from "@t3tools/shared/logging"; | |
| 6 | −import { afterEach, describe, expect, it } from "vitest"; | |
| 7 | − | |
| 8 | −const tempRoots: string[] = []; | |
| 9 | − | |
| 10 | −function makeTempDir(): string { | |
| 11 | − const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-rotating-log-")); | |
| 12 | − tempRoots.push(dir); | |
| 13 | − return dir; | |
| 14 | −} | |
| 15 | − | |
| 16 | −afterEach(() => { | |
| 17 | − for (const dir of tempRoots.splice(0)) { | |
| 18 | − fs.rmSync(dir, { recursive: true, force: true }); | |
| 19 | − } | |
| 20 | −}); | |
| 21 | − | |
| 22 | −describe("RotatingFileSink", () => { | |
| 23 | − it("rotates when writes exceed max bytes", () => { | |
| 24 | − const dir = makeTempDir(); | |
| 25 | − const logPath = path.join(dir, "desktop-main.log"); | |
| 26 | − const sink = new RotatingFileSink({ | |
| 27 | − filePath: logPath, | |
| 28 | − maxBytes: 10, | |
| 29 | − maxFiles: 3, | |
| 30 | − }); | |
| 31 | − | |
| 32 | − sink.write("12345"); | |
| 33 | − sink.write("67890"); | |
| 34 | − sink.write("abc"); | |
| 35 | − | |
| 36 | − expect(fs.readFileSync(path.join(dir, "desktop-main.log"), "utf8")).toBe("abc"); | |
| 37 | − expect(fs.readFileSync(path.join(dir, "desktop-main.log.1"), "utf8")).toBe("1234567890"); | |
| 38 | − }); | |
| 39 | − | |
| 40 | − it("retains only maxFiles backups", () => { | |
| 41 | − const dir = makeTempDir(); | |
| 42 | − const logPath = path.join(dir, "server-child.log"); | |
| 43 | − const sink = new RotatingFileSink({ | |
| 44 | − filePath: logPath, | |
| 45 | − maxBytes: 4, | |
| 46 | − maxFiles: 2, | |
| 47 | − }); | |
| 48 | − | |
| 49 | − sink.write("aaaa"); | |
| 50 | − sink.write("bbbb"); | |
| 51 | − sink.write("cccc"); | |
| 52 | − sink.write("dddd"); | |
| 53 | − | |
| 54 | − expect(fs.existsSync(path.join(dir, "server-child.log.1"))).toBe(true); | |
| 55 | − expect(fs.existsSync(path.join(dir, "server-child.log.2"))).toBe(true); | |
| 56 | − expect(fs.existsSync(path.join(dir, "server-child.log.3"))).toBe(false); | |
| 57 | − }); | |
| 58 | − | |
| 59 | − it("prunes stale backups above maxFiles on startup", () => { | |
| 60 | − const dir = makeTempDir(); | |
| 61 | − const logPath = path.join(dir, "desktop-main.log"); | |
| 62 | − fs.writeFileSync(path.join(dir, "desktop-main.log.1"), "first"); | |
| 63 | − fs.writeFileSync(path.join(dir, "desktop-main.log.4"), "stale"); | |
| 64 | − | |
| 65 | − const sink = new RotatingFileSink({ | |
| 66 | − filePath: logPath, | |
| 67 | − maxBytes: 16, | |
| 68 | − maxFiles: 2, | |
| 69 | − }); | |
| 70 | − sink.write("hello"); | |
| 71 | − | |
| 72 | − expect(fs.existsSync(path.join(dir, "desktop-main.log.4"))).toBe(false); | |
| 73 | − }); | |
| 74 | −}); | |
| @@ -1,49 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "vitest"; | |
| 2 | − | |
| 3 | −import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; | |
| 4 | − | |
| 5 | −describe("resolveDesktopRuntimeInfo", () => { | |
| 6 | − it("detects Rosetta-translated Intel builds on Apple Silicon", () => { | |
| 7 | − const runtimeInfo = resolveDesktopRuntimeInfo({ | |
| 8 | − platform: "darwin", | |
| 9 | − processArch: "x64", | |
| 10 | − runningUnderArm64Translation: true, | |
| 11 | − }); | |
| 12 | − | |
| 13 | − expect(runtimeInfo).toEqual({ | |
| 14 | − hostArch: "arm64", | |
| 15 | − appArch: "x64", | |
| 16 | − runningUnderArm64Translation: true, | |
| 17 | − }); | |
| 18 | − expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(true); | |
| 19 | − }); | |
| 20 | − | |
| 21 | − it("detects native Apple Silicon builds", () => { | |
| 22 | − const runtimeInfo = resolveDesktopRuntimeInfo({ | |
| 23 | − platform: "darwin", | |
| 24 | − processArch: "arm64", | |
| 25 | − runningUnderArm64Translation: false, | |
| 26 | − }); | |
| 27 | − | |
| 28 | − expect(runtimeInfo).toEqual({ | |
| 29 | − hostArch: "arm64", | |
| 30 | − appArch: "arm64", | |
| 31 | − runningUnderArm64Translation: false, | |
| 32 | − }); | |
| 33 | − expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(false); | |
| 34 | − }); | |
| 35 | − | |
| 36 | − it("passes through non-mac builds without translation", () => { | |
| 37 | − const runtimeInfo = resolveDesktopRuntimeInfo({ | |
| 38 | − platform: "linux", | |
| 39 | − processArch: "x64", | |
| 40 | − runningUnderArm64Translation: true, | |
| 41 | − }); | |
| 42 | − | |
| 43 | − expect(runtimeInfo).toEqual({ | |
| 44 | − hostArch: "x64", | |
| 45 | − appArch: "x64", | |
| 46 | − runningUnderArm64Translation: false, | |
| 47 | − }); | |
| 48 | − }); | |
| 49 | −}); | |
| @@ -1,39 +0,0 @@ | ||
| 1 | −import type { DesktopRuntimeArch, DesktopRuntimeInfo } from "@t3tools/contracts"; | |
| 2 | − | |
| 3 | −interface ResolveDesktopRuntimeInfoInput { | |
| 4 | − readonly platform: NodeJS.Platform; | |
| 5 | − readonly processArch: string; | |
| 6 | − readonly runningUnderArm64Translation: boolean; | |
| 7 | −} | |
| 8 | − | |
| 9 | −function normalizeDesktopArch(arch: string): DesktopRuntimeArch { | |
| 10 | − if (arch === "arm64") return "arm64"; | |
| 11 | − if (arch === "x64") return "x64"; | |
| 12 | − return "other"; | |
| 13 | −} | |
| 14 | − | |
| 15 | −export function resolveDesktopRuntimeInfo( | |
| 16 | − input: ResolveDesktopRuntimeInfoInput, | |
| 17 | −): DesktopRuntimeInfo { | |
| 18 | − const appArch = normalizeDesktopArch(input.processArch); | |
| 19 | − | |
| 20 | − if (input.platform !== "darwin") { | |
| 21 | − return { | |
| 22 | − hostArch: appArch, | |
| 23 | − appArch, | |
| 24 | − runningUnderArm64Translation: false, | |
| 25 | − }; | |
| 26 | − } | |
| 27 | − | |
| 28 | − const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch; | |
| 29 | − | |
| 30 | − return { | |
| 31 | − hostArch, | |
| 32 | − appArch, | |
| 33 | − runningUnderArm64Translation: input.runningUnderArm64Translation, | |
| 34 | − }; | |
| 35 | −} | |
| 36 | − | |
| 37 | −export function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean { | |
| 38 | − return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; | |
| 39 | −} | |
| @@ -1,258 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "vitest"; | |
| 2 | − | |
| 3 | −import { | |
| 4 | − resolveDesktopCoreAdvertisedEndpoints, | |
| 5 | − resolveDesktopServerExposure, | |
| 6 | − resolveLanAdvertisedHost, | |
| 7 | −} from "./serverExposure.ts"; | |
| 8 | − | |
| 9 | −describe("resolveLanAdvertisedHost", () => { | |
| 10 | − it("prefers an explicit host override", () => { | |
| 11 | − expect( | |
| 12 | − resolveLanAdvertisedHost( | |
| 13 | − { | |
| 14 | − en0: [ | |
| 15 | − { | |
| 16 | − address: "192.168.1.44", | |
| 17 | − family: "IPv4", | |
| 18 | − internal: false, | |
| 19 | − netmask: "255.255.255.0", | |
| 20 | − cidr: "192.168.1.44/24", | |
| 21 | − mac: "00:00:00:00:00:00", | |
| 22 | − }, | |
| 23 | − ], | |
| 24 | − }, | |
| 25 | − "10.0.0.9", | |
| 26 | − ), | |
| 27 | − ).toBe("10.0.0.9"); | |
| 28 | − }); | |
| 29 | − | |
| 30 | − it("returns the first usable non-internal IPv4 address", () => { | |
| 31 | − expect( | |
| 32 | − resolveLanAdvertisedHost( | |
| 33 | − { | |
| 34 | − lo0: [ | |
| 35 | − { | |
| 36 | − address: "127.0.0.1", | |
| 37 | − family: "IPv4", | |
| 38 | − internal: true, | |
| 39 | − netmask: "255.0.0.0", | |
| 40 | − cidr: "127.0.0.1/8", | |
| 41 | − mac: "00:00:00:00:00:00", | |
| 42 | − }, | |
| 43 | − ], | |
| 44 | − en0: [ | |
| 45 | − { | |
| 46 | − address: "192.168.1.44", | |
| 47 | − family: "IPv4", | |
| 48 | − internal: false, | |
| 49 | − netmask: "255.255.255.0", | |
| 50 | − cidr: "192.168.1.44/24", | |
| 51 | − mac: "00:00:00:00:00:00", | |
| 52 | − }, | |
| 53 | − ], | |
| 54 | − }, | |
| 55 | − undefined, | |
| 56 | − ), | |
| 57 | − ).toBe("192.168.1.44"); | |
| 58 | − }); | |
| 59 | − | |
| 60 | − it("returns null when no usable network address is available", () => { | |
| 61 | − expect( | |
| 62 | − resolveLanAdvertisedHost( | |
| 63 | − { | |
| 64 | − lo0: [ | |
| 65 | − { | |
| 66 | − address: "127.0.0.1", | |
| 67 | − family: "IPv4", | |
| 68 | − internal: true, | |
| 69 | − netmask: "255.0.0.0", | |
| 70 | − cidr: "127.0.0.1/8", | |
| 71 | − mac: "00:00:00:00:00:00", | |
| 72 | − }, | |
| 73 | − ], | |
| 74 | − }, | |
| 75 | − undefined, | |
| 76 | − ), | |
| 77 | − ).toBeNull(); | |
| 78 | − }); | |
| 79 | −}); | |
| 80 | − | |
| 81 | −describe("resolveDesktopCoreAdvertisedEndpoints", () => { | |
| 82 | − it("advertises loopback and LAN endpoints without provider-specific assumptions", () => { | |
| 83 | − const exposure = resolveDesktopServerExposure({ | |
| 84 | − mode: "network-accessible", | |
| 85 | − port: 3773, | |
| 86 | − networkInterfaces: { | |
| 87 | − en0: [ | |
| 88 | − { | |
| 89 | − address: "192.168.1.44", | |
| 90 | − family: "IPv4", | |
| 91 | − internal: false, | |
| 92 | − netmask: "255.255.255.0", | |
| 93 | − cidr: "192.168.1.44/24", | |
| 94 | − mac: "00:00:00:00:00:00", | |
| 95 | − }, | |
| 96 | − ], | |
| 97 | − }, | |
| 98 | − }); | |
| 99 | − | |
| 100 | − expect( | |
| 101 | − resolveDesktopCoreAdvertisedEndpoints({ | |
| 102 | − port: 3773, | |
| 103 | − exposure, | |
| 104 | − customHttpsEndpointUrls: [ | |
| 105 | − "https://desktop.example.ts.net", | |
| 106 | − "http://desktop.example.test:3773", | |
| 107 | − "not-a-url", | |
| 108 | − ], | |
| 109 | − }), | |
| 110 | − ).toEqual([ | |
| 111 | − { | |
| 112 | − id: "desktop-loopback:3773", | |
| 113 | − label: "This machine", | |
| 114 | − provider: { | |
| 115 | − id: "desktop-core", | |
| 116 | − label: "Desktop", | |
| 117 | − kind: "core", | |
| 118 | − isAddon: false, | |
| 119 | − }, | |
| 120 | − httpBaseUrl: "http://127.0.0.1:3773/", | |
| 121 | − wsBaseUrl: "ws://127.0.0.1:3773/", | |
| 122 | − reachability: "loopback", | |
| 123 | − compatibility: { | |
| 124 | − hostedHttpsApp: "mixed-content-blocked", | |
| 125 | − desktopApp: "compatible", | |
| 126 | − }, | |
| 127 | − source: "desktop-core", | |
| 128 | − status: "available", | |
| 129 | − description: "Loopback endpoint for this desktop app.", | |
| 130 | − }, | |
| 131 | − { | |
| 132 | − id: "desktop-lan:http://192.168.1.44:3773", | |
| 133 | − label: "Local network", | |
| 134 | − provider: { | |
| 135 | − id: "desktop-core", | |
| 136 | − label: "Desktop", | |
| 137 | − kind: "core", | |
| 138 | − isAddon: false, | |
| 139 | − }, | |
| 140 | − httpBaseUrl: "http://192.168.1.44:3773/", | |
| 141 | − wsBaseUrl: "ws://192.168.1.44:3773/", | |
| 142 | − reachability: "lan", | |
| 143 | − compatibility: { | |
| 144 | − hostedHttpsApp: "mixed-content-blocked", | |
| 145 | − desktopApp: "compatible", | |
| 146 | − }, | |
| 147 | − source: "desktop-core", | |
| 148 | − status: "available", | |
| 149 | − isDefault: true, | |
| 150 | − description: "Reachable from devices on the same network.", | |
| 151 | − }, | |
| 152 | − { | |
| 153 | − id: "manual:https://desktop.example.ts.net", | |
| 154 | − label: "Custom HTTPS", | |
| 155 | − provider: { | |
| 156 | − id: "manual", | |
| 157 | − label: "Manual", | |
| 158 | − kind: "manual", | |
| 159 | − isAddon: false, | |
| 160 | − }, | |
| 161 | − httpBaseUrl: "https://desktop.example.ts.net/", | |
| 162 | − wsBaseUrl: "wss://desktop.example.ts.net/", | |
| 163 | − reachability: "public", | |
| 164 | − compatibility: { | |
| 165 | − hostedHttpsApp: "compatible", | |
| 166 | − desktopApp: "compatible", | |
| 167 | − }, | |
| 168 | − source: "user", | |
| 169 | − status: "unknown", | |
| 170 | − description: "User-configured HTTPS endpoint for this desktop backend.", | |
| 171 | − }, | |
| 172 | − { | |
| 173 | − id: "manual:http://desktop.example.test:3773", | |
| 174 | − label: "Custom endpoint", | |
| 175 | − provider: { | |
| 176 | − id: "manual", | |
| 177 | − label: "Manual", | |
| 178 | − kind: "manual", | |
| 179 | − isAddon: false, | |
| 180 | − }, | |
| 181 | − httpBaseUrl: "http://desktop.example.test:3773/", | |
| 182 | − wsBaseUrl: "ws://desktop.example.test:3773/", | |
| 183 | − reachability: "public", | |
| 184 | − compatibility: { | |
| 185 | − hostedHttpsApp: "mixed-content-blocked", | |
| 186 | − desktopApp: "compatible", | |
| 187 | − }, | |
| 188 | − source: "user", | |
| 189 | − status: "unknown", | |
| 190 | − description: "User-configured endpoint for this desktop backend.", | |
| 191 | − }, | |
| 192 | − ]); | |
| 193 | − }); | |
| 194 | −}); | |
| 195 | − | |
| 196 | −describe("resolveDesktopServerExposure", () => { | |
| 197 | − it("keeps the desktop server loopback-only when local-only mode is selected", () => { | |
| 198 | − expect( | |
| 199 | − resolveDesktopServerExposure({ | |
| 200 | − mode: "local-only", | |
| 201 | − port: 3773, | |
| 202 | − networkInterfaces: {}, | |
| 203 | − }), | |
| 204 | − ).toEqual({ | |
| 205 | − mode: "local-only", | |
| 206 | − bindHost: "127.0.0.1", | |
| 207 | − localHttpUrl: "http://127.0.0.1:3773", | |
| 208 | − localWsUrl: "ws://127.0.0.1:3773", | |
| 209 | − endpointUrl: null, | |
| 210 | − advertisedHost: null, | |
| 211 | − }); | |
| 212 | − }); | |
| 213 | − | |
| 214 | − it("binds to all interfaces in network-accessible mode", () => { | |
| 215 | − expect( | |
| 216 | − resolveDesktopServerExposure({ | |
| 217 | − mode: "network-accessible", | |
| 218 | − port: 3773, | |
| 219 | − networkInterfaces: { | |
| 220 | − en0: [ | |
| 221 | − { | |
| 222 | − address: "192.168.1.44", | |
| 223 | − family: "IPv4", | |
| 224 | − internal: false, | |
| 225 | − netmask: "255.255.255.0", | |
| 226 | − cidr: "192.168.1.44/24", | |
| 227 | − mac: "00:00:00:00:00:00", | |
| 228 | − }, | |
| 229 | − ], | |
| 230 | − }, | |
| 231 | − }), | |
| 232 | − ).toEqual({ | |
| 233 | − mode: "network-accessible", | |
| 234 | − bindHost: "0.0.0.0", | |
| 235 | − localHttpUrl: "http://127.0.0.1:3773", | |
| 236 | − localWsUrl: "ws://127.0.0.1:3773", | |
| 237 | − endpointUrl: "http://192.168.1.44:3773", | |
| 238 | − advertisedHost: "192.168.1.44", | |
| 239 | − }); | |
| 240 | − }); | |
| 241 | − | |
| 242 | − it("stays network-accessible even when no LAN address is currently detectable", () => { | |
| 243 | − expect( | |
| 244 | − resolveDesktopServerExposure({ | |
| 245 | − mode: "network-accessible", | |
| 246 | − port: 3773, | |
| 247 | − networkInterfaces: {}, | |
| 248 | − }), | |
| 249 | − ).toEqual({ | |
| 250 | − mode: "network-accessible", | |
| 251 | − bindHost: "0.0.0.0", | |
| 252 | − localHttpUrl: "http://127.0.0.1:3773", | |
| 253 | − localWsUrl: "ws://127.0.0.1:3773", | |
| 254 | − endpointUrl: null, | |
| 255 | − advertisedHost: null, | |
| 256 | − }); | |
| 257 | − }); | |
| 258 | −}); | |
| @@ -1,188 +0,0 @@ | ||
| 1 | −import type { NetworkInterfaceInfo } from "node:os"; | |
| 2 | −import { | |
| 3 | − createAdvertisedEndpoint, | |
| 4 | − type CreateAdvertisedEndpointInput, | |
| 5 | −} from "@t3tools/client-runtime"; | |
| 6 | −import type { | |
| 7 | − AdvertisedEndpoint, | |
| 8 | − AdvertisedEndpointProvider, | |
| 9 | − DesktopServerExposureMode, | |
| 10 | −} from "@t3tools/contracts"; | |
| 11 | − | |
| 12 | −const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; | |
| 13 | −const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; | |
| 14 | − | |
| 15 | −export interface DesktopServerExposure { | |
| 16 | − readonly mode: DesktopServerExposureMode; | |
| 17 | − readonly bindHost: string; | |
| 18 | − readonly localHttpUrl: string; | |
| 19 | − readonly localWsUrl: string; | |
| 20 | − readonly endpointUrl: string | null; | |
| 21 | − readonly advertisedHost: string | null; | |
| 22 | −} | |
| 23 | − | |
| 24 | −export interface DesktopAdvertisedEndpointInput { | |
| 25 | − readonly port: number; | |
| 26 | − readonly exposure: DesktopServerExposure; | |
| 27 | − readonly customHttpsEndpointUrls?: readonly string[]; | |
| 28 | −} | |
| 29 | − | |
| 30 | −const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { | |
| 31 | − id: "desktop-core", | |
| 32 | − label: "Desktop", | |
| 33 | − kind: "core", | |
| 34 | − isAddon: false, | |
| 35 | −}; | |
| 36 | − | |
| 37 | −const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { | |
| 38 | − id: "manual", | |
| 39 | − label: "Manual", | |
| 40 | − kind: "manual", | |
| 41 | − isAddon: false, | |
| 42 | −}; | |
| 43 | − | |
| 44 | −const normalizeOptionalHost = (value: string | undefined): string | undefined => { | |
| 45 | − const normalized = value?.trim(); | |
| 46 | − return normalized && normalized.length > 0 ? normalized : undefined; | |
| 47 | −}; | |
| 48 | − | |
| 49 | −const isUsableLanIpv4Address = (address: string): boolean => | |
| 50 | − !address.startsWith("127.") && !address.startsWith("169.254."); | |
| 51 | − | |
| 52 | −function isHttpsEndpointUrl(value: string): boolean { | |
| 53 | − try { | |
| 54 | − return new URL(value).protocol === "https:"; | |
| 55 | − } catch { | |
| 56 | − return false; | |
| 57 | − } | |
| 58 | −} | |
| 59 | − | |
| 60 | −export function resolveLanAdvertisedHost( | |
| 61 | − networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>, | |
| 62 | − explicitHost: string | undefined, | |
| 63 | −): string | null { | |
| 64 | − const normalizedExplicitHost = normalizeOptionalHost(explicitHost); | |
| 65 | − if (normalizedExplicitHost) { | |
| 66 | − return normalizedExplicitHost; | |
| 67 | − } | |
| 68 | − | |
| 69 | − for (const interfaceAddresses of Object.values(networkInterfaces)) { | |
| 70 | − if (!interfaceAddresses) continue; | |
| 71 | − | |
| 72 | − for (const address of interfaceAddresses) { | |
| 73 | − if (address.internal) continue; | |
| 74 | − if (address.family !== "IPv4") continue; | |
| 75 | − if (!isUsableLanIpv4Address(address.address)) continue; | |
| 76 | − return address.address; | |
| 77 | − } | |
| 78 | − } | |
| 79 | − | |
| 80 | − return null; | |
| 81 | −} | |
| 82 | − | |
| 83 | −export function resolveDesktopServerExposure(input: { | |
| 84 | − readonly mode: DesktopServerExposureMode; | |
| 85 | − readonly port: number; | |
| 86 | − readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>; | |
| 87 | − readonly advertisedHostOverride?: string; | |
| 88 | −}): DesktopServerExposure { | |
| 89 | − const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; | |
| 90 | − const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; | |
| 91 | − | |
| 92 | − if (input.mode === "local-only") { | |
| 93 | − return { | |
| 94 | − mode: input.mode, | |
| 95 | − bindHost: DESKTOP_LOOPBACK_HOST, | |
| 96 | − localHttpUrl, | |
| 97 | − localWsUrl, | |
| 98 | − endpointUrl: null, | |
| 99 | − advertisedHost: null, | |
| 100 | − }; | |
| 101 | − } | |
| 102 | − | |
| 103 | − const advertisedHost = resolveLanAdvertisedHost( | |
| 104 | − input.networkInterfaces, | |
| 105 | − input.advertisedHostOverride, | |
| 106 | − ); | |
| 107 | − | |
| 108 | − return { | |
| 109 | − mode: input.mode, | |
| 110 | − bindHost: DESKTOP_LAN_BIND_HOST, | |
| 111 | − localHttpUrl, | |
| 112 | − localWsUrl, | |
| 113 | − endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null, | |
| 114 | − advertisedHost, | |
| 115 | − }; | |
| 116 | −} | |
| 117 | − | |
| 118 | −function createDesktopEndpoint( | |
| 119 | − input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">, | |
| 120 | −): AdvertisedEndpoint { | |
| 121 | − return createAdvertisedEndpoint({ | |
| 122 | − ...input, | |
| 123 | − provider: DESKTOP_CORE_ENDPOINT_PROVIDER, | |
| 124 | − source: "desktop-core", | |
| 125 | − }); | |
| 126 | −} | |
| 127 | − | |
| 128 | −function createManualEndpoint( | |
| 129 | − input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">, | |
| 130 | −): AdvertisedEndpoint { | |
| 131 | − return createAdvertisedEndpoint({ | |
| 132 | − ...input, | |
| 133 | − provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER, | |
| 134 | − source: "user", | |
| 135 | − }); | |
| 136 | −} | |
| 137 | − | |
| 138 | −export function resolveDesktopCoreAdvertisedEndpoints( | |
| 139 | − input: DesktopAdvertisedEndpointInput, | |
| 140 | −): readonly AdvertisedEndpoint[] { | |
| 141 | − const endpoints: AdvertisedEndpoint[] = [ | |
| 142 | − createDesktopEndpoint({ | |
| 143 | − id: `desktop-loopback:${input.port}`, | |
| 144 | − label: "This machine", | |
| 145 | − httpBaseUrl: input.exposure.localHttpUrl, | |
| 146 | − reachability: "loopback", | |
| 147 | − status: "available", | |
| 148 | − description: "Loopback endpoint for this desktop app.", | |
| 149 | − }), | |
| 150 | − ]; | |
| 151 | − | |
| 152 | − if (input.exposure.endpointUrl) { | |
| 153 | − endpoints.push( | |
| 154 | − createDesktopEndpoint({ | |
| 155 | − id: `desktop-lan:${input.exposure.endpointUrl}`, | |
| 156 | − label: "Local network", | |
| 157 | − httpBaseUrl: input.exposure.endpointUrl, | |
| 158 | − reachability: "lan", | |
| 159 | − status: "available", | |
| 160 | − isDefault: true, | |
| 161 | − description: "Reachable from devices on the same network.", | |
| 162 | − }), | |
| 163 | − ); | |
| 164 | − } | |
| 165 | − | |
| 166 | − for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) { | |
| 167 | − try { | |
| 168 | − const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl); | |
| 169 | − endpoints.push( | |
| 170 | − createManualEndpoint({ | |
| 171 | − id: `manual:${customEndpointUrl}`, | |
| 172 | − label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint", | |
| 173 | − httpBaseUrl: customEndpointUrl, | |
| 174 | − reachability: "public", | |
| 175 | − ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}), | |
| 176 | − status: "unknown", | |
| 177 | − description: isHttpsEndpoint | |
| 178 | − ? "User-configured HTTPS endpoint for this desktop backend." | |
| 179 | − : "User-configured endpoint for this desktop backend.", | |
| 180 | − }), | |
| 181 | − ); | |
| 182 | − } catch { | |
| 183 | − // Ignore malformed user-configured endpoints without dropping valid endpoints. | |
| 184 | − } | |
| 185 | − } | |
| 186 | − | |
| 187 | − return endpoints; | |
| 188 | −} | |
| @@ -1,31 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "vitest"; | |
| 2 | − | |
| 3 | −import { ServerListeningDetector } from "./serverListeningDetector.ts"; | |
| 4 | − | |
| 5 | −describe("ServerListeningDetector", () => { | |
| 6 | − it("resolves when the server logs the listening line", async () => { | |
| 7 | − const detector = new ServerListeningDetector(); | |
| 8 | − | |
| 9 | − detector.push("[01:23:30.571] INFO (#148): Listening on http://0.0.0.0:7011\n"); | |
| 10 | − | |
| 11 | − await expect(detector.promise).resolves.toBeUndefined(); | |
| 12 | − }); | |
| 13 | − | |
| 14 | − it("resolves when the listening line arrives across multiple chunks", async () => { | |
| 15 | − const detector = new ServerListeningDetector(); | |
| 16 | − | |
| 17 | − detector.push("[01:23:30.571] INFO (#148): Listen"); | |
| 18 | − detector.push("ing on http://0.0.0.0:7011\n"); | |
| 19 | − | |
| 20 | − await expect(detector.promise).resolves.toBeUndefined(); | |
| 21 | − }); | |
| 22 | − | |
| 23 | − it("rejects when the server exits before logging readiness", async () => { | |
| 24 | − const detector = new ServerListeningDetector(); | |
| 25 | − const error = new Error("server exited"); | |
| 26 | − | |
| 27 | − detector.fail(error); | |
| 28 | − | |
| 29 | − await expect(detector.promise).rejects.toBe(error); | |
| 30 | − }); | |
| 31 | −}); | |
| @@ -1,56 +0,0 @@ | ||
| 1 | −const LISTENING_LOG_FRAGMENT = "Listening on http://"; | |
| 2 | −const MAX_BUFFER_CHARS = 8_192; | |
| 3 | − | |
| 4 | −export class ServerListeningDetector { | |
| 5 | − private buffer = ""; | |
| 6 | − private settled = false; | |
| 7 | − private readonly resolvePromise: () => void; | |
| 8 | − private readonly rejectPromise: (error: unknown) => void; | |
| 9 | − readonly promise: Promise<void>; | |
| 10 | − | |
| 11 | − constructor() { | |
| 12 | − let resolvePromise: (() => void) | null = null; | |
| 13 | − let rejectPromise: ((error: unknown) => void) | null = null; | |
| 14 | − | |
| 15 | − this.promise = new Promise<void>((resolve, reject) => { | |
| 16 | − resolvePromise = resolve; | |
| 17 | − rejectPromise = reject; | |
| 18 | − }); | |
| 19 | − | |
| 20 | − this.resolvePromise = () => { | |
| 21 | − if (this.settled) { | |
| 22 | − return; | |
| 23 | − } | |
| 24 | − this.settled = true; | |
| 25 | − resolvePromise?.(); | |
| 26 | − }; | |
| 27 | − this.rejectPromise = (error) => { | |
| 28 | − if (this.settled) { | |
| 29 | − return; | |
| 30 | − } | |
| 31 | − this.settled = true; | |
| 32 | − rejectPromise?.(error); | |
| 33 | − }; | |
| 34 | − } | |
| 35 | − | |
| 36 | − push(chunk: unknown): void { | |
| 37 | − if (this.settled) { | |
| 38 | − return; | |
| 39 | − } | |
| 40 | − | |
| 41 | − const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); | |
| 42 | − this.buffer = `${this.buffer}${text.replace(/\r/g, "")}`; | |
| 43 | − if (this.buffer.includes(LISTENING_LOG_FRAGMENT)) { | |
| 44 | − this.resolvePromise(); | |
| 45 | − return; | |
| 46 | − } | |
| 47 | − | |
| 48 | − if (this.buffer.length > MAX_BUFFER_CHARS) { | |
| 49 | − this.buffer = this.buffer.slice(-MAX_BUFFER_CHARS); | |
| 50 | − } | |
| 51 | − } | |
| 52 | − | |
| 53 | − fail(error: unknown): void { | |
| 54 | − this.rejectPromise(error); | |
| 55 | − } | |
| 56 | −} | |
| @@ -0,0 +1,284 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, describe, it } from "@effect/vitest"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as FileSystem from "effect/FileSystem"; | |
| 5 | +import * as Layer from "effect/Layer"; | |
| 6 | +import * as Option from "effect/Option"; | |
| 7 | +import * as Schema from "effect/Schema"; | |
| 8 | + | |
| 9 | +import * as DesktopConfig from "../app/DesktopConfig.ts"; | |
| 10 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 11 | +import { | |
| 12 | + DEFAULT_DESKTOP_SETTINGS, | |
| 13 | + resolveDefaultDesktopSettings, | |
| 14 | + type DesktopSettings as DesktopSettingsValue, | |
| 15 | +} from "./DesktopAppSettings.ts"; | |
| 16 | +import * as DesktopAppSettings from "./DesktopAppSettings.ts"; | |
| 17 | + | |
| 18 | +const DesktopSettingsPatch = Schema.Struct({ | |
| 19 | + serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), | |
| 20 | + tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), | |
| 21 | + tailscaleServePort: Schema.optionalKey(Schema.Number), | |
| 22 | + updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])), | |
| 23 | + updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), | |
| 24 | +}); | |
| 25 | + | |
| 26 | +const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); | |
| 27 | +const encodeDesktopSettingsPatch = Schema.encodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); | |
| 28 | + | |
| 29 | +function makeEnvironmentLayer(baseDir: string, appVersion = "0.0.17") { | |
| 30 | + return DesktopEnvironment.layer({ | |
| 31 | + dirname: "/repo/apps/desktop/src", | |
| 32 | + homeDirectory: baseDir, | |
| 33 | + platform: "darwin", | |
| 34 | + processArch: "x64", | |
| 35 | + appVersion, | |
| 36 | + appPath: "/repo", | |
| 37 | + isPackaged: true, | |
| 38 | + resourcesPath: "/missing/resources", | |
| 39 | + runningUnderArm64Translation: false, | |
| 40 | + }).pipe( | |
| 41 | + Layer.provide( | |
| 42 | + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), | |
| 43 | + ), | |
| 44 | + ); | |
| 45 | +} | |
| 46 | + | |
| 47 | +const withSettings = <A, E, R>( | |
| 48 | + effect: Effect.Effect< | |
| 49 | + A, | |
| 50 | + E, | |
| 51 | + R | DesktopAppSettings.DesktopAppSettings | DesktopEnvironment.DesktopEnvironment | |
| 52 | + >, | |
| 53 | + options?: { readonly appVersion?: string }, | |
| 54 | +) => | |
| 55 | + Effect.gen(function* () { | |
| 56 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 57 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 58 | + prefix: "t3-desktop-settings-test-", | |
| 59 | + }); | |
| 60 | + return yield* effect.pipe( | |
| 61 | + Effect.provide( | |
| 62 | + DesktopAppSettings.layer.pipe( | |
| 63 | + Layer.provideMerge(makeEnvironmentLayer(baseDir, options?.appVersion)), | |
| 64 | + Layer.provideMerge(NodeServices.layer), | |
| 65 | + ), | |
| 66 | + ), | |
| 67 | + ); | |
| 68 | + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); | |
| 69 | + | |
| 70 | +function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { | |
| 71 | + return Effect.gen(function* () { | |
| 72 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 73 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 74 | + const encoded = yield* encodeDesktopSettingsPatch(patch); | |
| 75 | + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | |
| 76 | + yield* fileSystem.writeFileString(environment.desktopSettingsPath, `${encoded}\n`); | |
| 77 | + }); | |
| 78 | +} | |
| 79 | + | |
| 80 | +describe("DesktopSettings", () => { | |
| 81 | + it.effect("loads defaults when no settings file exists", () => | |
| 82 | + withSettings( | |
| 83 | + Effect.gen(function* () { | |
| 84 | + const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 85 | + assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS); | |
| 86 | + assert.deepEqual(yield* settings.get, DEFAULT_DESKTOP_SETTINGS); | |
| 87 | + }), | |
| 88 | + ), | |
| 89 | + ); | |
| 90 | + | |
| 91 | + it("defaults packaged nightly builds to the nightly update channel", () => { | |
| 92 | + assert.deepEqual(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { | |
| 93 | + serverExposureMode: "local-only", | |
| 94 | + tailscaleServeEnabled: false, | |
| 95 | + tailscaleServePort: 443, | |
| 96 | + updateChannel: "nightly", | |
| 97 | + updateChannelConfiguredByUser: false, | |
| 98 | + } satisfies DesktopSettingsValue); | |
| 99 | + }); | |
| 100 | + | |
| 101 | + it.effect("loads persisted settings and applies semantic updates", () => | |
| 102 | + withSettings( | |
| 103 | + Effect.gen(function* () { | |
| 104 | + const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 105 | + yield* writeSettingsPatch({ | |
| 106 | + serverExposureMode: "network-accessible", | |
| 107 | + tailscaleServeEnabled: true, | |
| 108 | + tailscaleServePort: 8443, | |
| 109 | + updateChannel: "latest", | |
| 110 | + updateChannelConfiguredByUser: true, | |
| 111 | + }); | |
| 112 | + | |
| 113 | + assert.deepEqual(yield* settings.load, { | |
| 114 | + serverExposureMode: "network-accessible", | |
| 115 | + tailscaleServeEnabled: true, | |
| 116 | + tailscaleServePort: 8443, | |
| 117 | + updateChannel: "latest", | |
| 118 | + updateChannelConfiguredByUser: true, | |
| 119 | + } satisfies DesktopSettingsValue); | |
| 120 | + | |
| 121 | + const exposure = yield* settings.setServerExposureMode("local-only"); | |
| 122 | + assert.isTrue(exposure.changed); | |
| 123 | + assert.equal(exposure.settings.serverExposureMode, "local-only"); | |
| 124 | + | |
| 125 | + const tailscale = yield* settings.setTailscaleServe({ | |
| 126 | + enabled: true, | |
| 127 | + port: Option.some(9443), | |
| 128 | + }); | |
| 129 | + assert.isTrue(tailscale.changed); | |
| 130 | + assert.equal(tailscale.settings.tailscaleServePort, 9443); | |
| 131 | + | |
| 132 | + const updateChannel = yield* settings.setUpdateChannel("nightly"); | |
| 133 | + assert.isTrue(updateChannel.changed); | |
| 134 | + assert.equal(updateChannel.settings.updateChannel, "nightly"); | |
| 135 | + assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true); | |
| 136 | + }), | |
| 137 | + ), | |
| 138 | + ); | |
| 139 | + | |
| 140 | + it.effect("does not persist no-op semantic updates", () => | |
| 141 | + withSettings( | |
| 142 | + Effect.gen(function* () { | |
| 143 | + const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 144 | + | |
| 145 | + const exposure = yield* settings.setServerExposureMode("local-only"); | |
| 146 | + assert.isFalse(exposure.changed); | |
| 147 | + | |
| 148 | + const tailscale = yield* settings.setTailscaleServe({ | |
| 149 | + enabled: false, | |
| 150 | + port: Option.none(), | |
| 151 | + }); | |
| 152 | + assert.isFalse(tailscale.changed); | |
| 153 | + | |
| 154 | + const updateChannel = yield* settings.setUpdateChannel("latest"); | |
| 155 | + assert.isFalse(updateChannel.changed); | |
| 156 | + assert.equal(updateChannel.settings.updateChannelConfiguredByUser, false); | |
| 157 | + }), | |
| 158 | + ), | |
| 159 | + ); | |
| 160 | + | |
| 161 | + it.effect("falls back to defaults when the settings file is malformed", () => | |
| 162 | + withSettings( | |
| 163 | + Effect.gen(function* () { | |
| 164 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 165 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 166 | + const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 167 | + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | |
| 168 | + yield* fileSystem.writeFileString(environment.desktopSettingsPath, "{not-json"); | |
| 169 | + | |
| 170 | + assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS); | |
| 171 | + }), | |
| 172 | + ), | |
| 173 | + ); | |
| 174 | + | |
| 175 | + it.effect("loads lenient persisted desktop settings JSON", () => | |
| 176 | + withSettings( | |
| 177 | + Effect.gen(function* () { | |
| 178 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 179 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 180 | + const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 181 | + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | |
| 182 | + yield* fileSystem.writeFileString( | |
| 183 | + environment.desktopSettingsPath, | |
| 184 | + `{ | |
| 185 | + // JSONC-style comments and trailing commas match server settings parsing. | |
| 186 | + "serverExposureMode": "network-accessible", | |
| 187 | + "tailscaleServeEnabled": true, | |
| 188 | + "tailscaleServePort": 8443, | |
| 189 | + }\n`, | |
| 190 | + ); | |
| 191 | + | |
| 192 | + assert.deepEqual(yield* settings.load, { | |
| 193 | + serverExposureMode: "network-accessible", | |
| 194 | + tailscaleServeEnabled: true, | |
| 195 | + tailscaleServePort: 8443, | |
| 196 | + updateChannel: "latest", | |
| 197 | + updateChannelConfiguredByUser: false, | |
| 198 | + } satisfies DesktopSettingsValue); | |
| 199 | + }), | |
| 200 | + ), | |
| 201 | + ); | |
| 202 | + | |
| 203 | + it.effect("persists sparse desktop settings documents", () => | |
| 204 | + withSettings( | |
| 205 | + Effect.gen(function* () { | |
| 206 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 207 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 208 | + const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 209 | + | |
| 210 | + yield* settings.setServerExposureMode("network-accessible"); | |
| 211 | + | |
| 212 | + const persisted = yield* decodeDesktopSettingsPatch( | |
| 213 | + yield* fileSystem.readFileString(environment.desktopSettingsPath), | |
| 214 | + ); | |
| 215 | + assert.deepEqual(persisted, { | |
| 216 | + serverExposureMode: "network-accessible", | |
| 217 | + } satisfies typeof DesktopSettingsPatch.Type); | |
| 218 | + }), | |
| 219 | + ), | |
| 220 | + ); | |
| 221 | + | |
| 222 | + it.effect("migrates legacy implicit update channels to the runtime default", () => | |
| 223 | + withSettings( | |
| 224 | + Effect.gen(function* () { | |
| 225 | + const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 226 | + yield* writeSettingsPatch({ | |
| 227 | + serverExposureMode: "local-only", | |
| 228 | + updateChannel: "latest", | |
| 229 | + }); | |
| 230 | + | |
| 231 | + assert.deepEqual(yield* settings.load, { | |
| 232 | + serverExposureMode: "local-only", | |
| 233 | + tailscaleServeEnabled: false, | |
| 234 | + tailscaleServePort: 443, | |
| 235 | + updateChannel: "nightly", | |
| 236 | + updateChannelConfiguredByUser: false, | |
| 237 | + } satisfies DesktopSettingsValue); | |
| 238 | + }), | |
| 239 | + { appVersion: "0.0.17-nightly.20260415.1" }, | |
| 240 | + ), | |
| 241 | + ); | |
| 242 | + | |
| 243 | + it.effect("preserves explicit stable update channel on nightly builds", () => | |
| 244 | + withSettings( | |
| 245 | + Effect.gen(function* () { | |
| 246 | + const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 247 | + yield* writeSettingsPatch({ | |
| 248 | + serverExposureMode: "local-only", | |
| 249 | + updateChannel: "latest", | |
| 250 | + updateChannelConfiguredByUser: true, | |
| 251 | + }); | |
| 252 | + | |
| 253 | + assert.deepEqual(yield* settings.load, { | |
| 254 | + serverExposureMode: "local-only", | |
| 255 | + tailscaleServeEnabled: false, | |
| 256 | + tailscaleServePort: 443, | |
| 257 | + updateChannel: "latest", | |
| 258 | + updateChannelConfiguredByUser: true, | |
| 259 | + } satisfies DesktopSettingsValue); | |
| 260 | + }), | |
| 261 | + { appVersion: "0.0.17-nightly.20260415.1" }, | |
| 262 | + ), | |
| 263 | + ); | |
| 264 | + | |
| 265 | + it.effect("normalizes invalid persisted Tailscale Serve ports", () => | |
| 266 | + withSettings( | |
| 267 | + Effect.gen(function* () { | |
| 268 | + const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 269 | + yield* writeSettingsPatch({ | |
| 270 | + tailscaleServeEnabled: true, | |
| 271 | + tailscaleServePort: 0, | |
| 272 | + }); | |
| 273 | + | |
| 274 | + assert.deepEqual(yield* settings.load, { | |
| 275 | + serverExposureMode: "local-only", | |
| 276 | + tailscaleServeEnabled: true, | |
| 277 | + tailscaleServePort: 443, | |
| 278 | + updateChannel: "latest", | |
| 279 | + updateChannelConfiguredByUser: false, | |
| 280 | + } satisfies DesktopSettingsValue); | |
| 281 | + }), | |
| 282 | + ), | |
| 283 | + ); | |
| 284 | +}); | |
| @@ -0,0 +1,318 @@ | ||
| 1 | +import { | |
| 2 | + DesktopServerExposureModeSchema, | |
| 3 | + DesktopUpdateChannelSchema, | |
| 4 | + type DesktopServerExposureMode, | |
| 5 | + type DesktopUpdateChannel, | |
| 6 | +} from "@t3tools/contracts"; | |
| 7 | +import { fromLenientJson } from "@t3tools/shared/schemaJson"; | |
| 8 | +import * as Context from "effect/Context"; | |
| 9 | +import * as Data from "effect/Data"; | |
| 10 | +import * as Effect from "effect/Effect"; | |
| 11 | +import * as FileSystem from "effect/FileSystem"; | |
| 12 | +import * as Layer from "effect/Layer"; | |
| 13 | +import * as Option from "effect/Option"; | |
| 14 | +import * as Path from "effect/Path"; | |
| 15 | +import * as PlatformError from "effect/PlatformError"; | |
| 16 | +import * as Random from "effect/Random"; | |
| 17 | +import * as Schema from "effect/Schema"; | |
| 18 | +import * as SynchronizedRef from "effect/SynchronizedRef"; | |
| 19 | + | |
| 20 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 21 | +import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; | |
| 22 | + | |
| 23 | +export interface DesktopSettings { | |
| 24 | + readonly serverExposureMode: DesktopServerExposureMode; | |
| 25 | + readonly tailscaleServeEnabled: boolean; | |
| 26 | + readonly tailscaleServePort: number; | |
| 27 | + readonly updateChannel: DesktopUpdateChannel; | |
| 28 | + readonly updateChannelConfiguredByUser: boolean; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export interface DesktopSettingsChange { | |
| 32 | + readonly settings: DesktopSettings; | |
| 33 | + readonly changed: boolean; | |
| 34 | +} | |
| 35 | + | |
| 36 | +export const DEFAULT_TAILSCALE_SERVE_PORT = 443; | |
| 37 | + | |
| 38 | +export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { | |
| 39 | + serverExposureMode: "local-only", | |
| 40 | + tailscaleServeEnabled: false, | |
| 41 | + tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, | |
| 42 | + updateChannel: "latest", | |
| 43 | + updateChannelConfiguredByUser: false, | |
| 44 | +}; | |
| 45 | + | |
| 46 | +const DesktopSettingsDocument = Schema.Struct({ | |
| 47 | + serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), | |
| 48 | + tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), | |
| 49 | + tailscaleServePort: Schema.optionalKey(Schema.Number), | |
| 50 | + updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), | |
| 51 | + updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), | |
| 52 | +}); | |
| 53 | + | |
| 54 | +type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; | |
| 55 | +type Mutable<T> = { -readonly [K in keyof T]: T[K] }; | |
| 56 | + | |
| 57 | +const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument); | |
| 58 | +const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson); | |
| 59 | +const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson); | |
| 60 | + | |
| 61 | +const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({ | |
| 62 | + settings, | |
| 63 | + changed, | |
| 64 | +}); | |
| 65 | + | |
| 66 | +export class DesktopSettingsWriteError extends Data.TaggedError("DesktopSettingsWriteError")<{ | |
| 67 | + readonly cause: PlatformError.PlatformError | Schema.SchemaError; | |
| 68 | +}> { | |
| 69 | + override get message() { | |
| 70 | + return `Failed to write desktop settings: ${this.cause.message}`; | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +export interface DesktopAppSettingsShape { | |
| 75 | + readonly load: Effect.Effect<DesktopSettings>; | |
| 76 | + readonly get: Effect.Effect<DesktopSettings>; | |
| 77 | + readonly setServerExposureMode: ( | |
| 78 | + mode: DesktopServerExposureMode, | |
| 79 | + ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; | |
| 80 | + readonly setTailscaleServe: (input: { | |
| 81 | + readonly enabled: boolean; | |
| 82 | + readonly port: Option.Option<number>; | |
| 83 | + }) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; | |
| 84 | + readonly setUpdateChannel: ( | |
| 85 | + channel: DesktopUpdateChannel, | |
| 86 | + ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; | |
| 87 | +} | |
| 88 | + | |
| 89 | +export class DesktopAppSettings extends Context.Service< | |
| 90 | + DesktopAppSettings, | |
| 91 | + DesktopAppSettingsShape | |
| 92 | +>()("t3/desktop/AppSettings") {} | |
| 93 | + | |
| 94 | +export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { | |
| 95 | + return { | |
| 96 | + ...DEFAULT_DESKTOP_SETTINGS, | |
| 97 | + updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), | |
| 98 | + }; | |
| 99 | +} | |
| 100 | + | |
| 101 | +function normalizeTailscaleServePort(value: unknown): number { | |
| 102 | + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535 | |
| 103 | + ? value | |
| 104 | + : DEFAULT_TAILSCALE_SERVE_PORT; | |
| 105 | +} | |
| 106 | + | |
| 107 | +function normalizeDesktopSettingsDocument( | |
| 108 | + parsed: DesktopSettingsDocument, | |
| 109 | + appVersion: string, | |
| 110 | +): DesktopSettings { | |
| 111 | + const defaultSettings = resolveDefaultDesktopSettings(appVersion); | |
| 112 | + const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel); | |
| 113 | + const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; | |
| 114 | + const updateChannelConfiguredByUser = | |
| 115 | + parsed.updateChannelConfiguredByUser === true || | |
| 116 | + (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly")); | |
| 117 | + | |
| 118 | + return { | |
| 119 | + serverExposureMode: | |
| 120 | + parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", | |
| 121 | + tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, | |
| 122 | + tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), | |
| 123 | + updateChannel: updateChannelConfiguredByUser | |
| 124 | + ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) | |
| 125 | + : defaultSettings.updateChannel, | |
| 126 | + updateChannelConfiguredByUser, | |
| 127 | + }; | |
| 128 | +} | |
| 129 | + | |
| 130 | +function toDesktopSettingsDocument( | |
| 131 | + settings: DesktopSettings, | |
| 132 | + defaults: DesktopSettings, | |
| 133 | +): DesktopSettingsDocument { | |
| 134 | + const document: Mutable<DesktopSettingsDocument> = {}; | |
| 135 | + | |
| 136 | + if (settings.serverExposureMode !== defaults.serverExposureMode) { | |
| 137 | + document.serverExposureMode = settings.serverExposureMode; | |
| 138 | + } | |
| 139 | + if (settings.tailscaleServeEnabled !== defaults.tailscaleServeEnabled) { | |
| 140 | + document.tailscaleServeEnabled = settings.tailscaleServeEnabled; | |
| 141 | + } | |
| 142 | + if (settings.tailscaleServePort !== defaults.tailscaleServePort) { | |
| 143 | + document.tailscaleServePort = settings.tailscaleServePort; | |
| 144 | + } | |
| 145 | + if (settings.updateChannel !== defaults.updateChannel) { | |
| 146 | + document.updateChannel = settings.updateChannel; | |
| 147 | + } | |
| 148 | + if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) { | |
| 149 | + document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser; | |
| 150 | + } | |
| 151 | + | |
| 152 | + return document; | |
| 153 | +} | |
| 154 | + | |
| 155 | +function setServerExposureMode( | |
| 156 | + settings: DesktopSettings, | |
| 157 | + requestedMode: DesktopServerExposureMode, | |
| 158 | +): DesktopSettings { | |
| 159 | + return settings.serverExposureMode === requestedMode | |
| 160 | + ? settings | |
| 161 | + : { | |
| 162 | + ...settings, | |
| 163 | + serverExposureMode: requestedMode, | |
| 164 | + }; | |
| 165 | +} | |
| 166 | + | |
| 167 | +function setTailscaleServe( | |
| 168 | + settings: DesktopSettings, | |
| 169 | + input: { readonly enabled: boolean; readonly port: Option.Option<number> }, | |
| 170 | +): DesktopSettings { | |
| 171 | + const port = Option.match(input.port, { | |
| 172 | + onNone: () => settings.tailscaleServePort, | |
| 173 | + onSome: normalizeTailscaleServePort, | |
| 174 | + }); | |
| 175 | + return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port | |
| 176 | + ? settings | |
| 177 | + : { | |
| 178 | + ...settings, | |
| 179 | + tailscaleServeEnabled: input.enabled, | |
| 180 | + tailscaleServePort: port, | |
| 181 | + }; | |
| 182 | +} | |
| 183 | + | |
| 184 | +function setUpdateChannel( | |
| 185 | + settings: DesktopSettings, | |
| 186 | + requestedChannel: DesktopUpdateChannel, | |
| 187 | +): DesktopSettings { | |
| 188 | + return settings.updateChannel === requestedChannel | |
| 189 | + ? settings | |
| 190 | + : { | |
| 191 | + ...settings, | |
| 192 | + updateChannel: requestedChannel, | |
| 193 | + updateChannelConfiguredByUser: true, | |
| 194 | + }; | |
| 195 | +} | |
| 196 | + | |
| 197 | +function readSettings( | |
| 198 | + fileSystem: FileSystem.FileSystem, | |
| 199 | + settingsPath: string, | |
| 200 | + appVersion: string, | |
| 201 | +): Effect.Effect<DesktopSettings> { | |
| 202 | + const defaultSettings = resolveDefaultDesktopSettings(appVersion); | |
| 203 | + | |
| 204 | + return fileSystem.readFileString(settingsPath).pipe( | |
| 205 | + Effect.option, | |
| 206 | + Effect.flatMap( | |
| 207 | + Option.match({ | |
| 208 | + onNone: () => Effect.succeed(defaultSettings), | |
| 209 | + onSome: (raw) => | |
| 210 | + decodeDesktopSettingsJson(raw).pipe( | |
| 211 | + Effect.map((parsed) => normalizeDesktopSettingsDocument(parsed, appVersion)), | |
| 212 | + Effect.catch(() => Effect.succeed(defaultSettings)), | |
| 213 | + ), | |
| 214 | + }), | |
| 215 | + ), | |
| 216 | + ); | |
| 217 | +} | |
| 218 | + | |
| 219 | +const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (input: { | |
| 220 | + readonly fileSystem: FileSystem.FileSystem; | |
| 221 | + readonly path: Path.Path; | |
| 222 | + readonly settingsPath: string; | |
| 223 | + readonly settings: DesktopSettings; | |
| 224 | + readonly defaultSettings: DesktopSettings; | |
| 225 | +}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> { | |
| 226 | + const directory = input.path.dirname(input.settingsPath); | |
| 227 | + const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); | |
| 228 | + const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`; | |
| 229 | + const encoded = yield* encodeDesktopSettingsJson( | |
| 230 | + toDesktopSettingsDocument(input.settings, input.defaultSettings), | |
| 231 | + ); | |
| 232 | + yield* input.fileSystem.makeDirectory(directory, { recursive: true }); | |
| 233 | + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); | |
| 234 | + yield* input.fileSystem.rename(tempPath, input.settingsPath); | |
| 235 | +}); | |
| 236 | + | |
| 237 | +export const layer = Layer.effect( | |
| 238 | + DesktopAppSettings, | |
| 239 | + Effect.gen(function* () { | |
| 240 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 241 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 242 | + const path = yield* Path.Path; | |
| 243 | + const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); | |
| 244 | + | |
| 245 | + const persist = ( | |
| 246 | + update: (settings: DesktopSettings) => DesktopSettings, | |
| 247 | + ): Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError> => | |
| 248 | + SynchronizedRef.modifyEffect(settingsRef, (settings) => { | |
| 249 | + const nextSettings = update(settings); | |
| 250 | + if (nextSettings === settings) { | |
| 251 | + return Effect.succeed([settingsChange(settings, false), settings] as const); | |
| 252 | + } | |
| 253 | + | |
| 254 | + return writeSettings({ | |
| 255 | + fileSystem, | |
| 256 | + path, | |
| 257 | + settingsPath: environment.desktopSettingsPath, | |
| 258 | + settings: nextSettings, | |
| 259 | + defaultSettings: environment.defaultDesktopSettings, | |
| 260 | + }).pipe( | |
| 261 | + Effect.mapError((cause) => new DesktopSettingsWriteError({ cause })), | |
| 262 | + Effect.as([settingsChange(nextSettings, true), nextSettings] as const), | |
| 263 | + ); | |
| 264 | + }); | |
| 265 | + | |
| 266 | + return DesktopAppSettings.of({ | |
| 267 | + get: SynchronizedRef.get(settingsRef), | |
| 268 | + load: Effect.gen(function* () { | |
| 269 | + const settings = yield* readSettings( | |
| 270 | + fileSystem, | |
| 271 | + environment.desktopSettingsPath, | |
| 272 | + environment.appVersion, | |
| 273 | + ); | |
| 274 | + return yield* SynchronizedRef.setAndGet(settingsRef, settings); | |
| 275 | + }).pipe(Effect.withSpan("desktop.settings.load")), | |
| 276 | + setServerExposureMode: (mode) => | |
| 277 | + persist((settings) => setServerExposureMode(settings, mode)).pipe( | |
| 278 | + Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), | |
| 279 | + ), | |
| 280 | + setTailscaleServe: (input) => | |
| 281 | + persist((settings) => setTailscaleServe(settings, input)).pipe( | |
| 282 | + Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), | |
| 283 | + ), | |
| 284 | + setUpdateChannel: (channel) => | |
| 285 | + persist((settings) => setUpdateChannel(settings, channel)).pipe( | |
| 286 | + Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), | |
| 287 | + ), | |
| 288 | + }); | |
| 289 | + }), | |
| 290 | +); | |
| 291 | + | |
| 292 | +export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SETTINGS) => | |
| 293 | + Layer.effect( | |
| 294 | + DesktopAppSettings, | |
| 295 | + Effect.gen(function* () { | |
| 296 | + const settingsRef = yield* SynchronizedRef.make(initialSettings); | |
| 297 | + const update = (f: (settings: DesktopSettings) => DesktopSettings) => | |
| 298 | + SynchronizedRef.modify(settingsRef, (settings) => { | |
| 299 | + const nextSettings = f(settings); | |
| 300 | + return [ | |
| 301 | + { | |
| 302 | + settings: nextSettings, | |
| 303 | + changed: nextSettings !== settings, | |
| 304 | + }, | |
| 305 | + nextSettings, | |
| 306 | + ] as const; | |
| 307 | + }); | |
| 308 | + | |
| 309 | + return DesktopAppSettings.of({ | |
| 310 | + get: SynchronizedRef.get(settingsRef), | |
| 311 | + load: SynchronizedRef.get(settingsRef), | |
| 312 | + setServerExposureMode: (mode) => | |
| 313 | + update((settings) => setServerExposureMode(settings, mode)), | |
| 314 | + setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), | |
| 315 | + setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), | |
| 316 | + }); | |
| 317 | + }), | |
| 318 | + ); | |
| @@ -0,0 +1,185 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, describe, it } from "@effect/vitest"; | |
| 3 | +import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 5 | +import * as FileSystem from "effect/FileSystem"; | |
| 6 | +import * as Layer from "effect/Layer"; | |
| 7 | +import * as Option from "effect/Option"; | |
| 8 | +import * as Schema from "effect/Schema"; | |
| 9 | + | |
| 10 | +import * as DesktopConfig from "../app/DesktopConfig.ts"; | |
| 11 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 12 | +import * as DesktopClientSettings from "./DesktopClientSettings.ts"; | |
| 13 | + | |
| 14 | +const clientSettings: ClientSettings = { | |
| 15 | + autoOpenPlanSidebar: false, | |
| 16 | + confirmThreadArchive: true, | |
| 17 | + confirmThreadDelete: false, | |
| 18 | + dismissedProviderUpdateNotificationKeys: [], | |
| 19 | + diffIgnoreWhitespace: true, | |
| 20 | + diffWordWrap: true, | |
| 21 | + favorites: [], | |
| 22 | + providerModelPreferences: {}, | |
| 23 | + sidebarProjectGroupingMode: "repository_path", | |
| 24 | + sidebarProjectGroupingOverrides: { | |
| 25 | + "environment-1:/tmp/project-a": "separate", | |
| 26 | + }, | |
| 27 | + sidebarProjectSortOrder: "manual", | |
| 28 | + sidebarThreadSortOrder: "created_at", | |
| 29 | + sidebarThreadPreviewCount: 6, | |
| 30 | + timestampFormat: "24-hour", | |
| 31 | +}; | |
| 32 | + | |
| 33 | +const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema)); | |
| 34 | +const decodeRecordJson = Schema.decodeEffect( | |
| 35 | + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), | |
| 36 | +); | |
| 37 | + | |
| 38 | +function makeLayer(baseDir: string) { | |
| 39 | + const environmentLayer = DesktopEnvironment.layer({ | |
| 40 | + dirname: "/repo/apps/desktop/src", | |
| 41 | + homeDirectory: baseDir, | |
| 42 | + platform: "darwin", | |
| 43 | + processArch: "x64", | |
| 44 | + appVersion: "1.2.3", | |
| 45 | + appPath: "/repo", | |
| 46 | + isPackaged: true, | |
| 47 | + resourcesPath: "/missing/resources", | |
| 48 | + runningUnderArm64Translation: false, | |
| 49 | + }).pipe( | |
| 50 | + Layer.provide( | |
| 51 | + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), | |
| 52 | + ), | |
| 53 | + ); | |
| 54 | + | |
| 55 | + return DesktopClientSettings.layer.pipe( | |
| 56 | + Layer.provideMerge(environmentLayer), | |
| 57 | + Layer.provideMerge(NodeServices.layer), | |
| 58 | + ); | |
| 59 | +} | |
| 60 | + | |
| 61 | +const withClientSettings = <A, E, R>( | |
| 62 | + effect: Effect.Effect<A, E, R | DesktopClientSettings.DesktopClientSettings>, | |
| 63 | +) => | |
| 64 | + Effect.gen(function* () { | |
| 65 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 66 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 67 | + prefix: "t3-desktop-client-settings-test-", | |
| 68 | + }); | |
| 69 | + return yield* effect.pipe(Effect.provide(makeLayer(baseDir))); | |
| 70 | + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); | |
| 71 | + | |
| 72 | +describe("DesktopClientSettings", () => { | |
| 73 | + it.effect("returns none when no client settings file exists", () => | |
| 74 | + withClientSettings( | |
| 75 | + Effect.gen(function* () { | |
| 76 | + const settings = yield* DesktopClientSettings.DesktopClientSettings; | |
| 77 | + assert.isTrue(Option.isNone(yield* settings.get)); | |
| 78 | + }), | |
| 79 | + ), | |
| 80 | + ); | |
| 81 | + | |
| 82 | + it.effect("persists and reloads client settings", () => | |
| 83 | + withClientSettings( | |
| 84 | + Effect.gen(function* () { | |
| 85 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 86 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 87 | + const settings = yield* DesktopClientSettings.DesktopClientSettings; | |
| 88 | + yield* settings.set(clientSettings); | |
| 89 | + | |
| 90 | + assert.deepEqual(yield* settings.get, Option.some(clientSettings)); | |
| 91 | + assert.deepEqual( | |
| 92 | + yield* decodeClientSettingsJson( | |
| 93 | + yield* fileSystem.readFileString(environment.clientSettingsPath), | |
| 94 | + ), | |
| 95 | + clientSettings, | |
| 96 | + ); | |
| 97 | + assert.isFalse( | |
| 98 | + Object.hasOwn( | |
| 99 | + yield* decodeRecordJson( | |
| 100 | + yield* fileSystem.readFileString(environment.clientSettingsPath), | |
| 101 | + ), | |
| 102 | + "settings", | |
| 103 | + ), | |
| 104 | + ); | |
| 105 | + }), | |
| 106 | + ), | |
| 107 | + ); | |
| 108 | + | |
| 109 | + it.effect("loads lenient direct client settings documents", () => | |
| 110 | + withClientSettings( | |
| 111 | + Effect.gen(function* () { | |
| 112 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 113 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 114 | + const settings = yield* DesktopClientSettings.DesktopClientSettings; | |
| 115 | + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | |
| 116 | + yield* fileSystem.writeFileString( | |
| 117 | + environment.clientSettingsPath, | |
| 118 | + `{ | |
| 119 | + // Matches server settings parsing. | |
| 120 | + "timestampFormat": "24-hour", | |
| 121 | + }\n`, | |
| 122 | + ); | |
| 123 | + | |
| 124 | + const persisted = yield* settings.get; | |
| 125 | + assert.isTrue(Option.isSome(persisted)); | |
| 126 | + if (Option.isSome(persisted)) { | |
| 127 | + assert.equal(persisted.value.timestampFormat, "24-hour"); | |
| 128 | + } | |
| 129 | + }), | |
| 130 | + ), | |
| 131 | + ); | |
| 132 | + | |
| 133 | + it.effect("loads legacy wrapped client settings documents", () => | |
| 134 | + withClientSettings( | |
| 135 | + Effect.gen(function* () { | |
| 136 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 137 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 138 | + const settings = yield* DesktopClientSettings.DesktopClientSettings; | |
| 139 | + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | |
| 140 | + yield* fileSystem.writeFileString( | |
| 141 | + environment.clientSettingsPath, | |
| 142 | + `{ | |
| 143 | + "settings": { | |
| 144 | + "timestampFormat": "12-hour" | |
| 145 | + } | |
| 146 | + }\n`, | |
| 147 | + ); | |
| 148 | + | |
| 149 | + const persisted = yield* settings.get; | |
| 150 | + assert.isTrue(Option.isSome(persisted)); | |
| 151 | + if (Option.isSome(persisted)) { | |
| 152 | + assert.equal(persisted.value.timestampFormat, "12-hour"); | |
| 153 | + } | |
| 154 | + }), | |
| 155 | + ), | |
| 156 | + ); | |
| 157 | + | |
| 158 | + it.effect("loads defaults from empty client settings documents", () => | |
| 159 | + withClientSettings( | |
| 160 | + Effect.gen(function* () { | |
| 161 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 162 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 163 | + const settings = yield* DesktopClientSettings.DesktopClientSettings; | |
| 164 | + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | |
| 165 | + yield* fileSystem.writeFileString(environment.clientSettingsPath, "{}\n"); | |
| 166 | + | |
| 167 | + assert.deepEqual(yield* settings.get, Option.some(yield* decodeClientSettingsJson("{}"))); | |
| 168 | + }), | |
| 169 | + ), | |
| 170 | + ); | |
| 171 | + | |
| 172 | + it.effect("treats malformed client settings documents as absent", () => | |
| 173 | + withClientSettings( | |
| 174 | + Effect.gen(function* () { | |
| 175 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 176 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 177 | + const settings = yield* DesktopClientSettings.DesktopClientSettings; | |
| 178 | + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | |
| 179 | + yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json"); | |
| 180 | + | |
| 181 | + assert.isTrue(Option.isNone(yield* settings.get)); | |
| 182 | + }), | |
| 183 | + ), | |
| 184 | + ); | |
| 185 | +}); | |
| @@ -0,0 +1,118 @@ | ||
| 1 | +import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; | |
| 2 | +import { fromLenientJson } from "@t3tools/shared/schemaJson"; | |
| 3 | +import * as Context from "effect/Context"; | |
| 4 | +import * as Data from "effect/Data"; | |
| 5 | +import * as Effect from "effect/Effect"; | |
| 6 | +import * as FileSystem from "effect/FileSystem"; | |
| 7 | +import * as Layer from "effect/Layer"; | |
| 8 | +import * as Option from "effect/Option"; | |
| 9 | +import * as Path from "effect/Path"; | |
| 10 | +import * as PlatformError from "effect/PlatformError"; | |
| 11 | +import * as Random from "effect/Random"; | |
| 12 | +import * as Schema from "effect/Schema"; | |
| 13 | +import * as Ref from "effect/Ref"; | |
| 14 | + | |
| 15 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 16 | + | |
| 17 | +const ClientSettingsDocumentSchema = Schema.Struct({ | |
| 18 | + settings: ClientSettingsSchema, | |
| 19 | +}); | |
| 20 | + | |
| 21 | +const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); | |
| 22 | +const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema); | |
| 23 | +const decodeClientSettingsJson = (raw: string): Effect.Effect<ClientSettings, Schema.SchemaError> => | |
| 24 | + Schema.decodeEffect(LegacyClientSettingsDocumentJson)(raw).pipe( | |
| 25 | + Effect.map((document) => document.settings), | |
| 26 | + Effect.catch(() => Schema.decodeEffect(ClientSettingsJson)(raw)), | |
| 27 | + ); | |
| 28 | +const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); | |
| 29 | + | |
| 30 | +export class DesktopClientSettingsWriteError extends Data.TaggedError( | |
| 31 | + "DesktopClientSettingsWriteError", | |
| 32 | +)<{ | |
| 33 | + readonly cause: PlatformError.PlatformError | Schema.SchemaError; | |
| 34 | +}> { | |
| 35 | + override get message() { | |
| 36 | + return `Failed to write desktop client settings: ${this.cause.message}`; | |
| 37 | + } | |
| 38 | +} | |
| 39 | + | |
| 40 | +export interface DesktopClientSettingsShape { | |
| 41 | + readonly get: Effect.Effect<Option.Option<ClientSettings>>; | |
| 42 | + readonly set: (settings: ClientSettings) => Effect.Effect<void, DesktopClientSettingsWriteError>; | |
| 43 | +} | |
| 44 | + | |
| 45 | +export class DesktopClientSettings extends Context.Service< | |
| 46 | + DesktopClientSettings, | |
| 47 | + DesktopClientSettingsShape | |
| 48 | +>()("t3/desktop/ClientSettings") {} | |
| 49 | + | |
| 50 | +const readClientSettings = ( | |
| 51 | + fileSystem: FileSystem.FileSystem, | |
| 52 | + settingsPath: string, | |
| 53 | +): Effect.Effect<Option.Option<ClientSettings>> => | |
| 54 | + fileSystem.readFileString(settingsPath).pipe( | |
| 55 | + Effect.option, | |
| 56 | + Effect.flatMap( | |
| 57 | + Option.match({ | |
| 58 | + onNone: () => Effect.succeed(Option.none<ClientSettings>()), | |
| 59 | + onSome: (raw) => | |
| 60 | + decodeClientSettingsJson(raw).pipe( | |
| 61 | + Effect.map((settings) => Option.some(settings)), | |
| 62 | + Effect.catch(() => Effect.succeed(Option.none<ClientSettings>())), | |
| 63 | + ), | |
| 64 | + }), | |
| 65 | + ), | |
| 66 | + ); | |
| 67 | + | |
| 68 | +const writeClientSettings = Effect.fnUntraced(function* (input: { | |
| 69 | + readonly fileSystem: FileSystem.FileSystem; | |
| 70 | + readonly path: Path.Path; | |
| 71 | + readonly settingsPath: string; | |
| 72 | + readonly settings: ClientSettings; | |
| 73 | +}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> { | |
| 74 | + const directory = input.path.dirname(input.settingsPath); | |
| 75 | + const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); | |
| 76 | + const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`; | |
| 77 | + const encoded = yield* encodeClientSettingsJson(input.settings); | |
| 78 | + yield* input.fileSystem.makeDirectory(directory, { recursive: true }); | |
| 79 | + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); | |
| 80 | + yield* input.fileSystem.rename(tempPath, input.settingsPath); | |
| 81 | +}); | |
| 82 | + | |
| 83 | +export const layer = Layer.effect( | |
| 84 | + DesktopClientSettings, | |
| 85 | + Effect.gen(function* () { | |
| 86 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 87 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 88 | + const path = yield* Path.Path; | |
| 89 | + | |
| 90 | + return DesktopClientSettings.of({ | |
| 91 | + get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( | |
| 92 | + Effect.withSpan("desktop.clientSettings.get"), | |
| 93 | + ), | |
| 94 | + set: (settings) => | |
| 95 | + writeClientSettings({ | |
| 96 | + fileSystem, | |
| 97 | + path, | |
| 98 | + settingsPath: environment.clientSettingsPath, | |
| 99 | + settings, | |
| 100 | + }).pipe( | |
| 101 | + Effect.mapError((cause) => new DesktopClientSettingsWriteError({ cause })), | |
| 102 | + Effect.withSpan("desktop.clientSettings.set"), | |
| 103 | + ), | |
| 104 | + }); | |
| 105 | + }), | |
| 106 | +); | |
| 107 | + | |
| 108 | +export const layerTest = (initialSettings: Option.Option<ClientSettings> = Option.none()) => | |
| 109 | + Layer.effect( | |
| 110 | + DesktopClientSettings, | |
| 111 | + Effect.gen(function* () { | |
| 112 | + const settingsRef = yield* Ref.make(initialSettings); | |
| 113 | + return DesktopClientSettings.of({ | |
| 114 | + get: Ref.get(settingsRef), | |
| 115 | + set: (settings) => Ref.set(settingsRef, Option.some(settings)), | |
| 116 | + }); | |
| 117 | + }), | |
| 118 | + ); | |
| @@ -0,0 +1,344 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, describe, it } from "@effect/vitest"; | |
| 3 | +import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 5 | +import * as FileSystem from "effect/FileSystem"; | |
| 6 | +import * as Layer from "effect/Layer"; | |
| 7 | +import * as Option from "effect/Option"; | |
| 8 | +import * as Schema from "effect/Schema"; | |
| 9 | + | |
| 10 | +import * as DesktopConfig from "../app/DesktopConfig.ts"; | |
| 11 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 12 | +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; | |
| 13 | +import * as DesktopSavedEnvironments from "./DesktopSavedEnvironments.ts"; | |
| 14 | + | |
| 15 | +const textDecoder = new TextDecoder(); | |
| 16 | +const textEncoder = new TextEncoder(); | |
| 17 | + | |
| 18 | +const savedRegistryRecord: PersistedSavedEnvironmentRecord = { | |
| 19 | + environmentId: EnvironmentId.make("environment-1"), | |
| 20 | + label: "Remote environment", | |
| 21 | + httpBaseUrl: "https://remote.example.com/", | |
| 22 | + wsBaseUrl: "wss://remote.example.com/", | |
| 23 | + createdAt: "2026-04-09T00:00:00.000Z", | |
| 24 | + lastConnectedAt: "2026-04-09T01:00:00.000Z", | |
| 25 | + desktopSsh: { | |
| 26 | + alias: "devbox", | |
| 27 | + hostname: "devbox.example.com", | |
| 28 | + username: "julius", | |
| 29 | + port: 22, | |
| 30 | + }, | |
| 31 | +}; | |
| 32 | + | |
| 33 | +const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({ | |
| 34 | + version: Schema.Number, | |
| 35 | + records: Schema.Array(Schema.Unknown), | |
| 36 | +}); | |
| 37 | +const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect( | |
| 38 | + Schema.fromJsonString(SavedEnvironmentRegistryDocumentProbe), | |
| 39 | +); | |
| 40 | + | |
| 41 | +function makeSafeStorageLayer(input: { | |
| 42 | + readonly available: boolean; | |
| 43 | + readonly availabilityError?: unknown; | |
| 44 | + readonly encryptError?: unknown; | |
| 45 | + readonly decryptError?: unknown; | |
| 46 | +}) { | |
| 47 | + return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { | |
| 48 | + isEncryptionAvailable: | |
| 49 | + input.availabilityError === undefined | |
| 50 | + ? Effect.succeed(input.available) | |
| 51 | + : Effect.fail( | |
| 52 | + new ElectronSafeStorage.ElectronSafeStorageAvailabilityError({ | |
| 53 | + cause: input.availabilityError, | |
| 54 | + }), | |
| 55 | + ), | |
| 56 | + encryptString: (value) => | |
| 57 | + input.encryptError === undefined | |
| 58 | + ? Effect.succeed(textEncoder.encode(`enc:${value}`)) | |
| 59 | + : Effect.fail( | |
| 60 | + new ElectronSafeStorage.ElectronSafeStorageEncryptError({ | |
| 61 | + cause: input.encryptError, | |
| 62 | + }), | |
| 63 | + ), | |
| 64 | + decryptString: (value) => { | |
| 65 | + if (input.decryptError !== undefined) { | |
| 66 | + return Effect.fail( | |
| 67 | + new ElectronSafeStorage.ElectronSafeStorageDecryptError({ | |
| 68 | + cause: input.decryptError, | |
| 69 | + }), | |
| 70 | + ); | |
| 71 | + } | |
| 72 | + | |
| 73 | + const decoded = textDecoder.decode(value); | |
| 74 | + if (!decoded.startsWith("enc:")) { | |
| 75 | + return Effect.fail( | |
| 76 | + new ElectronSafeStorage.ElectronSafeStorageDecryptError({ | |
| 77 | + cause: new Error("invalid secret"), | |
| 78 | + }), | |
| 79 | + ); | |
| 80 | + } | |
| 81 | + return Effect.succeed(decoded.slice("enc:".length)); | |
| 82 | + }, | |
| 83 | + } satisfies ElectronSafeStorage.ElectronSafeStorageShape); | |
| 84 | +} | |
| 85 | + | |
| 86 | +function makeLayer( | |
| 87 | + baseDir: string, | |
| 88 | + options?: { | |
| 89 | + readonly availableSecretStorage?: boolean; | |
| 90 | + readonly availabilityError?: unknown; | |
| 91 | + readonly encryptError?: unknown; | |
| 92 | + readonly decryptError?: unknown; | |
| 93 | + }, | |
| 94 | +) { | |
| 95 | + const environmentLayer = DesktopEnvironment.layer({ | |
| 96 | + dirname: "/repo/apps/desktop/src", | |
| 97 | + homeDirectory: baseDir, | |
| 98 | + platform: "darwin", | |
| 99 | + processArch: "x64", | |
| 100 | + appVersion: "1.2.3", | |
| 101 | + appPath: "/repo", | |
| 102 | + isPackaged: true, | |
| 103 | + resourcesPath: "/missing/resources", | |
| 104 | + runningUnderArm64Translation: false, | |
| 105 | + }).pipe( | |
| 106 | + Layer.provide( | |
| 107 | + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), | |
| 108 | + ), | |
| 109 | + ); | |
| 110 | + | |
| 111 | + return DesktopSavedEnvironments.layer.pipe( | |
| 112 | + Layer.provideMerge(environmentLayer), | |
| 113 | + Layer.provideMerge( | |
| 114 | + makeSafeStorageLayer({ | |
| 115 | + available: options?.availableSecretStorage ?? true, | |
| 116 | + availabilityError: options?.availabilityError, | |
| 117 | + encryptError: options?.encryptError, | |
| 118 | + decryptError: options?.decryptError, | |
| 119 | + }), | |
| 120 | + ), | |
| 121 | + Layer.provideMerge(NodeServices.layer), | |
| 122 | + ); | |
| 123 | +} | |
| 124 | + | |
| 125 | +const withSavedEnvironments = <A, E, R>( | |
| 126 | + effect: Effect.Effect<A, E, R | DesktopSavedEnvironments.DesktopSavedEnvironments>, | |
| 127 | + options?: { | |
| 128 | + readonly availableSecretStorage?: boolean; | |
| 129 | + readonly availabilityError?: unknown; | |
| 130 | + readonly encryptError?: unknown; | |
| 131 | + readonly decryptError?: unknown; | |
| 132 | + }, | |
| 133 | +) => | |
| 134 | + Effect.gen(function* () { | |
| 135 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 136 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 137 | + prefix: "t3-desktop-saved-environments-test-", | |
| 138 | + }); | |
| 139 | + return yield* effect.pipe(Effect.provide(makeLayer(baseDir, options))); | |
| 140 | + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); | |
| 141 | + | |
| 142 | +describe("DesktopSavedEnvironments", () => { | |
| 143 | + it.effect("persists and reloads saved environment metadata", () => | |
| 144 | + withSavedEnvironments( | |
| 145 | + Effect.gen(function* () { | |
| 146 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 147 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 148 | + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | |
| 149 | + yield* savedEnvironments.setRegistry([savedRegistryRecord]); | |
| 150 | + | |
| 151 | + assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); | |
| 152 | + const persisted = yield* decodeSavedEnvironmentRegistryDocumentProbe( | |
| 153 | + yield* fileSystem.readFileString(environment.savedEnvironmentRegistryPath), | |
| 154 | + ); | |
| 155 | + assert.equal(persisted.version, 1); | |
| 156 | + assert.lengthOf(persisted.records, 1); | |
| 157 | + }), | |
| 158 | + ), | |
| 159 | + ); | |
| 160 | + | |
| 161 | + it.effect("loads lenient saved environment registry documents", () => | |
| 162 | + withSavedEnvironments( | |
| 163 | + Effect.gen(function* () { | |
| 164 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 165 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 166 | + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | |
| 167 | + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | |
| 168 | + yield* fileSystem.writeFileString( | |
| 169 | + environment.savedEnvironmentRegistryPath, | |
| 170 | + `{ | |
| 171 | + // Same optional envelope shape as browser saved environments. | |
| 172 | + "version": 1, | |
| 173 | + "records": [ | |
| 174 | + { | |
| 175 | + "environmentId": "${savedRegistryRecord.environmentId}", | |
| 176 | + "label": "Remote environment", | |
| 177 | + "httpBaseUrl": "https://remote.example.com/", | |
| 178 | + "wsBaseUrl": "wss://remote.example.com/", | |
| 179 | + "createdAt": "2026-04-09T00:00:00.000Z", | |
| 180 | + "lastConnectedAt": "2026-04-09T01:00:00.000Z", | |
| 181 | + "desktopSsh": { | |
| 182 | + "alias": "devbox", | |
| 183 | + "hostname": "devbox.example.com", | |
| 184 | + "username": "julius", | |
| 185 | + "port": 22, | |
| 186 | + }, | |
| 187 | + }, | |
| 188 | + ], | |
| 189 | + }\n`, | |
| 190 | + ); | |
| 191 | + | |
| 192 | + assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); | |
| 193 | + }), | |
| 194 | + ), | |
| 195 | + ); | |
| 196 | + | |
| 197 | + it.effect("persists encrypted saved environment secrets when encryption is available", () => | |
| 198 | + withSavedEnvironments( | |
| 199 | + Effect.gen(function* () { | |
| 200 | + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | |
| 201 | + yield* savedEnvironments.setRegistry([savedRegistryRecord]); | |
| 202 | + | |
| 203 | + assert.isTrue( | |
| 204 | + yield* savedEnvironments.setSecret({ | |
| 205 | + environmentId: savedRegistryRecord.environmentId, | |
| 206 | + secret: "bearer-token", | |
| 207 | + }), | |
| 208 | + ); | |
| 209 | + | |
| 210 | + assert.deepEqual( | |
| 211 | + yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId), | |
| 212 | + Option.some("bearer-token"), | |
| 213 | + ); | |
| 214 | + }), | |
| 215 | + ), | |
| 216 | + ); | |
| 217 | + | |
| 218 | + it.effect("returns false when writing secrets while encryption is unavailable", () => | |
| 219 | + withSavedEnvironments( | |
| 220 | + Effect.gen(function* () { | |
| 221 | + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | |
| 222 | + yield* savedEnvironments.setRegistry([savedRegistryRecord]); | |
| 223 | + | |
| 224 | + assert.isFalse( | |
| 225 | + yield* savedEnvironments.setSecret({ | |
| 226 | + environmentId: savedRegistryRecord.environmentId, | |
| 227 | + secret: "next-token", | |
| 228 | + }), | |
| 229 | + ); | |
| 230 | + }), | |
| 231 | + { availableSecretStorage: false }, | |
| 232 | + ), | |
| 233 | + ); | |
| 234 | + | |
| 235 | + it.effect("surfaces typed safe storage availability failures", () => { | |
| 236 | + const cause = new Error("safe storage unavailable"); | |
| 237 | + return withSavedEnvironments( | |
| 238 | + Effect.gen(function* () { | |
| 239 | + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | |
| 240 | + yield* savedEnvironments.setRegistry([savedRegistryRecord]); | |
| 241 | + | |
| 242 | + const error = yield* savedEnvironments | |
| 243 | + .setSecret({ | |
| 244 | + environmentId: savedRegistryRecord.environmentId, | |
| 245 | + secret: "next-token", | |
| 246 | + }) | |
| 247 | + .pipe(Effect.flip); | |
| 248 | + | |
| 249 | + assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); | |
| 250 | + assert.equal(error.cause, cause); | |
| 251 | + }), | |
| 252 | + { availabilityError: cause }, | |
| 253 | + ); | |
| 254 | + }); | |
| 255 | + | |
| 256 | + it.effect("removes saved environment secrets", () => | |
| 257 | + withSavedEnvironments( | |
| 258 | + Effect.gen(function* () { | |
| 259 | + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | |
| 260 | + yield* savedEnvironments.setRegistry([savedRegistryRecord]); | |
| 261 | + yield* savedEnvironments.setSecret({ | |
| 262 | + environmentId: savedRegistryRecord.environmentId, | |
| 263 | + secret: "bearer-token", | |
| 264 | + }); | |
| 265 | + | |
| 266 | + yield* savedEnvironments.removeSecret(savedRegistryRecord.environmentId); | |
| 267 | + | |
| 268 | + assert.isTrue( | |
| 269 | + Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), | |
| 270 | + ); | |
| 271 | + }), | |
| 272 | + ), | |
| 273 | + ); | |
| 274 | + | |
| 275 | + it.effect("treats empty saved environment documents as empty", () => | |
| 276 | + withSavedEnvironments( | |
| 277 | + Effect.gen(function* () { | |
| 278 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 279 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 280 | + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | |
| 281 | + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | |
| 282 | + yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{}\n"); | |
| 283 | + | |
| 284 | + assert.deepEqual(yield* savedEnvironments.getRegistry, []); | |
| 285 | + assert.isTrue( | |
| 286 | + Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), | |
| 287 | + ); | |
| 288 | + }), | |
| 289 | + ), | |
| 290 | + ); | |
| 291 | + | |
| 292 | + it.effect("treats malformed saved environment documents as empty", () => | |
| 293 | + withSavedEnvironments( | |
| 294 | + Effect.gen(function* () { | |
| 295 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 296 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 297 | + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | |
| 298 | + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | |
| 299 | + yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); | |
| 300 | + | |
| 301 | + assert.deepEqual(yield* savedEnvironments.getRegistry, []); | |
| 302 | + assert.isTrue( | |
| 303 | + Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), | |
| 304 | + ); | |
| 305 | + }), | |
| 306 | + ), | |
| 307 | + ); | |
| 308 | + | |
| 309 | + it.effect("returns false when writing a secret without metadata", () => | |
| 310 | + withSavedEnvironments( | |
| 311 | + Effect.gen(function* () { | |
| 312 | + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | |
| 313 | + | |
| 314 | + assert.isFalse( | |
| 315 | + yield* savedEnvironments.setSecret({ | |
| 316 | + environmentId: savedRegistryRecord.environmentId, | |
| 317 | + secret: "bearer-token", | |
| 318 | + }), | |
| 319 | + ); | |
| 320 | + }), | |
| 321 | + ), | |
| 322 | + ); | |
| 323 | + | |
| 324 | + it.effect("preserves encrypted secrets when metadata is rewritten", () => | |
| 325 | + withSavedEnvironments( | |
| 326 | + Effect.gen(function* () { | |
| 327 | + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; | |
| 328 | + yield* savedEnvironments.setRegistry([savedRegistryRecord]); | |
| 329 | + yield* savedEnvironments.setSecret({ | |
| 330 | + environmentId: savedRegistryRecord.environmentId, | |
| 331 | + secret: "bearer-token", | |
| 332 | + }); | |
| 333 | + | |
| 334 | + yield* savedEnvironments.setRegistry([savedRegistryRecord]); | |
| 335 | + | |
| 336 | + assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); | |
| 337 | + assert.deepEqual( | |
| 338 | + yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId), | |
| 339 | + Option.some("bearer-token"), | |
| 340 | + ); | |
| 341 | + }), | |
| 342 | + ), | |
| 343 | + ); | |
| 344 | +}); | |
| @@ -0,0 +1,390 @@ | ||
| 1 | +import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; | |
| 2 | +import { fromLenientJson } from "@t3tools/shared/schemaJson"; | |
| 3 | +import * as Context from "effect/Context"; | |
| 4 | +import * as Data from "effect/Data"; | |
| 5 | +import * as Effect from "effect/Effect"; | |
| 6 | +import * as Encoding from "effect/Encoding"; | |
| 7 | +import * as FileSystem from "effect/FileSystem"; | |
| 8 | +import * as Layer from "effect/Layer"; | |
| 9 | +import * as Option from "effect/Option"; | |
| 10 | +import * as Path from "effect/Path"; | |
| 11 | +import * as PlatformError from "effect/PlatformError"; | |
| 12 | +import * as Random from "effect/Random"; | |
| 13 | +import * as Schema from "effect/Schema"; | |
| 14 | +import * as Ref from "effect/Ref"; | |
| 15 | + | |
| 16 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 17 | +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; | |
| 18 | + | |
| 19 | +type PersistedSavedEnvironmentDesktopSsh = NonNullable< | |
| 20 | + PersistedSavedEnvironmentRecord["desktopSsh"] | |
| 21 | +>; | |
| 22 | + | |
| 23 | +interface PersistedSavedEnvironmentStorageRecord extends Omit< | |
| 24 | + PersistedSavedEnvironmentRecord, | |
| 25 | + "desktopSsh" | |
| 26 | +> { | |
| 27 | + readonly desktopSsh?: PersistedSavedEnvironmentDesktopSsh; | |
| 28 | + readonly encryptedBearerToken?: string; | |
| 29 | +} | |
| 30 | + | |
| 31 | +interface SavedEnvironmentRegistryDocument { | |
| 32 | + readonly version: number; | |
| 33 | + readonly records: readonly PersistedSavedEnvironmentStorageRecord[]; | |
| 34 | +} | |
| 35 | + | |
| 36 | +interface SavedEnvironmentRegistryStorageDocument { | |
| 37 | + readonly version?: number; | |
| 38 | + readonly records?: readonly PersistedSavedEnvironmentStorageRecord[]; | |
| 39 | +} | |
| 40 | + | |
| 41 | +const DesktopSshTargetSchema = Schema.Struct({ | |
| 42 | + alias: Schema.String, | |
| 43 | + hostname: Schema.String, | |
| 44 | + username: Schema.NullOr(Schema.String), | |
| 45 | + port: Schema.NullOr(Schema.Number), | |
| 46 | +}); | |
| 47 | + | |
| 48 | +const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({ | |
| 49 | + environmentId: EnvironmentId, | |
| 50 | + label: Schema.String, | |
| 51 | + httpBaseUrl: Schema.String, | |
| 52 | + wsBaseUrl: Schema.String, | |
| 53 | + createdAt: Schema.String, | |
| 54 | + lastConnectedAt: Schema.NullOr(Schema.String), | |
| 55 | + desktopSsh: Schema.optionalKey(DesktopSshTargetSchema), | |
| 56 | + encryptedBearerToken: Schema.optionalKey(Schema.String), | |
| 57 | +}); | |
| 58 | + | |
| 59 | +const SavedEnvironmentRegistryDocumentSchema = Schema.Struct({ | |
| 60 | + version: Schema.optionalKey(Schema.Number), | |
| 61 | + records: Schema.optionalKey(Schema.Array(PersistedSavedEnvironmentStorageRecordSchema)), | |
| 62 | +}); | |
| 63 | + | |
| 64 | +const SavedEnvironmentRegistryDocumentJson = fromLenientJson( | |
| 65 | + SavedEnvironmentRegistryDocumentSchema, | |
| 66 | +); | |
| 67 | +const decodeSavedEnvironmentRegistryDocumentJson = Schema.decodeEffect( | |
| 68 | + SavedEnvironmentRegistryDocumentJson, | |
| 69 | +); | |
| 70 | +const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect( | |
| 71 | + SavedEnvironmentRegistryDocumentJson, | |
| 72 | +); | |
| 73 | + | |
| 74 | +export class DesktopSavedEnvironmentsWriteError extends Data.TaggedError( | |
| 75 | + "DesktopSavedEnvironmentsWriteError", | |
| 76 | +)<{ | |
| 77 | + readonly cause: PlatformError.PlatformError | Schema.SchemaError; | |
| 78 | +}> { | |
| 79 | + override get message() { | |
| 80 | + return `Failed to write desktop saved environments: ${this.cause.message}`; | |
| 81 | + } | |
| 82 | +} | |
| 83 | + | |
| 84 | +export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError( | |
| 85 | + "DesktopSavedEnvironmentSecretDecodeError", | |
| 86 | +)<{ | |
| 87 | + readonly cause: Encoding.EncodingError; | |
| 88 | +}> { | |
| 89 | + override get message() { | |
| 90 | + return "Failed to decode desktop saved environment secret."; | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +export type DesktopSavedEnvironmentsGetSecretError = | |
| 95 | + | DesktopSavedEnvironmentSecretDecodeError | |
| 96 | + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | |
| 97 | + | ElectronSafeStorage.ElectronSafeStorageDecryptError; | |
| 98 | + | |
| 99 | +export type DesktopSavedEnvironmentsSetSecretError = | |
| 100 | + | DesktopSavedEnvironmentsWriteError | |
| 101 | + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | |
| 102 | + | ElectronSafeStorage.ElectronSafeStorageEncryptError; | |
| 103 | + | |
| 104 | +export interface DesktopSavedEnvironmentsShape { | |
| 105 | + readonly getRegistry: Effect.Effect<readonly PersistedSavedEnvironmentRecord[]>; | |
| 106 | + readonly setRegistry: ( | |
| 107 | + records: readonly PersistedSavedEnvironmentRecord[], | |
| 108 | + ) => Effect.Effect<void, DesktopSavedEnvironmentsWriteError>; | |
| 109 | + readonly getSecret: ( | |
| 110 | + environmentId: string, | |
| 111 | + ) => Effect.Effect<Option.Option<string>, DesktopSavedEnvironmentsGetSecretError>; | |
| 112 | + readonly setSecret: (input: { | |
| 113 | + readonly environmentId: string; | |
| 114 | + readonly secret: string; | |
| 115 | + }) => Effect.Effect<boolean, DesktopSavedEnvironmentsSetSecretError>; | |
| 116 | + readonly removeSecret: ( | |
| 117 | + environmentId: string, | |
| 118 | + ) => Effect.Effect<void, DesktopSavedEnvironmentsWriteError>; | |
| 119 | +} | |
| 120 | + | |
| 121 | +export class DesktopSavedEnvironments extends Context.Service< | |
| 122 | + DesktopSavedEnvironments, | |
| 123 | + DesktopSavedEnvironmentsShape | |
| 124 | +>()("t3/desktop/SavedEnvironments") {} | |
| 125 | + | |
| 126 | +function toPersistedSavedEnvironmentRecord( | |
| 127 | + record: PersistedSavedEnvironmentStorageRecord, | |
| 128 | +): PersistedSavedEnvironmentRecord { | |
| 129 | + const nextRecord = { | |
| 130 | + environmentId: record.environmentId, | |
| 131 | + label: record.label, | |
| 132 | + httpBaseUrl: record.httpBaseUrl, | |
| 133 | + wsBaseUrl: record.wsBaseUrl, | |
| 134 | + createdAt: record.createdAt, | |
| 135 | + lastConnectedAt: record.lastConnectedAt, | |
| 136 | + }; | |
| 137 | + return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; | |
| 138 | +} | |
| 139 | + | |
| 140 | +function toSavedEnvironmentStorageRecord( | |
| 141 | + record: PersistedSavedEnvironmentRecord | PersistedSavedEnvironmentStorageRecord, | |
| 142 | + encryptedBearerToken: Option.Option<string>, | |
| 143 | +): PersistedSavedEnvironmentStorageRecord { | |
| 144 | + const nextRecord = { | |
| 145 | + environmentId: record.environmentId, | |
| 146 | + label: record.label, | |
| 147 | + httpBaseUrl: record.httpBaseUrl, | |
| 148 | + wsBaseUrl: record.wsBaseUrl, | |
| 149 | + createdAt: record.createdAt, | |
| 150 | + lastConnectedAt: record.lastConnectedAt, | |
| 151 | + }; | |
| 152 | + const desktopSsh = record.desktopSsh; | |
| 153 | + if (desktopSsh) { | |
| 154 | + return Option.match(encryptedBearerToken, { | |
| 155 | + onNone: () => ({ ...nextRecord, desktopSsh }), | |
| 156 | + onSome: (value) => ({ | |
| 157 | + ...nextRecord, | |
| 158 | + desktopSsh, | |
| 159 | + encryptedBearerToken: value, | |
| 160 | + }), | |
| 161 | + }); | |
| 162 | + } | |
| 163 | + return Option.match(encryptedBearerToken, { | |
| 164 | + onNone: () => nextRecord, | |
| 165 | + onSome: (value) => ({ ...nextRecord, encryptedBearerToken: value }), | |
| 166 | + }); | |
| 167 | +} | |
| 168 | + | |
| 169 | +function normalizeSavedEnvironmentRegistryDocument( | |
| 170 | + document: SavedEnvironmentRegistryStorageDocument, | |
| 171 | +): SavedEnvironmentRegistryDocument { | |
| 172 | + return { | |
| 173 | + version: document.version ?? 1, | |
| 174 | + records: document.records ?? [], | |
| 175 | + }; | |
| 176 | +} | |
| 177 | + | |
| 178 | +function readRegistryDocument( | |
| 179 | + fileSystem: FileSystem.FileSystem, | |
| 180 | + registryPath: string, | |
| 181 | +): Effect.Effect<SavedEnvironmentRegistryDocument> { | |
| 182 | + return fileSystem.readFileString(registryPath).pipe( | |
| 183 | + Effect.option, | |
| 184 | + Effect.flatMap( | |
| 185 | + Option.match({ | |
| 186 | + onNone: () => Effect.succeed({ version: 1, records: [] }), | |
| 187 | + onSome: (raw) => | |
| 188 | + decodeSavedEnvironmentRegistryDocumentJson(raw).pipe( | |
| 189 | + Effect.map(normalizeSavedEnvironmentRegistryDocument), | |
| 190 | + Effect.catch(() => Effect.succeed({ version: 1, records: [] })), | |
| 191 | + ), | |
| 192 | + }), | |
| 193 | + ), | |
| 194 | + ); | |
| 195 | +} | |
| 196 | + | |
| 197 | +const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistryDocument")( | |
| 198 | + function* (input: { | |
| 199 | + readonly fileSystem: FileSystem.FileSystem; | |
| 200 | + readonly path: Path.Path; | |
| 201 | + readonly registryPath: string; | |
| 202 | + readonly document: SavedEnvironmentRegistryDocument; | |
| 203 | + }): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> { | |
| 204 | + const directory = input.path.dirname(input.registryPath); | |
| 205 | + const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); | |
| 206 | + const tempPath = `${input.registryPath}.${process.pid}.${suffix}.tmp`; | |
| 207 | + const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document); | |
| 208 | + yield* input.fileSystem.makeDirectory(directory, { recursive: true }); | |
| 209 | + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); | |
| 210 | + yield* input.fileSystem.rename(tempPath, input.registryPath); | |
| 211 | + }, | |
| 212 | +); | |
| 213 | + | |
| 214 | +function preserveExistingSecrets( | |
| 215 | + currentDocument: SavedEnvironmentRegistryDocument, | |
| 216 | + records: readonly PersistedSavedEnvironmentRecord[], | |
| 217 | +): SavedEnvironmentRegistryDocument { | |
| 218 | + const encryptedBearerTokenById = new Map( | |
| 219 | + currentDocument.records.flatMap((record) => | |
| 220 | + record.encryptedBearerToken | |
| 221 | + ? [[record.environmentId, record.encryptedBearerToken] as const] | |
| 222 | + : [], | |
| 223 | + ), | |
| 224 | + ); | |
| 225 | + | |
| 226 | + return { | |
| 227 | + version: currentDocument.version, | |
| 228 | + records: records.map((record) => { | |
| 229 | + const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId); | |
| 230 | + return toSavedEnvironmentStorageRecord(record, Option.fromNullishOr(encryptedBearerToken)); | |
| 231 | + }), | |
| 232 | + }; | |
| 233 | +} | |
| 234 | + | |
| 235 | +function decodeSecretBytes( | |
| 236 | + encoded: string, | |
| 237 | +): Effect.Effect<Uint8Array, DesktopSavedEnvironmentSecretDecodeError> { | |
| 238 | + return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( | |
| 239 | + Effect.mapError((cause) => new DesktopSavedEnvironmentSecretDecodeError({ cause })), | |
| 240 | + ); | |
| 241 | +} | |
| 242 | + | |
| 243 | +export const layer = Layer.effect( | |
| 244 | + DesktopSavedEnvironments, | |
| 245 | + Effect.gen(function* () { | |
| 246 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 247 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 248 | + const path = yield* Path.Path; | |
| 249 | + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; | |
| 250 | + | |
| 251 | + const writeDocument = (document: SavedEnvironmentRegistryDocument) => | |
| 252 | + writeRegistryDocument({ | |
| 253 | + fileSystem, | |
| 254 | + path, | |
| 255 | + registryPath: environment.savedEnvironmentRegistryPath, | |
| 256 | + document, | |
| 257 | + }).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); | |
| 258 | + | |
| 259 | + return DesktopSavedEnvironments.of({ | |
| 260 | + getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe( | |
| 261 | + Effect.map((document) => | |
| 262 | + document.records.map((record) => toPersistedSavedEnvironmentRecord(record)), | |
| 263 | + ), | |
| 264 | + Effect.withSpan("desktop.savedEnvironments.getRegistry"), | |
| 265 | + ), | |
| 266 | + setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) { | |
| 267 | + const currentDocument = yield* readRegistryDocument( | |
| 268 | + fileSystem, | |
| 269 | + environment.savedEnvironmentRegistryPath, | |
| 270 | + ); | |
| 271 | + yield* writeDocument(preserveExistingSecrets(currentDocument, records)); | |
| 272 | + }), | |
| 273 | + getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { | |
| 274 | + yield* Effect.annotateCurrentSpan({ environmentId }); | |
| 275 | + const document = yield* readRegistryDocument( | |
| 276 | + fileSystem, | |
| 277 | + environment.savedEnvironmentRegistryPath, | |
| 278 | + ); | |
| 279 | + const encoded = Option.fromNullishOr( | |
| 280 | + document.records.find((record) => record.environmentId === environmentId) | |
| 281 | + ?.encryptedBearerToken, | |
| 282 | + ); | |
| 283 | + if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) { | |
| 284 | + return Option.none<string>(); | |
| 285 | + } | |
| 286 | + | |
| 287 | + const secretBytes = yield* decodeSecretBytes(encoded.value); | |
| 288 | + return Option.some(yield* safeStorage.decryptString(secretBytes)); | |
| 289 | + }), | |
| 290 | + setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) { | |
| 291 | + const { environmentId, secret } = input; | |
| 292 | + yield* Effect.annotateCurrentSpan({ environmentId }); | |
| 293 | + const document = yield* readRegistryDocument( | |
| 294 | + fileSystem, | |
| 295 | + environment.savedEnvironmentRegistryPath, | |
| 296 | + ); | |
| 297 | + | |
| 298 | + if (!(yield* safeStorage.isEncryptionAvailable)) { | |
| 299 | + return false; | |
| 300 | + } | |
| 301 | + | |
| 302 | + const encryptedBearerToken = Encoding.encodeBase64( | |
| 303 | + yield* safeStorage.encryptString(secret), | |
| 304 | + ); | |
| 305 | + let found = false; | |
| 306 | + const nextDocument: SavedEnvironmentRegistryDocument = { | |
| 307 | + version: document.version, | |
| 308 | + records: document.records.map((record) => { | |
| 309 | + if (record.environmentId !== environmentId) { | |
| 310 | + return record; | |
| 311 | + } | |
| 312 | + | |
| 313 | + found = true; | |
| 314 | + return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken)); | |
| 315 | + }), | |
| 316 | + }; | |
| 317 | + | |
| 318 | + if (found) { | |
| 319 | + yield* writeDocument(nextDocument); | |
| 320 | + } | |
| 321 | + return found; | |
| 322 | + }), | |
| 323 | + removeSecret: Effect.fn("desktop.savedEnvironments.removeSecret")(function* (environmentId) { | |
| 324 | + yield* Effect.annotateCurrentSpan({ environmentId }); | |
| 325 | + const document = yield* readRegistryDocument( | |
| 326 | + fileSystem, | |
| 327 | + environment.savedEnvironmentRegistryPath, | |
| 328 | + ); | |
| 329 | + if ( | |
| 330 | + !document.records.some( | |
| 331 | + (record) => | |
| 332 | + record.environmentId === environmentId && record.encryptedBearerToken !== undefined, | |
| 333 | + ) | |
| 334 | + ) { | |
| 335 | + return; | |
| 336 | + } | |
| 337 | + | |
| 338 | + yield* writeDocument({ | |
| 339 | + version: document.version, | |
| 340 | + records: document.records.map((record) => { | |
| 341 | + if (record.environmentId !== environmentId) { | |
| 342 | + return record; | |
| 343 | + } | |
| 344 | + return toPersistedSavedEnvironmentRecord(record); | |
| 345 | + }), | |
| 346 | + }); | |
| 347 | + }), | |
| 348 | + }); | |
| 349 | + }), | |
| 350 | +); | |
| 351 | + | |
| 352 | +export const layerTest = (input?: { | |
| 353 | + readonly records?: readonly PersistedSavedEnvironmentRecord[]; | |
| 354 | + readonly secrets?: ReadonlyMap<string, string>; | |
| 355 | +}) => | |
| 356 | + Layer.effect( | |
| 357 | + DesktopSavedEnvironments, | |
| 358 | + Effect.gen(function* () { | |
| 359 | + const recordsRef = yield* Ref.make(input?.records ?? []); | |
| 360 | + const secretsRef = yield* Ref.make(new Map(input?.secrets ?? [])); | |
| 361 | + | |
| 362 | + return DesktopSavedEnvironments.of({ | |
| 363 | + getRegistry: Ref.get(recordsRef), | |
| 364 | + setRegistry: (records) => Ref.set(recordsRef, records), | |
| 365 | + getSecret: (environmentId) => | |
| 366 | + Ref.get(secretsRef).pipe( | |
| 367 | + Effect.map((secrets) => Option.fromNullishOr(secrets.get(environmentId))), | |
| 368 | + ), | |
| 369 | + setSecret: ({ environmentId, secret }) => | |
| 370 | + Ref.get(recordsRef).pipe( | |
| 371 | + Effect.flatMap((records) => { | |
| 372 | + if (!records.some((record) => record.environmentId === environmentId)) { | |
| 373 | + return Effect.succeed(false); | |
| 374 | + } | |
| 375 | + return Ref.update(secretsRef, (secrets) => { | |
| 376 | + const nextSecrets = new Map(secrets); | |
| 377 | + nextSecrets.set(environmentId, secret); | |
| 378 | + return nextSecrets; | |
| 379 | + }).pipe(Effect.as(true)); | |
| 380 | + }), | |
| 381 | + ), | |
| 382 | + removeSecret: (environmentId) => | |
| 383 | + Ref.update(secretsRef, (secrets) => { | |
| 384 | + const nextSecrets = new Map(secrets); | |
| 385 | + nextSecrets.delete(environmentId); | |
| 386 | + return nextSecrets; | |
| 387 | + }), | |
| 388 | + }); | |
| 389 | + }), | |
| 390 | + ); | |
| @@ -0,0 +1,232 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Layer from "effect/Layer"; | |
| 4 | +import * as Sink from "effect/Sink"; | |
| 5 | +import * as Stream from "effect/Stream"; | |
| 6 | +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 7 | + | |
| 8 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 9 | +import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; | |
| 10 | + | |
| 11 | +const textEncoder = new TextEncoder(); | |
| 12 | + | |
| 13 | +function envOutput(values: Readonly<Record<string, string>>): string { | |
| 14 | + return Object.entries(values) | |
| 15 | + .flatMap(([name, value]) => [ | |
| 16 | + `__T3CODE_ENV_${name}_START__`, | |
| 17 | + value, | |
| 18 | + `__T3CODE_ENV_${name}_END__`, | |
| 19 | + ]) | |
| 20 | + .join("\n"); | |
| 21 | +} | |
| 22 | + | |
| 23 | +function makeProcess(output: string): ChildProcessSpawner.ChildProcessHandle { | |
| 24 | + const stdout = output.length === 0 ? Stream.empty : Stream.make(textEncoder.encode(output)); | |
| 25 | + return ChildProcessSpawner.makeHandle({ | |
| 26 | + pid: ChildProcessSpawner.ProcessId(123), | |
| 27 | + stdout, | |
| 28 | + stderr: Stream.empty, | |
| 29 | + all: stdout, | |
| 30 | + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), | |
| 31 | + isRunning: Effect.succeed(false), | |
| 32 | + kill: () => Effect.void, | |
| 33 | + stdin: Sink.drain, | |
| 34 | + getInputFd: () => Sink.drain, | |
| 35 | + getOutputFd: () => Stream.empty, | |
| 36 | + unref: Effect.succeed(Effect.void), | |
| 37 | + }); | |
| 38 | +} | |
| 39 | + | |
| 40 | +function withProcessEnv<A, E, R>( | |
| 41 | + env: NodeJS.ProcessEnv, | |
| 42 | + effect: Effect.Effect<A, E, R>, | |
| 43 | +): Effect.Effect<A, E, R> { | |
| 44 | + return Effect.acquireUseRelease( | |
| 45 | + Effect.sync(() => { | |
| 46 | + const previous = process.env; | |
| 47 | + process.env = env; | |
| 48 | + return previous; | |
| 49 | + }), | |
| 50 | + () => effect, | |
| 51 | + (previous) => | |
| 52 | + Effect.sync(() => { | |
| 53 | + process.env = previous; | |
| 54 | + }), | |
| 55 | + ); | |
| 56 | +} | |
| 57 | + | |
| 58 | +function runShellEnvironment(input: { | |
| 59 | + readonly env: NodeJS.ProcessEnv; | |
| 60 | + readonly platform: NodeJS.Platform; | |
| 61 | + readonly handler: (command: ChildProcess.Command) => string; | |
| 62 | +}) { | |
| 63 | + const environmentLayer = Layer.succeed( | |
| 64 | + DesktopEnvironment.DesktopEnvironment, | |
| 65 | + DesktopEnvironment.DesktopEnvironment.of({ | |
| 66 | + platform: input.platform, | |
| 67 | + } as DesktopEnvironment.DesktopEnvironmentShape), | |
| 68 | + ); | |
| 69 | + const spawnerLayer = Layer.succeed( | |
| 70 | + ChildProcessSpawner.ChildProcessSpawner, | |
| 71 | + ChildProcessSpawner.make((command) => Effect.succeed(makeProcess(input.handler(command)))), | |
| 72 | + ); | |
| 73 | + | |
| 74 | + const program = Effect.gen(function* () { | |
| 75 | + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; | |
| 76 | + yield* shellEnvironment.installIntoProcess; | |
| 77 | + }).pipe( | |
| 78 | + Effect.provide( | |
| 79 | + DesktopShellEnvironment.layer.pipe( | |
| 80 | + Layer.provide(Layer.mergeAll(environmentLayer, spawnerLayer)), | |
| 81 | + ), | |
| 82 | + ), | |
| 83 | + ); | |
| 84 | + | |
| 85 | + return withProcessEnv(input.env, program); | |
| 86 | +} | |
| 87 | + | |
| 88 | +describe("DesktopShellEnvironment", () => { | |
| 89 | + it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => | |
| 90 | + Effect.gen(function* () { | |
| 91 | + const env: NodeJS.ProcessEnv = { | |
| 92 | + SHELL: "/bin/zsh", | |
| 93 | + PATH: "/Users/test/.local/bin:/usr/bin", | |
| 94 | + }; | |
| 95 | + const commands: ChildProcess.Command[] = []; | |
| 96 | + | |
| 97 | + yield* runShellEnvironment({ | |
| 98 | + env, | |
| 99 | + platform: "darwin", | |
| 100 | + handler: (command) => { | |
| 101 | + commands.push(command); | |
| 102 | + return envOutput({ | |
| 103 | + PATH: "/opt/homebrew/bin:/usr/bin", | |
| 104 | + SSH_AUTH_SOCK: "/tmp/secretive.sock", | |
| 105 | + HOMEBREW_PREFIX: "/opt/homebrew", | |
| 106 | + }); | |
| 107 | + }, | |
| 108 | + }); | |
| 109 | + | |
| 110 | + assert.equal(commands.length, 1); | |
| 111 | + assert.equal(commands[0]?._tag === "StandardCommand" ? commands[0].command : "", "/bin/zsh"); | |
| 112 | + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); | |
| 113 | + assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); | |
| 114 | + assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew"); | |
| 115 | + }), | |
| 116 | + ); | |
| 117 | + | |
| 118 | + it.effect("preserves inherited POSIX values when present", () => | |
| 119 | + Effect.gen(function* () { | |
| 120 | + const env: NodeJS.ProcessEnv = { | |
| 121 | + SHELL: "/bin/zsh", | |
| 122 | + PATH: "/usr/bin", | |
| 123 | + SSH_AUTH_SOCK: "/tmp/inherited.sock", | |
| 124 | + }; | |
| 125 | + | |
| 126 | + yield* runShellEnvironment({ | |
| 127 | + env, | |
| 128 | + platform: "darwin", | |
| 129 | + handler: () => | |
| 130 | + envOutput({ | |
| 131 | + PATH: "/opt/homebrew/bin:/usr/bin", | |
| 132 | + SSH_AUTH_SOCK: "/tmp/login-shell.sock", | |
| 133 | + }), | |
| 134 | + }); | |
| 135 | + | |
| 136 | + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); | |
| 137 | + assert.equal(env.SSH_AUTH_SOCK, "/tmp/inherited.sock"); | |
| 138 | + }), | |
| 139 | + ); | |
| 140 | + | |
| 141 | + it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => | |
| 142 | + Effect.gen(function* () { | |
| 143 | + const env: NodeJS.ProcessEnv = { | |
| 144 | + SHELL: "/bin/zsh", | |
| 145 | + PATH: "/usr/bin", | |
| 146 | + }; | |
| 147 | + | |
| 148 | + yield* runShellEnvironment({ | |
| 149 | + env, | |
| 150 | + platform: "linux", | |
| 151 | + handler: () => | |
| 152 | + envOutput({ | |
| 153 | + PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", | |
| 154 | + SSH_AUTH_SOCK: "/tmp/secretive.sock", | |
| 155 | + }), | |
| 156 | + }); | |
| 157 | + | |
| 158 | + assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin"); | |
| 159 | + assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); | |
| 160 | + }), | |
| 161 | + ); | |
| 162 | + | |
| 163 | + it.effect("falls back to launchctl PATH on macOS when shell probing does not return one", () => | |
| 164 | + Effect.gen(function* () { | |
| 165 | + const env: NodeJS.ProcessEnv = { | |
| 166 | + SHELL: "/opt/homebrew/bin/nu", | |
| 167 | + PATH: "/usr/bin", | |
| 168 | + }; | |
| 169 | + const commands: string[] = []; | |
| 170 | + | |
| 171 | + yield* runShellEnvironment({ | |
| 172 | + env, | |
| 173 | + platform: "darwin", | |
| 174 | + handler: (command) => { | |
| 175 | + if (command._tag !== "StandardCommand") return ""; | |
| 176 | + commands.push(command.command); | |
| 177 | + return command.command === "/bin/launchctl" ? "/opt/homebrew/bin:/usr/bin" : ""; | |
| 178 | + }, | |
| 179 | + }); | |
| 180 | + | |
| 181 | + assert.deepEqual(commands, ["/opt/homebrew/bin/nu", "/bin/zsh", "/bin/launchctl"]); | |
| 182 | + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); | |
| 183 | + }), | |
| 184 | + ); | |
| 185 | + | |
| 186 | + it.effect("loads PowerShell profile environment on Windows", () => | |
| 187 | + Effect.gen(function* () { | |
| 188 | + const env: NodeJS.ProcessEnv = { | |
| 189 | + PATH: "C:\\Windows\\System32", | |
| 190 | + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | |
| 191 | + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", | |
| 192 | + USERPROFILE: "C:\\Users\\testuser", | |
| 193 | + }; | |
| 194 | + | |
| 195 | + yield* runShellEnvironment({ | |
| 196 | + env, | |
| 197 | + platform: "win32", | |
| 198 | + handler: (command) => { | |
| 199 | + if (command._tag !== "StandardCommand") return ""; | |
| 200 | + const loadProfile = !command.args.includes("-NoProfile"); | |
| 201 | + return loadProfile | |
| 202 | + ? envOutput({ | |
| 203 | + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", | |
| 204 | + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", | |
| 205 | + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", | |
| 206 | + }) | |
| 207 | + : envOutput({ PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }); | |
| 208 | + }, | |
| 209 | + }); | |
| 210 | + | |
| 211 | + assert.equal( | |
| 212 | + env.PATH, | |
| 213 | + [ | |
| 214 | + "C:\\Profile\\Node", | |
| 215 | + "C:\\Windows\\System32", | |
| 216 | + "C:\\Users\\testuser\\AppData\\Roaming\\npm", | |
| 217 | + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", | |
| 218 | + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", | |
| 219 | + "C:\\Users\\testuser\\AppData\\Local\\pnpm", | |
| 220 | + "C:\\Users\\testuser\\.bun\\bin", | |
| 221 | + "C:\\Users\\testuser\\scoop\\shims", | |
| 222 | + "C:\\Custom\\Bin", | |
| 223 | + ].join(";"), | |
| 224 | + ); | |
| 225 | + assert.equal(env.FNM_DIR, "C:\\Users\\testuser\\AppData\\Roaming\\fnm"); | |
| 226 | + assert.equal( | |
| 227 | + env.FNM_MULTISHELL_PATH, | |
| 228 | + "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", | |
| 229 | + ); | |
| 230 | + }), | |
| 231 | + ); | |
| 232 | +}); | |
| @@ -0,0 +1,356 @@ | ||
| 1 | +import * as Context from "effect/Context"; | |
| 2 | +import * as Duration from "effect/Duration"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Layer from "effect/Layer"; | |
| 5 | +import * as Option from "effect/Option"; | |
| 6 | +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 7 | + | |
| 8 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 9 | + | |
| 10 | +type EnvironmentPatch = Record<string, string>; | |
| 11 | + | |
| 12 | +interface ShellEnvironmentConfig { | |
| 13 | + readonly env: NodeJS.ProcessEnv; | |
| 14 | + readonly platform: NodeJS.Platform; | |
| 15 | + readonly userShell: Option.Option<string>; | |
| 16 | +} | |
| 17 | + | |
| 18 | +interface WindowsProbeOptions { | |
| 19 | + readonly loadProfile: boolean; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export interface DesktopShellEnvironmentShape { | |
| 23 | + readonly installIntoProcess: Effect.Effect<void, never>; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export class DesktopShellEnvironment extends Context.Service< | |
| 27 | + DesktopShellEnvironment, | |
| 28 | + DesktopShellEnvironmentShape | |
| 29 | +>()("t3/desktop/ShellEnvironment") {} | |
| 30 | + | |
| 31 | +const LOGIN_SHELL_ENV_NAMES = [ | |
| 32 | + "PATH", | |
| 33 | + "SSH_AUTH_SOCK", | |
| 34 | + "HOMEBREW_PREFIX", | |
| 35 | + "HOMEBREW_CELLAR", | |
| 36 | + "HOMEBREW_REPOSITORY", | |
| 37 | + "XDG_CONFIG_HOME", | |
| 38 | + "XDG_DATA_HOME", | |
| 39 | +] as const; | |
| 40 | +const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; | |
| 41 | +const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; | |
| 42 | +const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); | |
| 43 | +const LAUNCHCTL_TIMEOUT = Duration.seconds(2); | |
| 44 | +const PROCESS_TERMINATE_GRACE = Duration.seconds(1); | |
| 45 | + | |
| 46 | +const trimNonEmpty = (value: string | null | undefined): Option.Option<string> => | |
| 47 | + Option.fromNullishOr(value).pipe( | |
| 48 | + Option.map((entry) => entry.trim()), | |
| 49 | + Option.filter((entry) => entry.length > 0), | |
| 50 | + ); | |
| 51 | + | |
| 52 | +const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" : ":"); | |
| 53 | + | |
| 54 | +const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option<string> => | |
| 55 | + trimNonEmpty(env.PATH ?? env.Path ?? env.path); | |
| 56 | + | |
| 57 | +const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { | |
| 58 | + const normalized = entry.trim().replace(/^"+|"+$/g, ""); | |
| 59 | + return platform === "win32" ? normalized.toLowerCase() : normalized; | |
| 60 | +}; | |
| 61 | + | |
| 62 | +const mergePaths = ( | |
| 63 | + platform: NodeJS.Platform, | |
| 64 | + values: ReadonlyArray<Option.Option<string>>, | |
| 65 | +): Option.Option<string> => { | |
| 66 | + const delimiter = pathDelimiter(platform); | |
| 67 | + const entries: string[] = []; | |
| 68 | + const seen = new Set<string>(); | |
| 69 | + | |
| 70 | + for (const value of values) { | |
| 71 | + if (Option.isNone(value)) continue; | |
| 72 | + | |
| 73 | + for (const entry of value.value.split(delimiter)) { | |
| 74 | + const trimmed = entry.trim(); | |
| 75 | + if (trimmed.length === 0) continue; | |
| 76 | + | |
| 77 | + const key = pathComparisonKey(trimmed, platform); | |
| 78 | + if (key.length === 0 || seen.has(key)) continue; | |
| 79 | + | |
| 80 | + seen.add(key); | |
| 81 | + entries.push(trimmed); | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none(); | |
| 86 | +}; | |
| 87 | + | |
| 88 | +const listLoginShellCandidates = (config: ShellEnvironmentConfig): ReadonlyArray<string> => { | |
| 89 | + const fallback = | |
| 90 | + config.platform === "darwin" ? "/bin/zsh" : config.platform === "linux" ? "/bin/bash" : ""; | |
| 91 | + const seen = new Set<string>(); | |
| 92 | + const candidates: string[] = []; | |
| 93 | + | |
| 94 | + for (const candidate of [ | |
| 95 | + trimNonEmpty(config.env.SHELL), | |
| 96 | + config.userShell, | |
| 97 | + trimNonEmpty(fallback), | |
| 98 | + ]) { | |
| 99 | + if (Option.isNone(candidate) || seen.has(candidate.value)) continue; | |
| 100 | + seen.add(candidate.value); | |
| 101 | + candidates.push(candidate.value); | |
| 102 | + } | |
| 103 | + | |
| 104 | + return candidates; | |
| 105 | +}; | |
| 106 | + | |
| 107 | +const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray<string> => [ | |
| 108 | + ...trimNonEmpty(env.APPDATA).pipe( | |
| 109 | + Option.match({ | |
| 110 | + onNone: () => [], | |
| 111 | + onSome: (value) => [`${value}\\npm`], | |
| 112 | + }), | |
| 113 | + ), | |
| 114 | + ...trimNonEmpty(env.LOCALAPPDATA).pipe( | |
| 115 | + Option.match({ | |
| 116 | + onNone: () => [], | |
| 117 | + onSome: (value) => [`${value}\\Programs\\nodejs`, `${value}\\Volta\\bin`, `${value}\\pnpm`], | |
| 118 | + }), | |
| 119 | + ), | |
| 120 | + ...trimNonEmpty(env.USERPROFILE).pipe( | |
| 121 | + Option.match({ | |
| 122 | + onNone: () => [], | |
| 123 | + onSome: (value) => [`${value}\\.bun\\bin`, `${value}\\scoop\\shims`], | |
| 124 | + }), | |
| 125 | + ), | |
| 126 | +]; | |
| 127 | + | |
| 128 | +const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`; | |
| 129 | +const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`; | |
| 130 | + | |
| 131 | +const capturePosixEnvironmentCommand = (names: ReadonlyArray<string>) => | |
| 132 | + names | |
| 133 | + .map((name) => { | |
| 134 | + return [ | |
| 135 | + `printf '%s\\n' '${startMarker(name)}'`, | |
| 136 | + `printenv ${name} || true`, | |
| 137 | + `printf '%s\\n' '${endMarker(name)}'`, | |
| 138 | + ].join("; "); | |
| 139 | + }) | |
| 140 | + .join("; "); | |
| 141 | + | |
| 142 | +const captureWindowsEnvironmentCommand = (names: ReadonlyArray<string>) => | |
| 143 | + [ | |
| 144 | + "$ErrorActionPreference = 'Stop'", | |
| 145 | + ...names.flatMap((name) => { | |
| 146 | + return [ | |
| 147 | + `Write-Output '${startMarker(name)}'`, | |
| 148 | + `$value = [Environment]::GetEnvironmentVariable('${name}')`, | |
| 149 | + "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", | |
| 150 | + `Write-Output '${endMarker(name)}'`, | |
| 151 | + ]; | |
| 152 | + }), | |
| 153 | + ].join("; "); | |
| 154 | + | |
| 155 | +const extractEnvironment = (output: string, names: ReadonlyArray<string>): EnvironmentPatch => { | |
| 156 | + const environment: EnvironmentPatch = {}; | |
| 157 | + | |
| 158 | + for (const name of names) { | |
| 159 | + const start = output.indexOf(startMarker(name)); | |
| 160 | + if (start === -1) continue; | |
| 161 | + | |
| 162 | + const valueStart = start + startMarker(name).length; | |
| 163 | + const end = output.indexOf(endMarker(name), valueStart); | |
| 164 | + if (end === -1) continue; | |
| 165 | + | |
| 166 | + const value = output | |
| 167 | + .slice(valueStart, end) | |
| 168 | + .replace(/^\r?\n/, "") | |
| 169 | + .replace(/\r?\n$/, ""); | |
| 170 | + if (value.length > 0) { | |
| 171 | + environment[name] = value; | |
| 172 | + } | |
| 173 | + } | |
| 174 | + | |
| 175 | + return environment; | |
| 176 | +}; | |
| 177 | + | |
| 178 | +const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: { | |
| 179 | + readonly command: string; | |
| 180 | + readonly args: ReadonlyArray<string>; | |
| 181 | + readonly timeout: Duration.Duration; | |
| 182 | + readonly shell?: boolean; | |
| 183 | +}): Effect.fn.Return<string, never, ChildProcessSpawner.ChildProcessSpawner> { | |
| 184 | + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 185 | + return yield* spawner | |
| 186 | + .string( | |
| 187 | + ChildProcess.make(input.command, input.args, { | |
| 188 | + shell: input.shell ?? false, | |
| 189 | + stdin: "ignore", | |
| 190 | + stdout: "pipe", | |
| 191 | + stderr: "pipe", | |
| 192 | + killSignal: "SIGTERM", | |
| 193 | + forceKillAfter: PROCESS_TERMINATE_GRACE, | |
| 194 | + }), | |
| 195 | + ) | |
| 196 | + .pipe( | |
| 197 | + Effect.timeoutOption(input.timeout), | |
| 198 | + Effect.map(Option.getOrElse(() => "")), | |
| 199 | + Effect.catch(() => Effect.succeed("")), | |
| 200 | + ); | |
| 201 | +}); | |
| 202 | + | |
| 203 | +const readLoginShellEnvironment = ( | |
| 204 | + shell: string, | |
| 205 | + names: ReadonlyArray<string>, | |
| 206 | +): Effect.Effect<EnvironmentPatch, never, ChildProcessSpawner.ChildProcessSpawner> => | |
| 207 | + names.length === 0 | |
| 208 | + ? Effect.succeed({}) | |
| 209 | + : runCommandOutput({ | |
| 210 | + command: shell, | |
| 211 | + args: ["-ilc", capturePosixEnvironmentCommand(names)], | |
| 212 | + timeout: LOGIN_SHELL_TIMEOUT, | |
| 213 | + }).pipe(Effect.map((output) => extractEnvironment(output, names))); | |
| 214 | + | |
| 215 | +const readLaunchctlPath: Effect.Effect< | |
| 216 | + Option.Option<string>, | |
| 217 | + never, | |
| 218 | + ChildProcessSpawner.ChildProcessSpawner | |
| 219 | +> = runCommandOutput({ | |
| 220 | + command: "/bin/launchctl", | |
| 221 | + args: ["getenv", "PATH"], | |
| 222 | + timeout: LAUNCHCTL_TIMEOUT, | |
| 223 | +}).pipe(Effect.map(trimNonEmpty)); | |
| 224 | + | |
| 225 | +const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEnvironment")( | |
| 226 | + function* ( | |
| 227 | + names: ReadonlyArray<string>, | |
| 228 | + options: WindowsProbeOptions, | |
| 229 | + ): Effect.fn.Return<EnvironmentPatch, never, ChildProcessSpawner.ChildProcessSpawner> { | |
| 230 | + if (names.length === 0) return {}; | |
| 231 | + | |
| 232 | + const args = [ | |
| 233 | + "-NoLogo", | |
| 234 | + ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), | |
| 235 | + "-NonInteractive", | |
| 236 | + "-Command", | |
| 237 | + captureWindowsEnvironmentCommand(names), | |
| 238 | + ]; | |
| 239 | + | |
| 240 | + for (const command of WINDOWS_SHELL_CANDIDATES) { | |
| 241 | + const output = yield* runCommandOutput({ | |
| 242 | + command, | |
| 243 | + args, | |
| 244 | + shell: true, | |
| 245 | + timeout: LOGIN_SHELL_TIMEOUT, | |
| 246 | + }); | |
| 247 | + const environment = extractEnvironment(output, names); | |
| 248 | + if (Object.keys(environment).length > 0) { | |
| 249 | + return environment; | |
| 250 | + } | |
| 251 | + } | |
| 252 | + | |
| 253 | + return {}; | |
| 254 | + }, | |
| 255 | +); | |
| 256 | + | |
| 257 | +const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWindowsEnvironment")( | |
| 258 | + function* ( | |
| 259 | + config: ShellEnvironmentConfig, | |
| 260 | + ): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> { | |
| 261 | + const noProfile = yield* readWindowsEnvironment(["PATH"], { loadProfile: false }); | |
| 262 | + const profile = yield* readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { | |
| 263 | + loadProfile: true, | |
| 264 | + }); | |
| 265 | + const mergedPath = mergePaths("win32", [ | |
| 266 | + trimNonEmpty(profile.PATH), | |
| 267 | + trimNonEmpty(knownWindowsCliDirs(config.env).join(";")), | |
| 268 | + trimNonEmpty(noProfile.PATH), | |
| 269 | + readEnvPath(config.env), | |
| 270 | + ]); | |
| 271 | + | |
| 272 | + if (Option.isSome(mergedPath)) { | |
| 273 | + config.env.PATH = mergedPath.value; | |
| 274 | + } | |
| 275 | + if (!config.env.FNM_DIR && profile.FNM_DIR) { | |
| 276 | + config.env.FNM_DIR = profile.FNM_DIR; | |
| 277 | + } | |
| 278 | + if (!config.env.FNM_MULTISHELL_PATH && profile.FNM_MULTISHELL_PATH) { | |
| 279 | + config.env.FNM_MULTISHELL_PATH = profile.FNM_MULTISHELL_PATH; | |
| 280 | + } | |
| 281 | + }, | |
| 282 | +); | |
| 283 | + | |
| 284 | +const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")( | |
| 285 | + function* ( | |
| 286 | + config: ShellEnvironmentConfig, | |
| 287 | + ): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> { | |
| 288 | + const shellEnvironment: EnvironmentPatch = {}; | |
| 289 | + | |
| 290 | + for (const shell of listLoginShellCandidates(config)) { | |
| 291 | + Object.assign( | |
| 292 | + shellEnvironment, | |
| 293 | + yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES), | |
| 294 | + ); | |
| 295 | + if (shellEnvironment.PATH) break; | |
| 296 | + } | |
| 297 | + | |
| 298 | + const launchctlPath = | |
| 299 | + config.platform === "darwin" && !shellEnvironment.PATH | |
| 300 | + ? yield* readLaunchctlPath | |
| 301 | + : Option.none<string>(); | |
| 302 | + const mergedPath = mergePaths(config.platform, [ | |
| 303 | + trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), | |
| 304 | + readEnvPath(config.env), | |
| 305 | + ]); | |
| 306 | + | |
| 307 | + if (Option.isSome(mergedPath)) { | |
| 308 | + config.env.PATH = mergedPath.value; | |
| 309 | + } | |
| 310 | + if (!config.env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { | |
| 311 | + config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; | |
| 312 | + } | |
| 313 | + | |
| 314 | + for (const name of [ | |
| 315 | + "HOMEBREW_PREFIX", | |
| 316 | + "HOMEBREW_CELLAR", | |
| 317 | + "HOMEBREW_REPOSITORY", | |
| 318 | + "XDG_CONFIG_HOME", | |
| 319 | + "XDG_DATA_HOME", | |
| 320 | + ] as const) { | |
| 321 | + if (!config.env[name] && shellEnvironment[name]) { | |
| 322 | + config.env[name] = shellEnvironment[name]; | |
| 323 | + } | |
| 324 | + } | |
| 325 | + }, | |
| 326 | +); | |
| 327 | + | |
| 328 | +const installShellEnvironment = ( | |
| 329 | + config: ShellEnvironmentConfig, | |
| 330 | +): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => { | |
| 331 | + if (config.platform === "win32") { | |
| 332 | + return installWindowsEnvironment(config); | |
| 333 | + } | |
| 334 | + if (config.platform === "darwin" || config.platform === "linux") { | |
| 335 | + return installPosixEnvironment(config); | |
| 336 | + } | |
| 337 | + return Effect.void; | |
| 338 | +}; | |
| 339 | + | |
| 340 | +export const layer = Layer.effect( | |
| 341 | + DesktopShellEnvironment, | |
| 342 | + Effect.gen(function* () { | |
| 343 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 344 | + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 345 | + return DesktopShellEnvironment.of({ | |
| 346 | + installIntoProcess: installShellEnvironment({ | |
| 347 | + env: process.env, | |
| 348 | + platform: environment.platform, | |
| 349 | + userShell: Option.none(), | |
| 350 | + }).pipe( | |
| 351 | + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 352 | + Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), | |
| 353 | + ), | |
| 354 | + }); | |
| 355 | + }), | |
| 356 | +); | |
| @@ -0,0 +1,118 @@ | ||
| 1 | +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; | |
| 2 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | +import { assert, describe, it } from "@effect/vitest"; | |
| 4 | +import * as NetService from "@t3tools/shared/Net"; | |
| 5 | +import { SshPasswordPromptError } from "@t3tools/ssh/errors"; | |
| 6 | +import * as Effect from "effect/Effect"; | |
| 7 | +import * as FileSystem from "effect/FileSystem"; | |
| 8 | +import * as Layer from "effect/Layer"; | |
| 9 | +import * as Path from "effect/Path"; | |
| 10 | + | |
| 11 | +import * as DesktopSshEnvironment from "./DesktopSshEnvironment.ts"; | |
| 12 | +import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; | |
| 13 | + | |
| 14 | +function makeTempHomeDir() { | |
| 15 | + return Effect.gen(function* () { | |
| 16 | + const fs = yield* FileSystem.FileSystem; | |
| 17 | + return yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-env-test-" }); | |
| 18 | + }); | |
| 19 | +} | |
| 20 | + | |
| 21 | +describe("sshEnvironment", () => { | |
| 22 | + it("treats password prompt timeouts as cancellable authentication prompts", () => { | |
| 23 | + assert.equal( | |
| 24 | + DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation( | |
| 25 | + new SshPasswordPromptError({ | |
| 26 | + message: "SSH authentication timed out for devbox.", | |
| 27 | + cause: new DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError({ | |
| 28 | + requestId: "prompt-1", | |
| 29 | + destination: "devbox", | |
| 30 | + }), | |
| 31 | + }), | |
| 32 | + ), | |
| 33 | + true, | |
| 34 | + ); | |
| 35 | + }); | |
| 36 | + | |
| 37 | + it.effect("wires desktop host discovery through the ssh package runtime", () => | |
| 38 | + Effect.gen(function* () { | |
| 39 | + const fs = yield* FileSystem.FileSystem; | |
| 40 | + const path = yield* Path.Path; | |
| 41 | + const homeDir = yield* makeTempHomeDir(); | |
| 42 | + const sshDir = path.join(homeDir, ".ssh"); | |
| 43 | + yield* fs.makeDirectory(path.join(sshDir, "config.d"), { recursive: true }); | |
| 44 | + yield* fs.writeFileString( | |
| 45 | + path.join(sshDir, "config"), | |
| 46 | + ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"), | |
| 47 | + ); | |
| 48 | + yield* fs.writeFileString( | |
| 49 | + path.join(sshDir, "config.d", "team.conf"), | |
| 50 | + [ | |
| 51 | + "Host staging", | |
| 52 | + " HostName staging.example.com", | |
| 53 | + "Host *", | |
| 54 | + " ServerAliveInterval 30", | |
| 55 | + "", | |
| 56 | + ].join("\n"), | |
| 57 | + ); | |
| 58 | + yield* fs.writeFileString( | |
| 59 | + path.join(sshDir, "known_hosts"), | |
| 60 | + [ | |
| 61 | + "known.example.com ssh-ed25519 AAAA", | |
| 62 | + "|1|hashed|entry ssh-ed25519 AAAA", | |
| 63 | + "[bastion.example.com]:2222 ssh-ed25519 AAAA", | |
| 64 | + "", | |
| 65 | + ].join("\n"), | |
| 66 | + ); | |
| 67 | + | |
| 68 | + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; | |
| 69 | + const hosts = yield* sshEnvironment.discoverHosts({ homeDir }); | |
| 70 | + assert.deepEqual(hosts, [ | |
| 71 | + { | |
| 72 | + alias: "bastion.example.com", | |
| 73 | + hostname: "bastion.example.com", | |
| 74 | + username: null, | |
| 75 | + port: null, | |
| 76 | + source: "known-hosts", | |
| 77 | + }, | |
| 78 | + { | |
| 79 | + alias: "devbox", | |
| 80 | + hostname: "devbox", | |
| 81 | + username: null, | |
| 82 | + port: null, | |
| 83 | + source: "ssh-config", | |
| 84 | + }, | |
| 85 | + { | |
| 86 | + alias: "known.example.com", | |
| 87 | + hostname: "known.example.com", | |
| 88 | + username: null, | |
| 89 | + port: null, | |
| 90 | + source: "known-hosts", | |
| 91 | + }, | |
| 92 | + { | |
| 93 | + alias: "staging", | |
| 94 | + hostname: "staging", | |
| 95 | + username: null, | |
| 96 | + port: null, | |
| 97 | + source: "ssh-config", | |
| 98 | + }, | |
| 99 | + ]); | |
| 100 | + }).pipe( | |
| 101 | + Effect.provide( | |
| 102 | + DesktopSshEnvironment.layer().pipe( | |
| 103 | + Layer.provideMerge( | |
| 104 | + Layer.succeed(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, { | |
| 105 | + request: () => Effect.die("unexpected password prompt request"), | |
| 106 | + resolve: () => Effect.die("unexpected password prompt resolution"), | |
| 107 | + cancelPending: () => Effect.void, | |
| 108 | + }), | |
| 109 | + ), | |
| 110 | + Layer.provideMerge(NodeServices.layer), | |
| 111 | + Layer.provideMerge(NodeHttpClient.layerUndici), | |
| 112 | + Layer.provideMerge(NetService.layer), | |
| 113 | + ), | |
| 114 | + ), | |
| 115 | + Effect.scoped, | |
| 116 | + ), | |
| 117 | + ); | |
| 118 | +}); | |
| @@ -0,0 +1,150 @@ | ||
| 1 | +import type { | |
| 2 | + DesktopDiscoveredSshHost, | |
| 3 | + DesktopSshEnvironmentBootstrap, | |
| 4 | + DesktopSshEnvironmentTarget, | |
| 5 | +} from "@t3tools/contracts"; | |
| 6 | +import { type NetError, NetService } from "@t3tools/shared/Net"; | |
| 7 | +import { | |
| 8 | + SshPasswordPrompt, | |
| 9 | + type SshPasswordPromptShape, | |
| 10 | + type SshPasswordRequest, | |
| 11 | +} from "@t3tools/ssh/auth"; | |
| 12 | +import { discoverSshHosts } from "@t3tools/ssh/config"; | |
| 13 | +import { | |
| 14 | + SshCommandError, | |
| 15 | + SshHostDiscoveryError, | |
| 16 | + SshInvalidTargetError, | |
| 17 | + SshLaunchError, | |
| 18 | + SshPairingError, | |
| 19 | + SshPasswordPromptError, | |
| 20 | + SshReadinessError, | |
| 21 | +} from "@t3tools/ssh/errors"; | |
| 22 | +import { SshEnvironmentManager, type RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; | |
| 23 | +import * as Context from "effect/Context"; | |
| 24 | +import * as Effect from "effect/Effect"; | |
| 25 | +import * as FileSystem from "effect/FileSystem"; | |
| 26 | +import * as Layer from "effect/Layer"; | |
| 27 | +import * as Path from "effect/Path"; | |
| 28 | +import { HttpClient } from "effect/unstable/http"; | |
| 29 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 30 | + | |
| 31 | +import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; | |
| 32 | + | |
| 33 | +export type DesktopSshEnvironmentRuntimeServices = | |
| 34 | + | ChildProcessSpawner.ChildProcessSpawner | |
| 35 | + | FileSystem.FileSystem | |
| 36 | + | Path.Path | |
| 37 | + | HttpClient.HttpClient | |
| 38 | + | NetService; | |
| 39 | + | |
| 40 | +export type DesktopSshEnvironmentOperationError = | |
| 41 | + | SshCommandError | |
| 42 | + | SshInvalidTargetError | |
| 43 | + | SshLaunchError | |
| 44 | + | SshPairingError | |
| 45 | + | SshReadinessError | |
| 46 | + | SshPasswordPromptError | |
| 47 | + | NetError; | |
| 48 | + | |
| 49 | +export type DesktopSshEnvironmentDiscoverError = SshHostDiscoveryError; | |
| 50 | + | |
| 51 | +export type DesktopSshEnvironmentError = | |
| 52 | + | DesktopSshEnvironmentDiscoverError | |
| 53 | + | DesktopSshEnvironmentOperationError; | |
| 54 | + | |
| 55 | +export interface DesktopSshEnvironmentShape { | |
| 56 | + readonly discoverHosts: (input?: { | |
| 57 | + readonly homeDir?: string; | |
| 58 | + }) => Effect.Effect<readonly DesktopDiscoveredSshHost[], DesktopSshEnvironmentDiscoverError>; | |
| 59 | + readonly ensureEnvironment: ( | |
| 60 | + target: DesktopSshEnvironmentTarget, | |
| 61 | + options?: { readonly issuePairingToken?: boolean }, | |
| 62 | + ) => Effect.Effect<DesktopSshEnvironmentBootstrap, DesktopSshEnvironmentOperationError>; | |
| 63 | + readonly disconnectEnvironment: ( | |
| 64 | + target: DesktopSshEnvironmentTarget, | |
| 65 | + ) => Effect.Effect<void, DesktopSshEnvironmentOperationError>; | |
| 66 | +} | |
| 67 | + | |
| 68 | +export class DesktopSshEnvironment extends Context.Service< | |
| 69 | + DesktopSshEnvironment, | |
| 70 | + DesktopSshEnvironmentShape | |
| 71 | +>()("t3/desktop/SshEnvironment") {} | |
| 72 | + | |
| 73 | +export interface DesktopSshEnvironmentLayerOptions { | |
| 74 | + readonly resolveCliPackageSpec?: () => string; | |
| 75 | + readonly resolveCliRunner?: Effect.Effect<RemoteT3RunnerOptions>; | |
| 76 | +} | |
| 77 | + | |
| 78 | +function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { | |
| 79 | + return discoverSshHosts(input ?? {}); | |
| 80 | +} | |
| 81 | + | |
| 82 | +export function isDesktopSshPasswordPromptCancellation( | |
| 83 | + error: unknown, | |
| 84 | +): error is SshPasswordPromptError { | |
| 85 | + return ( | |
| 86 | + error instanceof SshPasswordPromptError && | |
| 87 | + DesktopSshPasswordPrompts.isDesktopSshPasswordPromptCancellation(error.cause) | |
| 88 | + ); | |
| 89 | +} | |
| 90 | + | |
| 91 | +const makePasswordPrompt = ( | |
| 92 | + prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPromptsShape, | |
| 93 | +): SshPasswordPromptShape => ({ | |
| 94 | + isAvailable: true, | |
| 95 | + request: (request: SshPasswordRequest) => | |
| 96 | + prompts.request(request).pipe( | |
| 97 | + Effect.mapError( | |
| 98 | + (cause) => | |
| 99 | + new SshPasswordPromptError({ | |
| 100 | + message: cause.message, | |
| 101 | + cause, | |
| 102 | + }), | |
| 103 | + ), | |
| 104 | + ), | |
| 105 | +}); | |
| 106 | + | |
| 107 | +const make = Effect.gen(function* () { | |
| 108 | + const manager = yield* SshEnvironmentManager; | |
| 109 | + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; | |
| 110 | + const runtimeContext = yield* Effect.context<DesktopSshEnvironmentRuntimeServices>(); | |
| 111 | + const passwordPrompt = SshPasswordPrompt.of(makePasswordPrompt(prompts)); | |
| 112 | + | |
| 113 | + return DesktopSshEnvironment.of({ | |
| 114 | + discoverHosts: (input) => | |
| 115 | + discoverDesktopSshHostsEffect(input).pipe( | |
| 116 | + Effect.provide(runtimeContext), | |
| 117 | + Effect.withSpan("desktop.ssh.discoverHosts"), | |
| 118 | + ), | |
| 119 | + ensureEnvironment: (target, ensureOptions) => | |
| 120 | + manager | |
| 121 | + .ensureEnvironment(target, ensureOptions) | |
| 122 | + .pipe( | |
| 123 | + Effect.provideService(SshPasswordPrompt, passwordPrompt), | |
| 124 | + Effect.provide(runtimeContext), | |
| 125 | + Effect.withSpan("desktop.ssh.ensureEnvironment"), | |
| 126 | + ), | |
| 127 | + disconnectEnvironment: (target) => | |
| 128 | + manager | |
| 129 | + .disconnectEnvironment(target) | |
| 130 | + .pipe( | |
| 131 | + Effect.provideService(SshPasswordPrompt, passwordPrompt), | |
| 132 | + Effect.provide(runtimeContext), | |
| 133 | + Effect.withSpan("desktop.ssh.disconnectEnvironment"), | |
| 134 | + ), | |
| 135 | + }); | |
| 136 | +}); | |
| 137 | + | |
| 138 | +export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => | |
| 139 | + Layer.effect(DesktopSshEnvironment, make).pipe( | |
| 140 | + Layer.provide( | |
| 141 | + SshEnvironmentManager.layer({ | |
| 142 | + ...(options.resolveCliPackageSpec === undefined | |
| 143 | + ? {} | |
| 144 | + : { resolveCliPackageSpec: options.resolveCliPackageSpec }), | |
| 145 | + ...(options.resolveCliRunner === undefined | |
| 146 | + ? {} | |
| 147 | + : { resolveCliRunner: options.resolveCliRunner }), | |
| 148 | + }), | |
| 149 | + ), | |
| 150 | + ); | |
| @@ -0,0 +1,144 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import * as Duration from "effect/Duration"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Fiber from "effect/Fiber"; | |
| 5 | +import * as Layer from "effect/Layer"; | |
| 6 | +import * as Option from "effect/Option"; | |
| 7 | +import { TestClock } from "effect/testing"; | |
| 8 | +import type * as Electron from "electron"; | |
| 9 | + | |
| 10 | +import * as ElectronWindow from "../electron/ElectronWindow.ts"; | |
| 11 | +import * as IpcChannels from "../ipc/channels.ts"; | |
| 12 | +import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; | |
| 13 | + | |
| 14 | +interface SentMessage { | |
| 15 | + readonly channel: string; | |
| 16 | + readonly args: readonly unknown[]; | |
| 17 | +} | |
| 18 | + | |
| 19 | +function makeTestWindow() { | |
| 20 | + const listeners = new Map<string, Set<() => void>>(); | |
| 21 | + const sentMessages: SentMessage[] = []; | |
| 22 | + let destroyed = false; | |
| 23 | + let minimized = true; | |
| 24 | + let restored = false; | |
| 25 | + let focused = false; | |
| 26 | + | |
| 27 | + const window = { | |
| 28 | + isDestroyed: () => destroyed, | |
| 29 | + isMinimized: () => minimized, | |
| 30 | + restore: () => { | |
| 31 | + restored = true; | |
| 32 | + minimized = false; | |
| 33 | + }, | |
| 34 | + focus: () => { | |
| 35 | + focused = true; | |
| 36 | + }, | |
| 37 | + once: (eventName: string, listener: () => void) => { | |
| 38 | + const eventListeners = listeners.get(eventName) ?? new Set<() => void>(); | |
| 39 | + eventListeners.add(listener); | |
| 40 | + listeners.set(eventName, eventListeners); | |
| 41 | + }, | |
| 42 | + removeListener: (eventName: string, listener: () => void) => { | |
| 43 | + listeners.get(eventName)?.delete(listener); | |
| 44 | + }, | |
| 45 | + webContents: { | |
| 46 | + send: (channel: string, ...args: readonly unknown[]) => { | |
| 47 | + sentMessages.push({ channel, args }); | |
| 48 | + }, | |
| 49 | + }, | |
| 50 | + }; | |
| 51 | + | |
| 52 | + return { | |
| 53 | + window, | |
| 54 | + sentMessages, | |
| 55 | + isRestored: () => restored, | |
| 56 | + isFocused: () => focused, | |
| 57 | + close: () => { | |
| 58 | + destroyed = true; | |
| 59 | + const closedListeners = [...(listeners.get("closed") ?? [])]; | |
| 60 | + listeners.delete("closed"); | |
| 61 | + for (const listener of closedListeners) { | |
| 62 | + listener(); | |
| 63 | + } | |
| 64 | + }, | |
| 65 | + }; | |
| 66 | +} | |
| 67 | + | |
| 68 | +function makeElectronWindowLayer(window: ReturnType<typeof makeTestWindow>["window"]) { | |
| 69 | + return Layer.succeed( | |
| 70 | + ElectronWindow.ElectronWindow, | |
| 71 | + ElectronWindow.ElectronWindow.of({ | |
| 72 | + create: () => Effect.die("unexpected BrowserWindow creation"), | |
| 73 | + main: Effect.succeed(Option.some(window as Electron.BrowserWindow)), | |
| 74 | + currentMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), | |
| 75 | + focusedMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), | |
| 76 | + setMain: () => Effect.void, | |
| 77 | + clearMain: () => Effect.void, | |
| 78 | + reveal: () => Effect.void, | |
| 79 | + sendAll: () => Effect.void, | |
| 80 | + destroyAll: Effect.void, | |
| 81 | + syncAllAppearance: () => Effect.void, | |
| 82 | + }), | |
| 83 | + ); | |
| 84 | +} | |
| 85 | + | |
| 86 | +function makeLayer(window: ReturnType<typeof makeTestWindow>["window"]) { | |
| 87 | + return DesktopSshPasswordPrompts.layer({ passwordPromptTimeoutMs: 1_000 }).pipe( | |
| 88 | + Layer.provide(makeElectronWindowLayer(window)), | |
| 89 | + Layer.provideMerge(TestClock.layer()), | |
| 90 | + ); | |
| 91 | +} | |
| 92 | + | |
| 93 | +describe("DesktopSshPasswordPrompts", () => { | |
| 94 | + it.effect("sends renderer prompts and resolves them by request id", () => { | |
| 95 | + const testWindow = makeTestWindow(); | |
| 96 | + | |
| 97 | + return Effect.gen(function* () { | |
| 98 | + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; | |
| 99 | + const fiber = yield* prompts | |
| 100 | + .request({ | |
| 101 | + destination: "devbox", | |
| 102 | + username: "julius", | |
| 103 | + prompt: "Enter the SSH password.", | |
| 104 | + attempt: 1, | |
| 105 | + }) | |
| 106 | + .pipe(Effect.forkScoped); | |
| 107 | + | |
| 108 | + yield* Effect.yieldNow; | |
| 109 | + assert.equal(testWindow.sentMessages.length, 1); | |
| 110 | + const sent = testWindow.sentMessages[0]; | |
| 111 | + assert.ok(sent); | |
| 112 | + assert.equal(sent.channel, IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL); | |
| 113 | + const request = sent.args[0] as { readonly requestId: string; readonly destination: string }; | |
| 114 | + assert.equal(request.destination, "devbox"); | |
| 115 | + assert.equal(testWindow.isRestored(), true); | |
| 116 | + assert.equal(testWindow.isFocused(), true); | |
| 117 | + | |
| 118 | + yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); | |
| 119 | + assert.equal(yield* Fiber.join(fiber), "secret"); | |
| 120 | + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); | |
| 121 | + }); | |
| 122 | + | |
| 123 | + it.effect("times out pending renderer prompts with a typed error", () => { | |
| 124 | + const testWindow = makeTestWindow(); | |
| 125 | + | |
| 126 | + return Effect.gen(function* () { | |
| 127 | + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; | |
| 128 | + const fiber = yield* prompts | |
| 129 | + .request({ | |
| 130 | + destination: "devbox", | |
| 131 | + username: null, | |
| 132 | + prompt: "Enter the SSH password.", | |
| 133 | + attempt: 1, | |
| 134 | + }) | |
| 135 | + .pipe(Effect.forkScoped); | |
| 136 | + | |
| 137 | + yield* Effect.yieldNow; | |
| 138 | + yield* TestClock.adjust(Duration.millis(1_000)); | |
| 139 | + const error = yield* Fiber.join(fiber).pipe(Effect.flip); | |
| 140 | + assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError); | |
| 141 | + assert.equal(error.destination, "devbox"); | |
| 142 | + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); | |
| 143 | + }); | |
| 144 | +}); | |
| @@ -0,0 +1,332 @@ | ||
| 1 | +import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts"; | |
| 2 | +import { DesktopSshPasswordPromptResolutionInputSchema } from "@t3tools/contracts"; | |
| 3 | +import type { SshPasswordRequest } from "@t3tools/ssh/auth"; | |
| 4 | +import * as Context from "effect/Context"; | |
| 5 | +import * as Data from "effect/Data"; | |
| 6 | +import * as DateTime from "effect/DateTime"; | |
| 7 | +import * as Deferred from "effect/Deferred"; | |
| 8 | +import * as Duration from "effect/Duration"; | |
| 9 | +import * as Effect from "effect/Effect"; | |
| 10 | +import * as Layer from "effect/Layer"; | |
| 11 | +import * as Option from "effect/Option"; | |
| 12 | +import * as Random from "effect/Random"; | |
| 13 | +import * as Ref from "effect/Ref"; | |
| 14 | + | |
| 15 | +import * as IpcChannels from "../ipc/channels.ts"; | |
| 16 | +import * as ElectronWindow from "../electron/ElectronWindow.ts"; | |
| 17 | + | |
| 18 | +const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; | |
| 19 | +const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication."; | |
| 20 | + | |
| 21 | +type DesktopSshPasswordPromptResolutionInput = | |
| 22 | + typeof DesktopSshPasswordPromptResolutionInputSchema.Type; | |
| 23 | + | |
| 24 | +export class DesktopSshPromptUnavailableError extends Data.TaggedError( | |
| 25 | + "DesktopSshPromptUnavailableError", | |
| 26 | +)<{ | |
| 27 | + readonly reason: string; | |
| 28 | +}> { | |
| 29 | + override get message() { | |
| 30 | + return this.reason; | |
| 31 | + } | |
| 32 | +} | |
| 33 | + | |
| 34 | +export class DesktopSshPromptWindowUnavailableError extends Data.TaggedError( | |
| 35 | + "DesktopSshPromptWindowUnavailableError", | |
| 36 | +)<{ | |
| 37 | + readonly destination: string; | |
| 38 | +}> { | |
| 39 | + override get message() { | |
| 40 | + return WINDOW_UNAVAILABLE_MESSAGE; | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +export class DesktopSshPromptSendError extends Data.TaggedError("DesktopSshPromptSendError")<{ | |
| 45 | + readonly requestId: string; | |
| 46 | + readonly destination: string; | |
| 47 | + readonly cause: unknown; | |
| 48 | +}> { | |
| 49 | + override get message() { | |
| 50 | + return WINDOW_UNAVAILABLE_MESSAGE; | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +export class DesktopSshPromptTimedOutError extends Data.TaggedError( | |
| 55 | + "DesktopSshPromptTimedOutError", | |
| 56 | +)<{ | |
| 57 | + readonly requestId: string; | |
| 58 | + readonly destination: string; | |
| 59 | +}> { | |
| 60 | + override get message() { | |
| 61 | + return `SSH authentication timed out for ${this.destination}.`; | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +export class DesktopSshPromptCancelledError extends Data.TaggedError( | |
| 66 | + "DesktopSshPromptCancelledError", | |
| 67 | +)<{ | |
| 68 | + readonly requestId: string; | |
| 69 | + readonly destination: string; | |
| 70 | + readonly reason: string; | |
| 71 | +}> { | |
| 72 | + override get message() { | |
| 73 | + return this.reason; | |
| 74 | + } | |
| 75 | +} | |
| 76 | + | |
| 77 | +export class DesktopSshPromptInvalidRequestIdError extends Data.TaggedError( | |
| 78 | + "DesktopSshPromptInvalidRequestIdError", | |
| 79 | +)<{ | |
| 80 | + readonly requestId: string; | |
| 81 | +}> { | |
| 82 | + override get message() { | |
| 83 | + return "Invalid SSH password prompt id."; | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +export class DesktopSshPromptExpiredError extends Data.TaggedError("DesktopSshPromptExpiredError")<{ | |
| 88 | + readonly requestId: string; | |
| 89 | +}> { | |
| 90 | + override get message() { | |
| 91 | + return "SSH password prompt expired. Try connecting again."; | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +export type DesktopSshPasswordPromptRequestError = | |
| 96 | + | DesktopSshPromptUnavailableError | |
| 97 | + | DesktopSshPromptWindowUnavailableError | |
| 98 | + | DesktopSshPromptSendError | |
| 99 | + | DesktopSshPromptTimedOutError | |
| 100 | + | DesktopSshPromptCancelledError; | |
| 101 | + | |
| 102 | +export type DesktopSshPasswordPromptResolveError = | |
| 103 | + | DesktopSshPromptInvalidRequestIdError | |
| 104 | + | DesktopSshPromptExpiredError; | |
| 105 | + | |
| 106 | +export type DesktopSshPasswordPromptError = | |
| 107 | + | DesktopSshPasswordPromptRequestError | |
| 108 | + | DesktopSshPasswordPromptResolveError; | |
| 109 | + | |
| 110 | +export function isDesktopSshPasswordPromptCancellation( | |
| 111 | + error: unknown, | |
| 112 | +): error is DesktopSshPromptCancelledError | DesktopSshPromptTimedOutError { | |
| 113 | + return ( | |
| 114 | + error instanceof DesktopSshPromptCancelledError || | |
| 115 | + error instanceof DesktopSshPromptTimedOutError | |
| 116 | + ); | |
| 117 | +} | |
| 118 | + | |
| 119 | +export interface DesktopSshPasswordPromptsShape { | |
| 120 | + readonly request: ( | |
| 121 | + request: SshPasswordRequest, | |
| 122 | + ) => Effect.Effect<string, DesktopSshPasswordPromptRequestError>; | |
| 123 | + readonly resolve: ( | |
| 124 | + input: DesktopSshPasswordPromptResolutionInput, | |
| 125 | + ) => Effect.Effect<void, DesktopSshPasswordPromptResolveError>; | |
| 126 | + readonly cancelPending: (reason: string) => Effect.Effect<void>; | |
| 127 | +} | |
| 128 | + | |
| 129 | +export class DesktopSshPasswordPrompts extends Context.Service< | |
| 130 | + DesktopSshPasswordPrompts, | |
| 131 | + DesktopSshPasswordPromptsShape | |
| 132 | +>()("t3/desktop/SshPasswordPrompts") {} | |
| 133 | + | |
| 134 | +interface PendingSshPasswordPrompt { | |
| 135 | + readonly requestId: string; | |
| 136 | + readonly destination: string; | |
| 137 | + readonly deferred: Deferred.Deferred<string, DesktopSshPasswordPromptRequestError>; | |
| 138 | +} | |
| 139 | + | |
| 140 | +interface LayerOptions { | |
| 141 | + readonly passwordPromptTimeoutMs?: number; | |
| 142 | +} | |
| 143 | + | |
| 144 | +const removePending = ( | |
| 145 | + pendingRef: Ref.Ref<Map<string, PendingSshPasswordPrompt>>, | |
| 146 | + requestId: string, | |
| 147 | +) => | |
| 148 | + Ref.modify(pendingRef, (pending) => { | |
| 149 | + const entry = pending.get(requestId); | |
| 150 | + if (entry === undefined) { | |
| 151 | + return [Option.none<PendingSshPasswordPrompt>(), pending] as const; | |
| 152 | + } | |
| 153 | + | |
| 154 | + const nextPending = new Map(pending); | |
| 155 | + nextPending.delete(requestId); | |
| 156 | + return [Option.some(entry), nextPending] as const; | |
| 157 | + }); | |
| 158 | + | |
| 159 | +const failPending = ( | |
| 160 | + pending: PendingSshPasswordPrompt, | |
| 161 | + error: DesktopSshPasswordPromptRequestError, | |
| 162 | +) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid); | |
| 163 | + | |
| 164 | +const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: LayerOptions = {}) { | |
| 165 | + const electronWindow = yield* ElectronWindow.ElectronWindow; | |
| 166 | + const pendingRef = yield* Ref.make(new Map<string, PendingSshPasswordPrompt>()); | |
| 167 | + const passwordPromptTimeoutMs = | |
| 168 | + options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; | |
| 169 | + | |
| 170 | + const cancelPending = (reason: string): Effect.Effect<void> => | |
| 171 | + Ref.getAndSet(pendingRef, new Map()).pipe( | |
| 172 | + Effect.flatMap((pending) => | |
| 173 | + Effect.forEach( | |
| 174 | + pending.values(), | |
| 175 | + (entry) => | |
| 176 | + failPending( | |
| 177 | + entry, | |
| 178 | + new DesktopSshPromptCancelledError({ | |
| 179 | + requestId: entry.requestId, | |
| 180 | + destination: entry.destination, | |
| 181 | + reason, | |
| 182 | + }), | |
| 183 | + ), | |
| 184 | + { discard: true }, | |
| 185 | + ), | |
| 186 | + ), | |
| 187 | + Effect.asVoid, | |
| 188 | + ); | |
| 189 | + | |
| 190 | + yield* Effect.addFinalizer(() => | |
| 191 | + cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore), | |
| 192 | + ); | |
| 193 | + | |
| 194 | + const resolve = Effect.fn("desktop.sshPasswordPrompts.resolve")(function* ( | |
| 195 | + input: DesktopSshPasswordPromptResolutionInput, | |
| 196 | + ): Effect.fn.Return<void, DesktopSshPasswordPromptResolveError> { | |
| 197 | + const requestId = input.requestId.trim(); | |
| 198 | + if (requestId.length === 0) { | |
| 199 | + return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId }); | |
| 200 | + } | |
| 201 | + | |
| 202 | + const pending = yield* removePending(pendingRef, requestId); | |
| 203 | + if (Option.isNone(pending)) { | |
| 204 | + return yield* new DesktopSshPromptExpiredError({ requestId }); | |
| 205 | + } | |
| 206 | + | |
| 207 | + const entry = pending.value; | |
| 208 | + if (input.password === null) { | |
| 209 | + yield* failPending( | |
| 210 | + entry, | |
| 211 | + new DesktopSshPromptCancelledError({ | |
| 212 | + requestId, | |
| 213 | + destination: entry.destination, | |
| 214 | + reason: `SSH authentication cancelled for ${entry.destination}.`, | |
| 215 | + }), | |
| 216 | + ); | |
| 217 | + return; | |
| 218 | + } | |
| 219 | + | |
| 220 | + yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid); | |
| 221 | + }); | |
| 222 | + | |
| 223 | + const request = Effect.fn("desktop.sshPasswordPrompts.request")(function* ( | |
| 224 | + input: SshPasswordRequest, | |
| 225 | + ): Effect.fn.Return<string, DesktopSshPasswordPromptRequestError> { | |
| 226 | + const window = yield* electronWindow.main; | |
| 227 | + if (Option.isNone(window) || window.value.isDestroyed()) { | |
| 228 | + return yield* new DesktopSshPromptWindowUnavailableError({ | |
| 229 | + destination: input.destination, | |
| 230 | + }); | |
| 231 | + } | |
| 232 | + | |
| 233 | + const requestId = yield* Random.nextUUIDv4; | |
| 234 | + const now = yield* DateTime.now; | |
| 235 | + const expiresAt = DateTime.formatIso( | |
| 236 | + DateTime.add(now, { milliseconds: passwordPromptTimeoutMs }), | |
| 237 | + ); | |
| 238 | + const promptRequest: DesktopSshPasswordPromptRequest = { | |
| 239 | + requestId, | |
| 240 | + destination: input.destination, | |
| 241 | + username: input.username, | |
| 242 | + prompt: input.prompt, | |
| 243 | + expiresAt, | |
| 244 | + }; | |
| 245 | + const deferred = yield* Deferred.make<string, DesktopSshPasswordPromptRequestError>(); | |
| 246 | + const pending: PendingSshPasswordPrompt = { | |
| 247 | + requestId, | |
| 248 | + destination: input.destination, | |
| 249 | + deferred, | |
| 250 | + }; | |
| 251 | + yield* Ref.update(pendingRef, (entries) => new Map(entries).set(requestId, pending)); | |
| 252 | + | |
| 253 | + const context = yield* Effect.context(); | |
| 254 | + const runFork = Effect.runForkWith(context); | |
| 255 | + | |
| 256 | + const cancelOnWindowClosed = () => { | |
| 257 | + runFork( | |
| 258 | + removePending(pendingRef, requestId).pipe( | |
| 259 | + Effect.flatMap((entry) => | |
| 260 | + Option.match(entry, { | |
| 261 | + onNone: () => Effect.void, | |
| 262 | + onSome: (pending) => | |
| 263 | + failPending( | |
| 264 | + pending, | |
| 265 | + new DesktopSshPromptCancelledError({ | |
| 266 | + requestId, | |
| 267 | + destination: input.destination, | |
| 268 | + reason: "SSH authentication was cancelled because the app window closed.", | |
| 269 | + }), | |
| 270 | + ), | |
| 271 | + }), | |
| 272 | + ), | |
| 273 | + ), | |
| 274 | + ); | |
| 275 | + }; | |
| 276 | + const cleanup = Effect.sync(() => { | |
| 277 | + if (!window.value.isDestroyed()) { | |
| 278 | + window.value.removeListener("closed", cancelOnWindowClosed); | |
| 279 | + } | |
| 280 | + }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid); | |
| 281 | + const waitForPassword = Deferred.await(deferred).pipe( | |
| 282 | + Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)), | |
| 283 | + Effect.flatMap( | |
| 284 | + Option.match({ | |
| 285 | + onNone: () => | |
| 286 | + Effect.fail( | |
| 287 | + new DesktopSshPromptTimedOutError({ | |
| 288 | + requestId, | |
| 289 | + destination: input.destination, | |
| 290 | + }), | |
| 291 | + ), | |
| 292 | + onSome: Effect.succeed, | |
| 293 | + }), | |
| 294 | + ), | |
| 295 | + ); | |
| 296 | + | |
| 297 | + return yield* Effect.try({ | |
| 298 | + try: () => { | |
| 299 | + if (window.value.isDestroyed()) { | |
| 300 | + throw new Error(WINDOW_UNAVAILABLE_MESSAGE); | |
| 301 | + } | |
| 302 | + window.value.once("closed", cancelOnWindowClosed); | |
| 303 | + window.value.webContents.send(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); | |
| 304 | + if (window.value.isDestroyed()) { | |
| 305 | + throw new Error(WINDOW_UNAVAILABLE_MESSAGE); | |
| 306 | + } | |
| 307 | + if (window.value.isMinimized()) { | |
| 308 | + window.value.restore(); | |
| 309 | + } | |
| 310 | + if (window.value.isDestroyed()) { | |
| 311 | + throw new Error(WINDOW_UNAVAILABLE_MESSAGE); | |
| 312 | + } | |
| 313 | + window.value.focus(); | |
| 314 | + }, | |
| 315 | + catch: (cause) => | |
| 316 | + new DesktopSshPromptSendError({ | |
| 317 | + requestId, | |
| 318 | + destination: input.destination, | |
| 319 | + cause, | |
| 320 | + }), | |
| 321 | + }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup)); | |
| 322 | + }); | |
| 323 | + | |
| 324 | + return DesktopSshPasswordPrompts.of({ | |
| 325 | + request, | |
| 326 | + resolve, | |
| 327 | + cancelPending, | |
| 328 | + }); | |
| 329 | +}); | |
| 330 | + | |
| 331 | +export const layer = (options: LayerOptions = {}) => | |
| 332 | + Layer.effect(DesktopSshPasswordPrompts, make(options)); | |
| @@ -0,0 +1,79 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import { SshHttpBridgeError } from "@t3tools/ssh/errors"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Layer from "effect/Layer"; | |
| 5 | +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | |
| 6 | + | |
| 7 | +import * as DesktopSshRemoteApi from "./DesktopSshRemoteApi.ts"; | |
| 8 | + | |
| 9 | +function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { | |
| 10 | + return HttpClientResponse.fromWeb( | |
| 11 | + request, | |
| 12 | + new Response(JSON.stringify(body), { | |
| 13 | + status, | |
| 14 | + headers: { "content-type": "application/json" }, | |
| 15 | + }), | |
| 16 | + ); | |
| 17 | +} | |
| 18 | + | |
| 19 | +function makeLayer( | |
| 20 | + handler: ( | |
| 21 | + request: HttpClientRequest.HttpClientRequest, | |
| 22 | + ) => Effect.Effect<HttpClientResponse.HttpClientResponse>, | |
| 23 | +) { | |
| 24 | + return DesktopSshRemoteApi.layer.pipe( | |
| 25 | + Layer.provide( | |
| 26 | + Layer.succeed( | |
| 27 | + HttpClient.HttpClient, | |
| 28 | + HttpClient.make((request) => handler(request)), | |
| 29 | + ), | |
| 30 | + ), | |
| 31 | + ); | |
| 32 | +} | |
| 33 | + | |
| 34 | +describe("DesktopSshRemoteApi", () => { | |
| 35 | + it.effect("fetches and decodes the remote environment descriptor", () => { | |
| 36 | + const requestUrls: string[] = []; | |
| 37 | + const layer = makeLayer((request) => | |
| 38 | + Effect.sync(() => { | |
| 39 | + requestUrls.push(request.url); | |
| 40 | + return jsonResponse(request, { | |
| 41 | + environmentId: "remote-env", | |
| 42 | + label: "Remote Devbox", | |
| 43 | + platform: { os: "linux", arch: "x64" }, | |
| 44 | + serverVersion: "1.2.3", | |
| 45 | + capabilities: { repositoryIdentity: true }, | |
| 46 | + }); | |
| 47 | + }), | |
| 48 | + ); | |
| 49 | + | |
| 50 | + return Effect.gen(function* () { | |
| 51 | + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | |
| 52 | + const descriptor = yield* remoteApi.fetchEnvironmentDescriptor({ | |
| 53 | + httpBaseUrl: "http://127.0.0.1:41773/", | |
| 54 | + }); | |
| 55 | + | |
| 56 | + assert.equal(descriptor.label, "Remote Devbox"); | |
| 57 | + assert.deepEqual(requestUrls, ["http://127.0.0.1:41773/.well-known/t3/environment"]); | |
| 58 | + }).pipe(Effect.provide(layer)); | |
| 59 | + }); | |
| 60 | + | |
| 61 | + it.effect("wraps schema decode failures in a typed remote api error", () => { | |
| 62 | + const layer = makeLayer((request) => | |
| 63 | + Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })), | |
| 64 | + ); | |
| 65 | + | |
| 66 | + return Effect.gen(function* () { | |
| 67 | + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; | |
| 68 | + const error = yield* remoteApi | |
| 69 | + .fetchEnvironmentDescriptor({ | |
| 70 | + httpBaseUrl: "http://127.0.0.1:41773/", | |
| 71 | + }) | |
| 72 | + .pipe(Effect.flip); | |
| 73 | + | |
| 74 | + assert.instanceOf(error, DesktopSshRemoteApi.DesktopSshRemoteApiError); | |
| 75 | + assert.equal(error.operation, "fetch-environment-descriptor"); | |
| 76 | + assert.equal(error.cause instanceof SshHttpBridgeError, false); | |
| 77 | + }).pipe(Effect.provide(layer)); | |
| 78 | + }); | |
| 79 | +}); | |
| @@ -0,0 +1,124 @@ | ||
| 1 | +import { | |
| 2 | + AuthBearerBootstrapResult, | |
| 3 | + AuthSessionState, | |
| 4 | + AuthWebSocketTokenResult, | |
| 5 | + type AuthBearerBootstrapResult as AuthBearerBootstrapResultType, | |
| 6 | + type AuthSessionState as AuthSessionStateType, | |
| 7 | + type AuthWebSocketTokenResult as AuthWebSocketTokenResultType, | |
| 8 | + ExecutionEnvironmentDescriptor, | |
| 9 | + type ExecutionEnvironmentDescriptor as ExecutionEnvironmentDescriptorType, | |
| 10 | +} from "@t3tools/contracts"; | |
| 11 | +import { SshHttpBridgeError } from "@t3tools/ssh/errors"; | |
| 12 | +import { fetchLoopbackSshJson } from "@t3tools/ssh/tunnel"; | |
| 13 | +import * as Context from "effect/Context"; | |
| 14 | +import * as Data from "effect/Data"; | |
| 15 | +import * as Effect from "effect/Effect"; | |
| 16 | +import * as Layer from "effect/Layer"; | |
| 17 | +import * as Schema from "effect/Schema"; | |
| 18 | +import { HttpClient } from "effect/unstable/http"; | |
| 19 | + | |
| 20 | +export type DesktopSshRemoteApiOperation = | |
| 21 | + | "fetch-environment-descriptor" | |
| 22 | + | "bootstrap-bearer-session" | |
| 23 | + | "fetch-session-state" | |
| 24 | + | "issue-websocket-token"; | |
| 25 | + | |
| 26 | +export class DesktopSshRemoteApiError extends Data.TaggedError("DesktopSshRemoteApiError")<{ | |
| 27 | + readonly operation: DesktopSshRemoteApiOperation; | |
| 28 | + readonly cause: SshHttpBridgeError | Schema.SchemaError; | |
| 29 | +}> { | |
| 30 | + override get message() { | |
| 31 | + return `SSH remote API request failed during ${this.operation}.`; | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +export interface DesktopSshRemoteApiShape { | |
| 36 | + readonly fetchEnvironmentDescriptor: (input: { | |
| 37 | + readonly httpBaseUrl: string; | |
| 38 | + }) => Effect.Effect<ExecutionEnvironmentDescriptorType, DesktopSshRemoteApiError>; | |
| 39 | + readonly bootstrapBearerSession: (input: { | |
| 40 | + readonly httpBaseUrl: string; | |
| 41 | + readonly credential: string; | |
| 42 | + }) => Effect.Effect<AuthBearerBootstrapResultType, DesktopSshRemoteApiError>; | |
| 43 | + readonly fetchSessionState: (input: { | |
| 44 | + readonly httpBaseUrl: string; | |
| 45 | + readonly bearerToken: string; | |
| 46 | + }) => Effect.Effect<AuthSessionStateType, DesktopSshRemoteApiError>; | |
| 47 | + readonly issueWebSocketToken: (input: { | |
| 48 | + readonly httpBaseUrl: string; | |
| 49 | + readonly bearerToken: string; | |
| 50 | + }) => Effect.Effect<AuthWebSocketTokenResultType, DesktopSshRemoteApiError>; | |
| 51 | +} | |
| 52 | + | |
| 53 | +export class DesktopSshRemoteApi extends Context.Service< | |
| 54 | + DesktopSshRemoteApi, | |
| 55 | + DesktopSshRemoteApiShape | |
| 56 | +>()("t3/desktop/SshRemoteApi") {} | |
| 57 | + | |
| 58 | +const decodeExecutionEnvironmentDescriptor = Schema.decodeUnknownEffect( | |
| 59 | + ExecutionEnvironmentDescriptor, | |
| 60 | +); | |
| 61 | +const decodeAuthBearerBootstrapResult = Schema.decodeUnknownEffect(AuthBearerBootstrapResult); | |
| 62 | +const decodeAuthSessionState = Schema.decodeUnknownEffect(AuthSessionState); | |
| 63 | +const decodeAuthWebSocketTokenResult = Schema.decodeUnknownEffect(AuthWebSocketTokenResult); | |
| 64 | + | |
| 65 | +const mapError = | |
| 66 | + (operation: DesktopSshRemoteApiOperation) => | |
| 67 | + (cause: SshHttpBridgeError | Schema.SchemaError): DesktopSshRemoteApiError => | |
| 68 | + new DesktopSshRemoteApiError({ operation, cause }); | |
| 69 | + | |
| 70 | +const make = Effect.gen(function* () { | |
| 71 | + const httpClient = yield* HttpClient.HttpClient; | |
| 72 | + const provideHttpClient = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) => | |
| 73 | + effect.pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); | |
| 74 | + | |
| 75 | + return DesktopSshRemoteApi.of({ | |
| 76 | + fetchEnvironmentDescriptor: ({ httpBaseUrl }) => | |
| 77 | + fetchLoopbackSshJson<unknown>({ | |
| 78 | + httpBaseUrl, | |
| 79 | + pathname: "/.well-known/t3/environment", | |
| 80 | + }).pipe( | |
| 81 | + Effect.flatMap(decodeExecutionEnvironmentDescriptor), | |
| 82 | + Effect.mapError(mapError("fetch-environment-descriptor")), | |
| 83 | + provideHttpClient, | |
| 84 | + Effect.withSpan("desktop.sshRemoteApi.fetchEnvironmentDescriptor"), | |
| 85 | + ), | |
| 86 | + bootstrapBearerSession: ({ httpBaseUrl, credential }) => | |
| 87 | + fetchLoopbackSshJson<unknown>({ | |
| 88 | + httpBaseUrl, | |
| 89 | + pathname: "/api/auth/bootstrap/bearer", | |
| 90 | + method: "POST", | |
| 91 | + body: { credential }, | |
| 92 | + }).pipe( | |
| 93 | + Effect.flatMap(decodeAuthBearerBootstrapResult), | |
| 94 | + Effect.mapError(mapError("bootstrap-bearer-session")), | |
| 95 | + provideHttpClient, | |
| 96 | + Effect.withSpan("desktop.sshRemoteApi.bootstrapBearerSession"), | |
| 97 | + ), | |
| 98 | + fetchSessionState: ({ httpBaseUrl, bearerToken }) => | |
| 99 | + fetchLoopbackSshJson<unknown>({ | |
| 100 | + httpBaseUrl, | |
| 101 | + pathname: "/api/auth/session", | |
| 102 | + bearerToken, | |
| 103 | + }).pipe( | |
| 104 | + Effect.flatMap(decodeAuthSessionState), | |
| 105 | + Effect.mapError(mapError("fetch-session-state")), | |
| 106 | + provideHttpClient, | |
| 107 | + Effect.withSpan("desktop.sshRemoteApi.fetchSessionState"), | |
| 108 | + ), | |
| 109 | + issueWebSocketToken: ({ httpBaseUrl, bearerToken }) => | |
| 110 | + fetchLoopbackSshJson<unknown>({ | |
| 111 | + httpBaseUrl, | |
| 112 | + pathname: "/api/auth/ws-token", | |
| 113 | + method: "POST", | |
| 114 | + bearerToken, | |
| 115 | + }).pipe( | |
| 116 | + Effect.flatMap(decodeAuthWebSocketTokenResult), | |
| 117 | + Effect.mapError(mapError("issue-websocket-token")), | |
| 118 | + provideHttpClient, | |
| 119 | + Effect.withSpan("desktop.sshRemoteApi.issueWebSocketToken"), | |
| 120 | + ), | |
| 121 | + }); | |
| 122 | +}); | |
| 123 | + | |
| 124 | +export const layer = Layer.effect(DesktopSshRemoteApi, make); | |
| @@ -1,98 +0,0 @@ | ||
| 1 | −import * as FS from "node:fs"; | |
| 2 | −import * as OS from "node:os"; | |
| 3 | −import * as Path from "node:path"; | |
| 4 | − | |
| 5 | −import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | − | |
| 7 | −import { SshPasswordPromptError } from "@t3tools/ssh/errors"; | |
| 8 | − | |
| 9 | −import { discoverDesktopSshHosts, isSshPasswordPromptCancellation } from "./sshEnvironment.ts"; | |
| 10 | − | |
| 11 | −const tempDirectories: string[] = []; | |
| 12 | − | |
| 13 | −afterEach(() => { | |
| 14 | − for (const directory of tempDirectories.splice(0)) { | |
| 15 | − FS.rmSync(directory, { recursive: true, force: true }); | |
| 16 | − } | |
| 17 | −}); | |
| 18 | − | |
| 19 | −function makeTempHomeDir(): string { | |
| 20 | − const directory = FS.mkdtempSync(Path.join(OS.tmpdir(), "t3-ssh-env-test-")); | |
| 21 | − tempDirectories.push(directory); | |
| 22 | − return directory; | |
| 23 | −} | |
| 24 | − | |
| 25 | −describe("sshEnvironment", () => { | |
| 26 | − it("treats password prompt timeouts as cancellable authentication prompts", () => { | |
| 27 | − expect( | |
| 28 | − isSshPasswordPromptCancellation( | |
| 29 | − new SshPasswordPromptError({ | |
| 30 | − message: "SSH authentication timed out for devbox.", | |
| 31 | − }), | |
| 32 | − ), | |
| 33 | − ).toBe(true); | |
| 34 | − }); | |
| 35 | − | |
| 36 | − it("wires desktop host discovery through the ssh package runtime", async () => { | |
| 37 | − const homeDir = makeTempHomeDir(); | |
| 38 | − const sshDir = Path.join(homeDir, ".ssh"); | |
| 39 | − FS.mkdirSync(Path.join(sshDir, "config.d"), { recursive: true }); | |
| 40 | − FS.writeFileSync( | |
| 41 | − Path.join(sshDir, "config"), | |
| 42 | − ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"), | |
| 43 | − "utf8", | |
| 44 | − ); | |
| 45 | − FS.writeFileSync( | |
| 46 | − Path.join(sshDir, "config.d", "team.conf"), | |
| 47 | − [ | |
| 48 | − "Host staging", | |
| 49 | − " HostName staging.example.com", | |
| 50 | − "Host *", | |
| 51 | − " ServerAliveInterval 30", | |
| 52 | − "", | |
| 53 | − ].join("\n"), | |
| 54 | − "utf8", | |
| 55 | − ); | |
| 56 | − FS.writeFileSync( | |
| 57 | − Path.join(sshDir, "known_hosts"), | |
| 58 | − [ | |
| 59 | − "known.example.com ssh-ed25519 AAAA", | |
| 60 | − "|1|hashed|entry ssh-ed25519 AAAA", | |
| 61 | − "[bastion.example.com]:2222 ssh-ed25519 AAAA", | |
| 62 | − "", | |
| 63 | − ].join("\n"), | |
| 64 | − "utf8", | |
| 65 | − ); | |
| 66 | − | |
| 67 | − await expect(discoverDesktopSshHosts({ homeDir })).resolves.toEqual([ | |
| 68 | − { | |
| 69 | − alias: "bastion.example.com", | |
| 70 | − hostname: "bastion.example.com", | |
| 71 | − username: null, | |
| 72 | − port: null, | |
| 73 | − source: "known-hosts", | |
| 74 | − }, | |
| 75 | − { | |
| 76 | − alias: "devbox", | |
| 77 | − hostname: "devbox", | |
| 78 | − username: null, | |
| 79 | − port: null, | |
| 80 | − source: "ssh-config", | |
| 81 | − }, | |
| 82 | − { | |
| 83 | − alias: "known.example.com", | |
| 84 | − hostname: "known.example.com", | |
| 85 | − username: null, | |
| 86 | − port: null, | |
| 87 | − source: "known-hosts", | |
| 88 | − }, | |
| 89 | − { | |
| 90 | − alias: "staging", | |
| 91 | − hostname: "staging", | |
| 92 | − username: null, | |
| 93 | − port: null, | |
| 94 | − source: "ssh-config", | |
| 95 | − }, | |
| 96 | − ]); | |
| 97 | − }); | |
| 98 | −}); | |
| @@ -1,420 +0,0 @@ | ||
| 1 | −import * as Crypto from "node:crypto"; | |
| 2 | − | |
| 3 | −import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; | |
| 4 | −import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 5 | −import { NetService } from "@t3tools/shared/Net"; | |
| 6 | −import type { | |
| 7 | − AuthBearerBootstrapResult, | |
| 8 | − AuthSessionState, | |
| 9 | − AuthWebSocketTokenResult, | |
| 10 | − DesktopDiscoveredSshHost, | |
| 11 | − DesktopSshEnvironmentTarget, | |
| 12 | − DesktopSshPasswordPromptRequest, | |
| 13 | − ExecutionEnvironmentDescriptor, | |
| 14 | −} from "@t3tools/contracts"; | |
| 15 | −import { | |
| 16 | − SshPasswordPrompt, | |
| 17 | − type SshPasswordPromptShape, | |
| 18 | − type SshPasswordRequest, | |
| 19 | −} from "@t3tools/ssh/auth"; | |
| 20 | −import { discoverSshHosts } from "@t3tools/ssh/config"; | |
| 21 | −import { SshPasswordPromptError } from "@t3tools/ssh/errors"; | |
| 22 | −import { | |
| 23 | − fetchLoopbackSshJson, | |
| 24 | − SshEnvironmentManager, | |
| 25 | − type RemoteT3RunnerOptions, | |
| 26 | −} from "@t3tools/ssh/tunnel"; | |
| 27 | −import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect"; | |
| 28 | − | |
| 29 | −export { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; | |
| 30 | − | |
| 31 | −const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; | |
| 32 | −const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; | |
| 33 | −const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; | |
| 34 | −const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; | |
| 35 | −const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; | |
| 36 | −const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; | |
| 37 | −const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; | |
| 38 | −const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; | |
| 39 | −const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; | |
| 40 | −const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; | |
| 41 | −const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; | |
| 42 | − | |
| 43 | −interface DesktopSshEnvironmentManagerOptions { | |
| 44 | − readonly passwordProvider?: (request: SshPasswordRequest) => Promise<string | null>; | |
| 45 | − readonly resolveCliPackageSpec?: () => string; | |
| 46 | − readonly resolveCliRunner?: () => RemoteT3RunnerOptions; | |
| 47 | −} | |
| 48 | − | |
| 49 | −const sshRuntime = ManagedRuntime.make( | |
| 50 | − Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, NetService.layer), | |
| 51 | −); | |
| 52 | − | |
| 53 | −function createDesktopSshRuntime( | |
| 54 | − passwordPrompt: SshPasswordPromptShape, | |
| 55 | − scope: Scope.Scope, | |
| 56 | − options: DesktopSshEnvironmentManagerOptions, | |
| 57 | −) { | |
| 58 | − return ManagedRuntime.make( | |
| 59 | − Layer.mergeAll( | |
| 60 | − NodeServices.layer, | |
| 61 | − NodeHttpClient.layerUndici, | |
| 62 | − NetService.layer, | |
| 63 | − Layer.succeed(Scope.Scope, scope), | |
| 64 | − Layer.succeed(SshPasswordPrompt, SshPasswordPrompt.of(passwordPrompt)), | |
| 65 | − SshEnvironmentManager.layer({ | |
| 66 | − ...(options.resolveCliPackageSpec === undefined | |
| 67 | − ? {} | |
| 68 | − : { resolveCliPackageSpec: options.resolveCliPackageSpec }), | |
| 69 | − ...(options.resolveCliRunner === undefined | |
| 70 | − ? {} | |
| 71 | − : { resolveCliRunner: options.resolveCliRunner }), | |
| 72 | − }), | |
| 73 | − ), | |
| 74 | − ); | |
| 75 | −} | |
| 76 | − | |
| 77 | −export async function discoverDesktopSshHosts(input?: { | |
| 78 | − readonly homeDir?: string; | |
| 79 | −}): Promise<readonly DesktopDiscoveredSshHost[]> { | |
| 80 | − return await sshRuntime.runPromise(discoverSshHosts(input ?? {})); | |
| 81 | −} | |
| 82 | − | |
| 83 | −export class DesktopSshEnvironmentManager { | |
| 84 | − private readonly runtime: ReturnType<typeof createDesktopSshRuntime>; | |
| 85 | − private readonly scope: Scope.Scope; | |
| 86 | − | |
| 87 | − constructor(options: DesktopSshEnvironmentManagerOptions = {}) { | |
| 88 | − const passwordPrompt: SshPasswordPromptShape = { | |
| 89 | − isAvailable: options.passwordProvider !== undefined, | |
| 90 | − request: (request) => { | |
| 91 | − const passwordProvider = options.passwordProvider; | |
| 92 | − if (!passwordProvider) { | |
| 93 | − return Effect.succeed(null); | |
| 94 | − } | |
| 95 | − | |
| 96 | − return Effect.tryPromise({ | |
| 97 | − try: () => passwordProvider(request), | |
| 98 | − catch: (cause) => | |
| 99 | − new SshPasswordPromptError({ | |
| 100 | − message: cause instanceof Error ? cause.message : "SSH password prompt failed.", | |
| 101 | − cause, | |
| 102 | − }), | |
| 103 | − }); | |
| 104 | − }, | |
| 105 | − }; | |
| 106 | − this.scope = Effect.runSync(Scope.make()); | |
| 107 | − this.runtime = createDesktopSshRuntime(passwordPrompt, this.scope, options); | |
| 108 | − } | |
| 109 | − | |
| 110 | − async discoverHosts(): Promise<readonly DesktopDiscoveredSshHost[]> { | |
| 111 | − return await discoverDesktopSshHosts(); | |
| 112 | − } | |
| 113 | − | |
| 114 | − async ensureEnvironment( | |
| 115 | − target: DesktopSshEnvironmentTarget, | |
| 116 | − options?: { readonly issuePairingToken?: boolean }, | |
| 117 | − ) { | |
| 118 | − return await this.runtime.runPromise( | |
| 119 | − Effect.service(SshEnvironmentManager).pipe( | |
| 120 | − Effect.flatMap((manager) => manager.ensureEnvironment(target, options)), | |
| 121 | − ), | |
| 122 | − ); | |
| 123 | − } | |
| 124 | − | |
| 125 | − async disconnectEnvironment(target: DesktopSshEnvironmentTarget): Promise<void> { | |
| 126 | − await this.runtime.runPromise( | |
| 127 | − Effect.service(SshEnvironmentManager).pipe( | |
| 128 | − Effect.flatMap((manager) => manager.disconnectEnvironment(target)), | |
| 129 | − ), | |
| 130 | − ); | |
| 131 | − } | |
| 132 | − | |
| 133 | − async dispose(): Promise<void> { | |
| 134 | − await this.runtime.runPromise(Scope.close(this.scope, Exit.void)); | |
| 135 | − await this.runtime.dispose(); | |
| 136 | − } | |
| 137 | −} | |
| 138 | − | |
| 139 | −function getSafeDesktopSshTarget(rawTarget: unknown): DesktopSshEnvironmentTarget | null { | |
| 140 | − if (typeof rawTarget !== "object" || rawTarget === null) { | |
| 141 | − return null; | |
| 142 | − } | |
| 143 | − | |
| 144 | − const target = rawTarget as Partial<DesktopSshEnvironmentTarget>; | |
| 145 | − if (typeof target.alias !== "string" || typeof target.hostname !== "string") { | |
| 146 | − return null; | |
| 147 | − } | |
| 148 | − if ( | |
| 149 | − target.username !== null && | |
| 150 | − target.username !== undefined && | |
| 151 | − typeof target.username !== "string" | |
| 152 | − ) { | |
| 153 | − return null; | |
| 154 | − } | |
| 155 | − if (target.port !== null && target.port !== undefined && !Number.isInteger(target.port)) { | |
| 156 | − return null; | |
| 157 | − } | |
| 158 | − | |
| 159 | − const alias = target.alias.trim(); | |
| 160 | − const hostname = target.hostname.trim(); | |
| 161 | − if (alias.length === 0 || hostname.length === 0) { | |
| 162 | − return null; | |
| 163 | − } | |
| 164 | − | |
| 165 | − return { | |
| 166 | − alias, | |
| 167 | − hostname, | |
| 168 | − username: target.username?.trim() || null, | |
| 169 | − port: target.port ?? null, | |
| 170 | − }; | |
| 171 | −} | |
| 172 | − | |
| 173 | −/** Minimal subset of Electron's BrowserWindow used by the SSH bridge. */ | |
| 174 | −export interface DesktopSshBridgeWindow { | |
| 175 | − isDestroyed(): boolean; | |
| 176 | − isMinimized(): boolean; | |
| 177 | − restore(): void; | |
| 178 | − focus(): void; | |
| 179 | − readonly webContents: { | |
| 180 | − send(channel: string, ...args: readonly unknown[]): void; | |
| 181 | − }; | |
| 182 | −} | |
| 183 | − | |
| 184 | −/** Minimal subset of Electron's ipcMain used by the SSH bridge. */ | |
| 185 | −export interface DesktopSshBridgeIpcMain { | |
| 186 | − removeHandler(channel: string): void; | |
| 187 | − handle( | |
| 188 | − channel: string, | |
| 189 | − listener: (event: unknown, ...args: readonly unknown[]) => unknown | Promise<unknown>, | |
| 190 | − ): void; | |
| 191 | −} | |
| 192 | − | |
| 193 | −export interface DesktopSshEnvironmentBridgeOptions { | |
| 194 | − readonly getMainWindow: () => DesktopSshBridgeWindow | null; | |
| 195 | − readonly resolveCliPackageSpec?: () => string; | |
| 196 | − readonly resolveCliRunner?: () => RemoteT3RunnerOptions; | |
| 197 | − readonly passwordPromptTimeoutMs?: number; | |
| 198 | −} | |
| 199 | − | |
| 200 | −interface PendingSshPasswordPrompt { | |
| 201 | − readonly resolve: (password: string | null) => void; | |
| 202 | − readonly reject: (error: Error) => void; | |
| 203 | − readonly timeout: ReturnType<typeof setTimeout>; | |
| 204 | −} | |
| 205 | − | |
| 206 | −export function isSshPasswordPromptCancellation(error: unknown): error is SshPasswordPromptError { | |
| 207 | − const message = error instanceof SshPasswordPromptError ? error.message.toLowerCase() : ""; | |
| 208 | − return ( | |
| 209 | − error instanceof SshPasswordPromptError && | |
| 210 | − (message.includes("cancelled") || message.includes("timed out")) | |
| 211 | − ); | |
| 212 | −} | |
| 213 | − | |
| 214 | −/** | |
| 215 | − * Wires the SSH environment manager to Electron IPC, owning the renderer-facing | |
| 216 | − * password prompt state so `main.ts` only needs to register, cancel, and dispose. | |
| 217 | − */ | |
| 218 | −export class DesktopSshEnvironmentBridge { | |
| 219 | − private readonly options: DesktopSshEnvironmentBridgeOptions; | |
| 220 | − private readonly manager: DesktopSshEnvironmentManager; | |
| 221 | − private readonly pendingPrompts = new Map<string, PendingSshPasswordPrompt>(); | |
| 222 | − private readonly passwordPromptTimeoutMs: number; | |
| 223 | − | |
| 224 | − constructor(options: DesktopSshEnvironmentBridgeOptions) { | |
| 225 | − this.options = options; | |
| 226 | − this.passwordPromptTimeoutMs = | |
| 227 | − options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; | |
| 228 | − this.manager = new DesktopSshEnvironmentManager({ | |
| 229 | − passwordProvider: (request) => this.requestPasswordFromRenderer(request), | |
| 230 | − ...(options.resolveCliPackageSpec === undefined | |
| 231 | − ? {} | |
| 232 | − : { resolveCliPackageSpec: options.resolveCliPackageSpec }), | |
| 233 | − ...(options.resolveCliRunner === undefined | |
| 234 | − ? {} | |
| 235 | − : { resolveCliRunner: options.resolveCliRunner }), | |
| 236 | − }); | |
| 237 | − } | |
| 238 | − | |
| 239 | − registerIpcHandlers(ipcMain: DesktopSshBridgeIpcMain): void { | |
| 240 | − ipcMain.removeHandler(DISCOVER_SSH_HOSTS_CHANNEL); | |
| 241 | − ipcMain.handle(DISCOVER_SSH_HOSTS_CHANNEL, async () => this.manager.discoverHosts()); | |
| 242 | − | |
| 243 | − ipcMain.removeHandler(ENSURE_SSH_ENVIRONMENT_CHANNEL); | |
| 244 | − ipcMain.handle(ENSURE_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget, rawOptions) => { | |
| 245 | − const target = getSafeDesktopSshTarget(rawTarget); | |
| 246 | − if (!target) { | |
| 247 | − throw new Error("Invalid desktop SSH target."); | |
| 248 | − } | |
| 249 | − | |
| 250 | − const issuePairingToken = | |
| 251 | − typeof rawOptions === "object" && | |
| 252 | − rawOptions !== null && | |
| 253 | − "issuePairingToken" in rawOptions && | |
| 254 | − (rawOptions as { issuePairingToken?: unknown }).issuePairingToken === true; | |
| 255 | − | |
| 256 | − try { | |
| 257 | − return await this.manager.ensureEnvironment(target, { | |
| 258 | − issuePairingToken, | |
| 259 | − }); | |
| 260 | − } catch (error) { | |
| 261 | − if (isSshPasswordPromptCancellation(error)) { | |
| 262 | − return { | |
| 263 | − type: SSH_PASSWORD_PROMPT_CANCELLED_RESULT, | |
| 264 | − message: error.message, | |
| 265 | − }; | |
| 266 | − } | |
| 267 | − throw error; | |
| 268 | − } | |
| 269 | − }); | |
| 270 | − | |
| 271 | − ipcMain.removeHandler(DISCONNECT_SSH_ENVIRONMENT_CHANNEL); | |
| 272 | − ipcMain.handle(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget) => { | |
| 273 | − const target = getSafeDesktopSshTarget(rawTarget); | |
| 274 | − if (!target) { | |
| 275 | − throw new Error("Invalid desktop SSH target."); | |
| 276 | − } | |
| 277 | − | |
| 278 | − await this.manager.disconnectEnvironment(target); | |
| 279 | − }); | |
| 280 | − | |
| 281 | − ipcMain.removeHandler(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL); | |
| 282 | − ipcMain.handle(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, async (_event, rawHttpBaseUrl) => | |
| 283 | − sshRuntime.runPromise( | |
| 284 | − fetchLoopbackSshJson<ExecutionEnvironmentDescriptor>({ | |
| 285 | − httpBaseUrl: rawHttpBaseUrl, | |
| 286 | − pathname: "/.well-known/t3/environment", | |
| 287 | − }), | |
| 288 | − ), | |
| 289 | − ); | |
| 290 | − | |
| 291 | − ipcMain.removeHandler(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL); | |
| 292 | − ipcMain.handle( | |
| 293 | − BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, | |
| 294 | − async (_event, rawHttpBaseUrl, rawCredential) => | |
| 295 | − sshRuntime.runPromise( | |
| 296 | − fetchLoopbackSshJson<AuthBearerBootstrapResult>({ | |
| 297 | − httpBaseUrl: rawHttpBaseUrl, | |
| 298 | − pathname: "/api/auth/bootstrap/bearer", | |
| 299 | − method: "POST", | |
| 300 | − body: { credential: rawCredential }, | |
| 301 | − }), | |
| 302 | − ), | |
| 303 | − ); | |
| 304 | − | |
| 305 | − ipcMain.removeHandler(FETCH_SSH_SESSION_STATE_CHANNEL); | |
| 306 | − ipcMain.handle( | |
| 307 | − FETCH_SSH_SESSION_STATE_CHANNEL, | |
| 308 | − async (_event, rawHttpBaseUrl, rawBearerToken) => | |
| 309 | − sshRuntime.runPromise( | |
| 310 | − fetchLoopbackSshJson<AuthSessionState>({ | |
| 311 | − httpBaseUrl: rawHttpBaseUrl, | |
| 312 | − pathname: "/api/auth/session", | |
| 313 | − bearerToken: rawBearerToken, | |
| 314 | − }), | |
| 315 | − ), | |
| 316 | − ); | |
| 317 | − | |
| 318 | − ipcMain.removeHandler(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL); | |
| 319 | − ipcMain.handle( | |
| 320 | − ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, | |
| 321 | − async (_event, rawHttpBaseUrl, rawBearerToken) => | |
| 322 | − sshRuntime.runPromise( | |
| 323 | − fetchLoopbackSshJson<AuthWebSocketTokenResult>({ | |
| 324 | − httpBaseUrl: rawHttpBaseUrl, | |
| 325 | − pathname: "/api/auth/ws-token", | |
| 326 | − method: "POST", | |
| 327 | − bearerToken: rawBearerToken, | |
| 328 | − }), | |
| 329 | − ), | |
| 330 | − ); | |
| 331 | − | |
| 332 | − ipcMain.removeHandler(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL); | |
| 333 | − ipcMain.handle( | |
| 334 | − RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, | |
| 335 | − async (_event, rawRequestId, rawPassword) => { | |
| 336 | − if (typeof rawRequestId !== "string" || rawRequestId.trim().length === 0) { | |
| 337 | − throw new Error("Invalid SSH password prompt id."); | |
| 338 | − } | |
| 339 | − if (rawPassword !== null && typeof rawPassword !== "string") { | |
| 340 | − throw new Error("Invalid SSH password prompt response."); | |
| 341 | − } | |
| 342 | − | |
| 343 | − const pending = this.pendingPrompts.get(rawRequestId); | |
| 344 | − if (!pending) { | |
| 345 | − throw new Error("SSH password prompt expired. Try connecting again."); | |
| 346 | − } | |
| 347 | − | |
| 348 | − clearTimeout(pending.timeout); | |
| 349 | − this.pendingPrompts.delete(rawRequestId); | |
| 350 | − pending.resolve(rawPassword); | |
| 351 | − }, | |
| 352 | − ); | |
| 353 | − } | |
| 354 | − | |
| 355 | − cancelPendingPasswordPrompts(reason: string): void { | |
| 356 | − for (const [requestId, pending] of this.pendingPrompts) { | |
| 357 | − clearTimeout(pending.timeout); | |
| 358 | − this.pendingPrompts.delete(requestId); | |
| 359 | − pending.reject(new Error(reason)); | |
| 360 | − } | |
| 361 | − } | |
| 362 | − | |
| 363 | − async dispose(): Promise<void> { | |
| 364 | − this.cancelPendingPasswordPrompts("SSH environment bridge disposed."); | |
| 365 | − await this.manager.dispose(); | |
| 366 | − } | |
| 367 | − | |
| 368 | − private async requestPasswordFromRenderer(input: SshPasswordRequest): Promise<string | null> { | |
| 369 | − const window = this.options.getMainWindow(); | |
| 370 | − if (!window || window.isDestroyed()) { | |
| 371 | − throw new Error("T3 Code window is not available for SSH authentication."); | |
| 372 | − } | |
| 373 | − | |
| 374 | − const request: DesktopSshPasswordPromptRequest = { | |
| 375 | − requestId: Crypto.randomUUID(), | |
| 376 | − destination: input.destination, | |
| 377 | − username: input.username, | |
| 378 | − prompt: input.prompt, | |
| 379 | − expiresAt: new Date(Date.now() + this.passwordPromptTimeoutMs).toISOString(), | |
| 380 | − }; | |
| 381 | − | |
| 382 | − return await new Promise<string | null>((resolve, reject) => { | |
| 383 | − const rejectPrompt = (error: Error) => { | |
| 384 | − clearTimeout(timeout); | |
| 385 | − this.pendingPrompts.delete(request.requestId); | |
| 386 | − reject(error); | |
| 387 | − }; | |
| 388 | − const timeout = setTimeout(() => { | |
| 389 | − this.pendingPrompts.delete(request.requestId); | |
| 390 | − reject(new Error(`SSH authentication timed out for ${input.destination}.`)); | |
| 391 | − }, this.passwordPromptTimeoutMs); | |
| 392 | − timeout.unref(); | |
| 393 | − | |
| 394 | − this.pendingPrompts.set(request.requestId, { resolve, reject, timeout }); | |
| 395 | − | |
| 396 | − try { | |
| 397 | − if (window.isDestroyed()) { | |
| 398 | − throw new Error("T3 Code window is not available for SSH authentication."); | |
| 399 | − } | |
| 400 | − window.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, request); | |
| 401 | − if (window.isDestroyed()) { | |
| 402 | − throw new Error("T3 Code window is not available for SSH authentication."); | |
| 403 | − } | |
| 404 | − if (window.isMinimized()) { | |
| 405 | − window.restore(); | |
| 406 | − } | |
| 407 | − if (window.isDestroyed()) { | |
| 408 | − throw new Error("T3 Code window is not available for SSH authentication."); | |
| 409 | − } | |
| 410 | − window.focus(); | |
| 411 | − } catch (error) { | |
| 412 | − rejectPrompt( | |
| 413 | − error instanceof Error | |
| 414 | − ? error | |
| 415 | − : new Error("T3 Code window is not available for SSH authentication."), | |
| 416 | − ); | |
| 417 | − } | |
| 418 | − }); | |
| 419 | − } | |
| 420 | −} | |
| @@ -1,289 +0,0 @@ | ||
| 1 | −import { describe, expect, it, vi } from "vitest"; | |
| 2 | − | |
| 3 | −import { syncShellEnvironment } from "./syncShellEnvironment.ts"; | |
| 4 | − | |
| 5 | −describe("syncShellEnvironment", () => { | |
| 6 | − it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => { | |
| 7 | − const env: NodeJS.ProcessEnv = { | |
| 8 | − SHELL: "/bin/zsh", | |
| 9 | − PATH: "/Users/test/.local/bin:/usr/bin", | |
| 10 | − }; | |
| 11 | − const readEnvironment = vi.fn(() => ({ | |
| 12 | − PATH: "/opt/homebrew/bin:/usr/bin", | |
| 13 | − SSH_AUTH_SOCK: "/tmp/secretive.sock", | |
| 14 | − HOMEBREW_PREFIX: "/opt/homebrew", | |
| 15 | − })); | |
| 16 | − | |
| 17 | − syncShellEnvironment(env, { | |
| 18 | − platform: "darwin", | |
| 19 | − readEnvironment, | |
| 20 | − }); | |
| 21 | − | |
| 22 | − expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ | |
| 23 | − "PATH", | |
| 24 | − "SSH_AUTH_SOCK", | |
| 25 | − "HOMEBREW_PREFIX", | |
| 26 | − "HOMEBREW_CELLAR", | |
| 27 | − "HOMEBREW_REPOSITORY", | |
| 28 | − "XDG_CONFIG_HOME", | |
| 29 | − "XDG_DATA_HOME", | |
| 30 | − ]); | |
| 31 | − expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); | |
| 32 | − expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); | |
| 33 | − expect(env.HOMEBREW_PREFIX).toBe("/opt/homebrew"); | |
| 34 | − }); | |
| 35 | − | |
| 36 | − it("preserves an inherited SSH_AUTH_SOCK value", () => { | |
| 37 | − const env: NodeJS.ProcessEnv = { | |
| 38 | − SHELL: "/bin/zsh", | |
| 39 | − PATH: "/usr/bin", | |
| 40 | − SSH_AUTH_SOCK: "/tmp/inherited.sock", | |
| 41 | − }; | |
| 42 | − const readEnvironment = vi.fn(() => ({ | |
| 43 | − PATH: "/opt/homebrew/bin:/usr/bin", | |
| 44 | − SSH_AUTH_SOCK: "/tmp/login-shell.sock", | |
| 45 | − })); | |
| 46 | − | |
| 47 | − syncShellEnvironment(env, { | |
| 48 | − platform: "darwin", | |
| 49 | − readEnvironment, | |
| 50 | − }); | |
| 51 | − | |
| 52 | − expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); | |
| 53 | − expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); | |
| 54 | − }); | |
| 55 | − | |
| 56 | − it("preserves inherited values when the login shell omits them", () => { | |
| 57 | − const env: NodeJS.ProcessEnv = { | |
| 58 | − SHELL: "/bin/zsh", | |
| 59 | − PATH: "/usr/bin", | |
| 60 | − SSH_AUTH_SOCK: "/tmp/inherited.sock", | |
| 61 | − }; | |
| 62 | − const readEnvironment = vi.fn(() => ({ | |
| 63 | − PATH: "/opt/homebrew/bin:/usr/bin", | |
| 64 | − })); | |
| 65 | − | |
| 66 | − syncShellEnvironment(env, { | |
| 67 | − platform: "darwin", | |
| 68 | − readEnvironment, | |
| 69 | − }); | |
| 70 | − | |
| 71 | − expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); | |
| 72 | − expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); | |
| 73 | − }); | |
| 74 | − | |
| 75 | − it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => { | |
| 76 | − const env: NodeJS.ProcessEnv = { | |
| 77 | − SHELL: "/bin/zsh", | |
| 78 | − PATH: "/usr/bin", | |
| 79 | − }; | |
| 80 | − const readEnvironment = vi.fn(() => ({ | |
| 81 | − PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", | |
| 82 | − SSH_AUTH_SOCK: "/tmp/secretive.sock", | |
| 83 | − })); | |
| 84 | − | |
| 85 | − syncShellEnvironment(env, { | |
| 86 | − platform: "linux", | |
| 87 | − readEnvironment, | |
| 88 | − }); | |
| 89 | − | |
| 90 | − expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ | |
| 91 | − "PATH", | |
| 92 | − "SSH_AUTH_SOCK", | |
| 93 | − "HOMEBREW_PREFIX", | |
| 94 | − "HOMEBREW_CELLAR", | |
| 95 | − "HOMEBREW_REPOSITORY", | |
| 96 | − "XDG_CONFIG_HOME", | |
| 97 | − "XDG_DATA_HOME", | |
| 98 | − ]); | |
| 99 | − expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin"); | |
| 100 | − expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); | |
| 101 | − }); | |
| 102 | − | |
| 103 | − it("falls back to launchctl PATH on macOS when shell probing does not return one", () => { | |
| 104 | − const env: NodeJS.ProcessEnv = { | |
| 105 | − SHELL: "/opt/homebrew/bin/nu", | |
| 106 | − PATH: "/usr/bin", | |
| 107 | − }; | |
| 108 | − const readEnvironment = vi | |
| 109 | − .fn() | |
| 110 | − .mockImplementationOnce(() => { | |
| 111 | − throw new Error("unknown flag"); | |
| 112 | − }) | |
| 113 | − .mockImplementationOnce(() => ({})); | |
| 114 | − const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); | |
| 115 | − const logWarning = vi.fn(); | |
| 116 | − | |
| 117 | − syncShellEnvironment(env, { | |
| 118 | − platform: "darwin", | |
| 119 | − readEnvironment, | |
| 120 | − readLaunchctlPath, | |
| 121 | − userShell: "/bin/zsh", | |
| 122 | − logWarning, | |
| 123 | − }); | |
| 124 | − | |
| 125 | − expect(readEnvironment).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu", [ | |
| 126 | − "PATH", | |
| 127 | − "SSH_AUTH_SOCK", | |
| 128 | − "HOMEBREW_PREFIX", | |
| 129 | − "HOMEBREW_CELLAR", | |
| 130 | − "HOMEBREW_REPOSITORY", | |
| 131 | − "XDG_CONFIG_HOME", | |
| 132 | − "XDG_DATA_HOME", | |
| 133 | − ]); | |
| 134 | − expect(readEnvironment).toHaveBeenNthCalledWith(2, "/bin/zsh", [ | |
| 135 | − "PATH", | |
| 136 | − "SSH_AUTH_SOCK", | |
| 137 | − "HOMEBREW_PREFIX", | |
| 138 | − "HOMEBREW_CELLAR", | |
| 139 | − "HOMEBREW_REPOSITORY", | |
| 140 | − "XDG_CONFIG_HOME", | |
| 141 | − "XDG_DATA_HOME", | |
| 142 | − ]); | |
| 143 | − expect(readLaunchctlPath).toHaveBeenCalledTimes(1); | |
| 144 | − expect(logWarning).toHaveBeenCalledWith( | |
| 145 | − "Failed to read login shell environment from /opt/homebrew/bin/nu.", | |
| 146 | − expect.any(Error), | |
| 147 | − ); | |
| 148 | − expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); | |
| 149 | − }); | |
| 150 | − | |
| 151 | − it("does nothing on unsupported platforms", () => { | |
| 152 | − const env: NodeJS.ProcessEnv = { | |
| 153 | − SHELL: "C:/Program Files/Git/bin/bash.exe", | |
| 154 | − PATH: "C:\\Windows\\System32", | |
| 155 | − SSH_AUTH_SOCK: "/tmp/inherited.sock", | |
| 156 | − }; | |
| 157 | − const readEnvironment = vi.fn(() => ({ | |
| 158 | − PATH: "/usr/local/bin:/usr/bin", | |
| 159 | − SSH_AUTH_SOCK: "/tmp/secretive.sock", | |
| 160 | − })); | |
| 161 | − | |
| 162 | − syncShellEnvironment(env, { | |
| 163 | − platform: "freebsd", | |
| 164 | − readEnvironment, | |
| 165 | − }); | |
| 166 | − | |
| 167 | − expect(readEnvironment).not.toHaveBeenCalled(); | |
| 168 | − expect(env.PATH).toBe("C:\\Windows\\System32"); | |
| 169 | − expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); | |
| 170 | − }); | |
| 171 | − | |
| 172 | − it("hydrates PATH on Windows by merging PowerShell PATH with inherited PATH", () => { | |
| 173 | − const env: NodeJS.ProcessEnv = { | |
| 174 | − PATH: "C:\\Windows\\System32", | |
| 175 | − APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | |
| 176 | − LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", | |
| 177 | − USERPROFILE: "C:\\Users\\testuser", | |
| 178 | − }; | |
| 179 | − const readWindowsEnvironment = vi.fn(() => ({ | |
| 180 | − PATH: "C:\\Custom\\Bin;C:\\Windows\\System32", | |
| 181 | − })); | |
| 182 | − const isWindowsCommandAvailable = vi.fn(() => true); | |
| 183 | − | |
| 184 | − syncShellEnvironment(env, { | |
| 185 | − platform: "win32", | |
| 186 | − readWindowsEnvironment, | |
| 187 | − isWindowsCommandAvailable, | |
| 188 | − }); | |
| 189 | − | |
| 190 | − expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); | |
| 191 | − expect(env.PATH).toBe( | |
| 192 | − [ | |
| 193 | − "C:\\Users\\testuser\\AppData\\Roaming\\npm", | |
| 194 | − "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", | |
| 195 | − "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", | |
| 196 | − "C:\\Users\\testuser\\AppData\\Local\\pnpm", | |
| 197 | − "C:\\Users\\testuser\\.bun\\bin", | |
| 198 | − "C:\\Users\\testuser\\scoop\\shims", | |
| 199 | − "C:\\Custom\\Bin", | |
| 200 | − "C:\\Windows\\System32", | |
| 201 | − ].join(";"), | |
| 202 | − ); | |
| 203 | − expect(isWindowsCommandAvailable).toHaveBeenCalledTimes(1); | |
| 204 | − }); | |
| 205 | − | |
| 206 | − it("loads the PowerShell profile on Windows when node is not available", () => { | |
| 207 | − const env: NodeJS.ProcessEnv = { | |
| 208 | − PATH: "C:\\Windows\\System32", | |
| 209 | − APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | |
| 210 | − LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", | |
| 211 | − USERPROFILE: "C:\\Users\\testuser", | |
| 212 | − }; | |
| 213 | − const readWindowsEnvironment = vi.fn( | |
| 214 | − (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => | |
| 215 | − options?.loadProfile | |
| 216 | − ? { | |
| 217 | − PATH: "C:\\Profile\\Node;C:\\Windows\\System32", | |
| 218 | − FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", | |
| 219 | − FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", | |
| 220 | − } | |
| 221 | − : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }, | |
| 222 | − ); | |
| 223 | − const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); | |
| 224 | − | |
| 225 | − syncShellEnvironment(env, { | |
| 226 | − platform: "win32", | |
| 227 | − readWindowsEnvironment, | |
| 228 | − isWindowsCommandAvailable, | |
| 229 | − }); | |
| 230 | − | |
| 231 | − expect(env.PATH).toBe( | |
| 232 | − [ | |
| 233 | − "C:\\Profile\\Node", | |
| 234 | − "C:\\Windows\\System32", | |
| 235 | − "C:\\Users\\testuser\\AppData\\Roaming\\npm", | |
| 236 | − "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", | |
| 237 | − "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", | |
| 238 | − "C:\\Users\\testuser\\AppData\\Local\\pnpm", | |
| 239 | − "C:\\Users\\testuser\\.bun\\bin", | |
| 240 | − "C:\\Users\\testuser\\scoop\\shims", | |
| 241 | − "C:\\Custom\\Bin", | |
| 242 | − ].join(";"), | |
| 243 | − ); | |
| 244 | − expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm"); | |
| 245 | − expect(env.FNM_MULTISHELL_PATH).toBe( | |
| 246 | − "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", | |
| 247 | − ); | |
| 248 | − expect(readWindowsEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); | |
| 249 | − expect(readWindowsEnvironment).toHaveBeenNthCalledWith( | |
| 250 | − 2, | |
| 251 | − ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], | |
| 252 | − { loadProfile: true }, | |
| 253 | − ); | |
| 254 | − }); | |
| 255 | − | |
| 256 | − it("preserves baseline Windows env when the profile probe fails", () => { | |
| 257 | − const env: NodeJS.ProcessEnv = { | |
| 258 | − PATH: "C:\\Windows\\System32", | |
| 259 | − APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | |
| 260 | − USERPROFILE: "C:\\Users\\testuser", | |
| 261 | − }; | |
| 262 | − const readWindowsEnvironment = vi.fn( | |
| 263 | − (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => { | |
| 264 | − if (options?.loadProfile) { | |
| 265 | − throw new Error("profile load failed"); | |
| 266 | − } | |
| 267 | − return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; | |
| 268 | − }, | |
| 269 | − ); | |
| 270 | − const isWindowsCommandAvailable = vi.fn(() => false); | |
| 271 | − | |
| 272 | − syncShellEnvironment(env, { | |
| 273 | − platform: "win32", | |
| 274 | − readWindowsEnvironment, | |
| 275 | − isWindowsCommandAvailable, | |
| 276 | − }); | |
| 277 | − | |
| 278 | − expect(env.PATH).toBe( | |
| 279 | − [ | |
| 280 | − "C:\\Users\\testuser\\AppData\\Roaming\\npm", | |
| 281 | − "C:\\Users\\testuser\\.bun\\bin", | |
| 282 | − "C:\\Users\\testuser\\scoop\\shims", | |
| 283 | − "C:\\Custom\\Bin", | |
| 284 | − "C:\\Windows\\System32", | |
| 285 | − ].join(";"), | |
| 286 | − ); | |
| 287 | − expect(env.SSH_AUTH_SOCK).toBeUndefined(); | |
| 288 | − }); | |
| 289 | −}); | |
| @@ -1,109 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − listLoginShellCandidates, | |
| 3 | − mergePathEntries, | |
| 4 | − readPathFromLaunchctl, | |
| 5 | − readEnvironmentFromLoginShell, | |
| 6 | − resolveWindowsEnvironment, | |
| 7 | −} from "@t3tools/shared/shell"; | |
| 8 | −import type { | |
| 9 | − CommandAvailabilityOptions, | |
| 10 | − ShellEnvironmentReader, | |
| 11 | − WindowsShellEnvironmentReader, | |
| 12 | −} from "@t3tools/shared/shell"; | |
| 13 | − | |
| 14 | −type WindowsCommandAvailabilityChecker = ( | |
| 15 | − command: string, | |
| 16 | − options?: CommandAvailabilityOptions, | |
| 17 | −) => boolean; | |
| 18 | − | |
| 19 | −const LOGIN_SHELL_ENV_NAMES = [ | |
| 20 | − "PATH", | |
| 21 | − "SSH_AUTH_SOCK", | |
| 22 | − "HOMEBREW_PREFIX", | |
| 23 | − "HOMEBREW_CELLAR", | |
| 24 | − "HOMEBREW_REPOSITORY", | |
| 25 | − "XDG_CONFIG_HOME", | |
| 26 | − "XDG_DATA_HOME", | |
| 27 | −] as const; | |
| 28 | − | |
| 29 | −function logShellEnvironmentWarning(message: string, error?: unknown): void { | |
| 30 | − console.warn(`[desktop] ${message}`, error instanceof Error ? error.message : (error ?? "")); | |
| 31 | −} | |
| 32 | − | |
| 33 | −export function syncShellEnvironment( | |
| 34 | − env: NodeJS.ProcessEnv = process.env, | |
| 35 | − options: { | |
| 36 | − platform?: NodeJS.Platform; | |
| 37 | − readEnvironment?: ShellEnvironmentReader; | |
| 38 | − readWindowsEnvironment?: WindowsShellEnvironmentReader; | |
| 39 | − isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; | |
| 40 | − readLaunchctlPath?: typeof readPathFromLaunchctl; | |
| 41 | − userShell?: string; | |
| 42 | − logWarning?: (message: string, error?: unknown) => void; | |
| 43 | − } = {}, | |
| 44 | −): void { | |
| 45 | − const platform = options.platform ?? process.platform; | |
| 46 | − | |
| 47 | − const logWarning = options.logWarning ?? logShellEnvironmentWarning; | |
| 48 | − const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell; | |
| 49 | − const shellEnvironment: Partial<Record<string, string>> = {}; | |
| 50 | − | |
| 51 | − try { | |
| 52 | − if (platform === "win32") { | |
| 53 | − const repairedEnvironment = resolveWindowsEnvironment(env, { | |
| 54 | − ...(options.readWindowsEnvironment | |
| 55 | − ? { readEnvironment: options.readWindowsEnvironment } | |
| 56 | − : {}), | |
| 57 | − ...(options.isWindowsCommandAvailable | |
| 58 | − ? { commandAvailable: options.isWindowsCommandAvailable } | |
| 59 | − : {}), | |
| 60 | − }); | |
| 61 | − for (const [key, value] of Object.entries(repairedEnvironment)) { | |
| 62 | − if (value !== undefined) { | |
| 63 | − env[key] = value; | |
| 64 | − } | |
| 65 | − } | |
| 66 | − return; | |
| 67 | − } | |
| 68 | − | |
| 69 | − if (platform !== "darwin" && platform !== "linux") return; | |
| 70 | − | |
| 71 | − for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { | |
| 72 | − try { | |
| 73 | − Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES)); | |
| 74 | − if (shellEnvironment.PATH) { | |
| 75 | − break; | |
| 76 | − } | |
| 77 | − } catch (error) { | |
| 78 | − logWarning(`Failed to read login shell environment from ${shell}.`, error); | |
| 79 | − } | |
| 80 | − } | |
| 81 | − | |
| 82 | − const launchctlPath = | |
| 83 | − platform === "darwin" && !shellEnvironment.PATH | |
| 84 | − ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() | |
| 85 | − : undefined; | |
| 86 | − const mergedPath = mergePathEntries(shellEnvironment.PATH ?? launchctlPath, env.PATH, platform); | |
| 87 | − if (mergedPath) { | |
| 88 | − env.PATH = mergedPath; | |
| 89 | − } | |
| 90 | − | |
| 91 | − if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { | |
| 92 | − env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; | |
| 93 | − } | |
| 94 | − | |
| 95 | − for (const name of [ | |
| 96 | − "HOMEBREW_PREFIX", | |
| 97 | − "HOMEBREW_CELLAR", | |
| 98 | − "HOMEBREW_REPOSITORY", | |
| 99 | − "XDG_CONFIG_HOME", | |
| 100 | − "XDG_DATA_HOME", | |
| 101 | − ] as const) { | |
| 102 | − if (!env[name] && shellEnvironment[name]) { | |
| 103 | − env[name] = shellEnvironment[name]; | |
| 104 | − } | |
| 105 | − } | |
| 106 | − } catch (error) { | |
| 107 | − logWarning("Failed to synchronize the desktop shell environment.", error); | |
| 108 | − } | |
| 109 | −} | |
| @@ -1,122 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "vitest"; | |
| 2 | −import { Effect } from "effect"; | |
| 3 | − | |
| 4 | −import { | |
| 5 | − isTailscaleIpv4Address, | |
| 6 | − parseTailscaleMagicDnsName, | |
| 7 | − resolveTailscaleAdvertisedEndpoints, | |
| 8 | −} from "./tailscaleEndpointProvider.ts"; | |
| 9 | − | |
| 10 | −describe("tailscale endpoint provider", () => { | |
| 11 | − it("detects Tailnet IPv4 addresses", () => { | |
| 12 | − expect(isTailscaleIpv4Address("100.64.0.1")).toBe(true); | |
| 13 | − expect(isTailscaleIpv4Address("100.127.255.254")).toBe(true); | |
| 14 | − expect(isTailscaleIpv4Address("100.128.0.1")).toBe(false); | |
| 15 | − expect(isTailscaleIpv4Address("192.168.1.44")).toBe(false); | |
| 16 | − }); | |
| 17 | − | |
| 18 | − it("parses MagicDNS names from tailscale status", async () => { | |
| 19 | − expect( | |
| 20 | − Effect.runSync( | |
| 21 | − parseTailscaleMagicDnsName(JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } })), | |
| 22 | − ), | |
| 23 | − ).toBe("desktop.tail.ts.net"); | |
| 24 | − expect(Effect.runSync(parseTailscaleMagicDnsName("{}"))).toBeNull(); | |
| 25 | − await expect(Effect.runPromise(parseTailscaleMagicDnsName("not-json"))).rejects.toBeDefined(); | |
| 26 | − }); | |
| 27 | − | |
| 28 | − it("resolves Tailscale endpoints as add-on advertised endpoints", async () => { | |
| 29 | − await expect( | |
| 30 | − resolveTailscaleAdvertisedEndpoints({ | |
| 31 | − port: 3773, | |
| 32 | − networkInterfaces: { | |
| 33 | − tailscale0: [ | |
| 34 | − { | |
| 35 | − address: "100.100.100.100", | |
| 36 | − family: "IPv4", | |
| 37 | − internal: false, | |
| 38 | − netmask: "255.192.0.0", | |
| 39 | − cidr: "100.100.100.100/10", | |
| 40 | − mac: "00:00:00:00:00:00", | |
| 41 | − }, | |
| 42 | − ], | |
| 43 | − }, | |
| 44 | − statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }), | |
| 45 | − }), | |
| 46 | − ).resolves.toEqual([ | |
| 47 | − { | |
| 48 | − id: "tailscale-ip:http://100.100.100.100:3773", | |
| 49 | − label: "Tailscale IP", | |
| 50 | − provider: { | |
| 51 | − id: "tailscale", | |
| 52 | − label: "Tailscale", | |
| 53 | − kind: "private-network", | |
| 54 | − isAddon: true, | |
| 55 | − }, | |
| 56 | − httpBaseUrl: "http://100.100.100.100:3773/", | |
| 57 | − wsBaseUrl: "ws://100.100.100.100:3773/", | |
| 58 | − reachability: "private-network", | |
| 59 | − compatibility: { | |
| 60 | − hostedHttpsApp: "mixed-content-blocked", | |
| 61 | − desktopApp: "compatible", | |
| 62 | − }, | |
| 63 | − source: "desktop-addon", | |
| 64 | − status: "available", | |
| 65 | − description: "Reachable from devices on the same Tailnet.", | |
| 66 | − }, | |
| 67 | − { | |
| 68 | − id: "tailscale-magicdns:https://desktop.tail.ts.net/", | |
| 69 | − label: "Tailscale HTTPS", | |
| 70 | − provider: { | |
| 71 | − id: "tailscale", | |
| 72 | − label: "Tailscale", | |
| 73 | − kind: "private-network", | |
| 74 | − isAddon: true, | |
| 75 | − }, | |
| 76 | − httpBaseUrl: "https://desktop.tail.ts.net/", | |
| 77 | − wsBaseUrl: "wss://desktop.tail.ts.net/", | |
| 78 | − reachability: "private-network", | |
| 79 | − compatibility: { | |
| 80 | − hostedHttpsApp: "requires-configuration", | |
| 81 | − desktopApp: "compatible", | |
| 82 | − }, | |
| 83 | − source: "desktop-addon", | |
| 84 | − status: "unavailable", | |
| 85 | − description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", | |
| 86 | − }, | |
| 87 | − ]); | |
| 88 | − }); | |
| 89 | − | |
| 90 | − it("marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", async () => { | |
| 91 | − await expect( | |
| 92 | − resolveTailscaleAdvertisedEndpoints({ | |
| 93 | − port: 3773, | |
| 94 | − networkInterfaces: {}, | |
| 95 | − statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }), | |
| 96 | − serveEnabled: true, | |
| 97 | − probe: async () => true, | |
| 98 | − }), | |
| 99 | − ).resolves.toEqual([ | |
| 100 | − { | |
| 101 | − id: "tailscale-magicdns:https://desktop.tail.ts.net/", | |
| 102 | − label: "Tailscale HTTPS", | |
| 103 | − provider: { | |
| 104 | − id: "tailscale", | |
| 105 | − label: "Tailscale", | |
| 106 | − kind: "private-network", | |
| 107 | − isAddon: true, | |
| 108 | − }, | |
| 109 | − httpBaseUrl: "https://desktop.tail.ts.net/", | |
| 110 | − wsBaseUrl: "wss://desktop.tail.ts.net/", | |
| 111 | − reachability: "private-network", | |
| 112 | − compatibility: { | |
| 113 | − hostedHttpsApp: "compatible", | |
| 114 | − desktopApp: "compatible", | |
| 115 | − }, | |
| 116 | − source: "desktop-addon", | |
| 117 | − status: "available", | |
| 118 | − description: "HTTPS endpoint served by Tailscale Serve.", | |
| 119 | − }, | |
| 120 | − ]); | |
| 121 | − }); | |
| 122 | −}); | |
| @@ -1,142 +0,0 @@ | ||
| 1 | −import type { NetworkInterfaceInfo } from "node:os"; | |
| 2 | − | |
| 3 | −import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; | |
| 4 | −import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 5 | −import { | |
| 6 | − createAdvertisedEndpoint, | |
| 7 | − type CreateAdvertisedEndpointInput, | |
| 8 | −} from "@t3tools/client-runtime"; | |
| 9 | −import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; | |
| 10 | −import { | |
| 11 | − buildTailscaleHttpsBaseUrl, | |
| 12 | − isTailscaleIpv4Address, | |
| 13 | − parseTailscaleMagicDnsName, | |
| 14 | − probeTailscaleHttpsEndpoint, | |
| 15 | − readTailscaleStatus, | |
| 16 | −} from "@t3tools/tailscale"; | |
| 17 | −import { Effect, Layer } from "effect"; | |
| 18 | − | |
| 19 | −export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; | |
| 20 | − | |
| 21 | −const TailscaleDesktopLayer = Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici); | |
| 22 | − | |
| 23 | −const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { | |
| 24 | − id: "tailscale", | |
| 25 | − label: "Tailscale", | |
| 26 | − kind: "private-network", | |
| 27 | − isAddon: true, | |
| 28 | −}; | |
| 29 | − | |
| 30 | −function createTailscaleEndpoint( | |
| 31 | − input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">, | |
| 32 | −): AdvertisedEndpoint { | |
| 33 | − return createAdvertisedEndpoint({ | |
| 34 | − ...input, | |
| 35 | − provider: TAILSCALE_ENDPOINT_PROVIDER, | |
| 36 | − source: "desktop-addon", | |
| 37 | − }); | |
| 38 | −} | |
| 39 | − | |
| 40 | −export function resolveTailscaleIpAdvertisedEndpoints(input: { | |
| 41 | − readonly port: number; | |
| 42 | − readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>; | |
| 43 | −}): readonly AdvertisedEndpoint[] { | |
| 44 | − const seen = new Set<string>(); | |
| 45 | − const endpoints: AdvertisedEndpoint[] = []; | |
| 46 | − | |
| 47 | − for (const interfaceAddresses of Object.values(input.networkInterfaces)) { | |
| 48 | − if (!interfaceAddresses) continue; | |
| 49 | − | |
| 50 | − for (const address of interfaceAddresses) { | |
| 51 | − if (address.internal) continue; | |
| 52 | − if (address.family !== "IPv4") continue; | |
| 53 | − if (!isTailscaleIpv4Address(address.address)) continue; | |
| 54 | − if (seen.has(address.address)) continue; | |
| 55 | − seen.add(address.address); | |
| 56 | − | |
| 57 | − endpoints.push( | |
| 58 | − createTailscaleEndpoint({ | |
| 59 | − id: `tailscale-ip:http://${address.address}:${input.port}`, | |
| 60 | − label: "Tailscale IP", | |
| 61 | − httpBaseUrl: `http://${address.address}:${input.port}`, | |
| 62 | − reachability: "private-network", | |
| 63 | − status: "available", | |
| 64 | − description: "Reachable from devices on the same Tailnet.", | |
| 65 | − }), | |
| 66 | − ); | |
| 67 | − } | |
| 68 | − } | |
| 69 | − | |
| 70 | − return endpoints; | |
| 71 | −} | |
| 72 | − | |
| 73 | −export async function resolveTailscaleMagicDnsAdvertisedEndpoint(input: { | |
| 74 | − readonly dnsName: string | null; | |
| 75 | − readonly serveEnabled: boolean; | |
| 76 | − readonly servePort?: number; | |
| 77 | − readonly probe?: (baseUrl: string) => Promise<boolean>; | |
| 78 | −}): Promise<AdvertisedEndpoint | null> { | |
| 79 | − if (!input.dnsName) { | |
| 80 | − return null; | |
| 81 | − } | |
| 82 | − | |
| 83 | − const httpBaseUrl = buildTailscaleHttpsBaseUrl({ | |
| 84 | − magicDnsName: input.dnsName, | |
| 85 | − ...(input.servePort === undefined ? {} : { servePort: input.servePort }), | |
| 86 | − }); | |
| 87 | − const isReachable = input.serveEnabled | |
| 88 | − ? await (input.probe?.(httpBaseUrl) ?? | |
| 89 | − Effect.runPromise( | |
| 90 | − probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe( | |
| 91 | − Effect.provide(TailscaleDesktopLayer), | |
| 92 | − ), | |
| 93 | − )) | |
| 94 | − : false; | |
| 95 | − | |
| 96 | − return createTailscaleEndpoint({ | |
| 97 | − id: `tailscale-magicdns:${httpBaseUrl}`, | |
| 98 | − label: "Tailscale HTTPS", | |
| 99 | − httpBaseUrl, | |
| 100 | − reachability: "private-network", | |
| 101 | − hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration", | |
| 102 | − status: isReachable ? "available" : "unavailable", | |
| 103 | − description: isReachable | |
| 104 | − ? "HTTPS endpoint served by Tailscale Serve." | |
| 105 | − : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", | |
| 106 | − }); | |
| 107 | −} | |
| 108 | − | |
| 109 | −export async function resolveTailscaleAdvertisedEndpoints(input: { | |
| 110 | − readonly port: number; | |
| 111 | − readonly serveEnabled?: boolean; | |
| 112 | − readonly servePort?: number; | |
| 113 | − readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>; | |
| 114 | − readonly statusJson?: string | null; | |
| 115 | − readonly probe?: (baseUrl: string) => Promise<boolean>; | |
| 116 | −}): Promise<readonly AdvertisedEndpoint[]> { | |
| 117 | − const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); | |
| 118 | − const dnsName = | |
| 119 | − input.statusJson === undefined | |
| 120 | − ? await Effect.runPromise( | |
| 121 | − readTailscaleStatus.pipe( | |
| 122 | − Effect.map((status) => status.magicDnsName), | |
| 123 | − Effect.catch(() => Effect.succeed(null)), | |
| 124 | − Effect.provide(TailscaleDesktopLayer), | |
| 125 | − ), | |
| 126 | − ) | |
| 127 | − : input.statusJson | |
| 128 | − ? await Effect.runPromise( | |
| 129 | − parseTailscaleMagicDnsName(input.statusJson).pipe( | |
| 130 | − Effect.catch(() => Effect.succeed(null)), | |
| 131 | − ), | |
| 132 | − ) | |
| 133 | − : null; | |
| 134 | − const magicDnsEndpoint = await resolveTailscaleMagicDnsAdvertisedEndpoint({ | |
| 135 | − dnsName, | |
| 136 | − serveEnabled: input.serveEnabled === true, | |
| 137 | − ...(input.servePort === undefined ? {} : { servePort: input.servePort }), | |
| 138 | − ...(input.probe === undefined ? {} : { probe: input.probe }), | |
| 139 | − }); | |
| 140 | − | |
| 141 | − return magicDnsEndpoint ? [...ipEndpoints, magicDnsEndpoint] : ipEndpoints; | |
| 142 | −} | |
| @@ -1,41 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "vitest"; | |
| 2 | − | |
| 3 | −import { | |
| 4 | − doesVersionMatchDesktopUpdateChannel, | |
| 5 | − isNightlyDesktopVersion, | |
| 6 | − resolveDefaultDesktopUpdateChannel, | |
| 7 | −} from "./updateChannels.ts"; | |
| 8 | − | |
| 9 | −describe("isNightlyDesktopVersion", () => { | |
| 10 | − it("detects packaged nightly versions", () => { | |
| 11 | − expect(isNightlyDesktopVersion("0.0.17-nightly.20260415.1")).toBe(true); | |
| 12 | − }); | |
| 13 | − | |
| 14 | − it("does not flag stable versions as nightly", () => { | |
| 15 | − expect(isNightlyDesktopVersion("0.0.17")).toBe(false); | |
| 16 | − }); | |
| 17 | −}); | |
| 18 | − | |
| 19 | −describe("resolveDefaultDesktopUpdateChannel", () => { | |
| 20 | − it("defaults stable builds to latest", () => { | |
| 21 | − expect(resolveDefaultDesktopUpdateChannel("0.0.17")).toBe("latest"); | |
| 22 | − }); | |
| 23 | − | |
| 24 | − it("defaults nightly builds to nightly", () => { | |
| 25 | − expect(resolveDefaultDesktopUpdateChannel("0.0.17-nightly.20260415.1")).toBe("nightly"); | |
| 26 | − }); | |
| 27 | −}); | |
| 28 | − | |
| 29 | −describe("doesVersionMatchDesktopUpdateChannel", () => { | |
| 30 | − it("accepts nightly releases on the nightly channel", () => { | |
| 31 | − expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "nightly")).toBe(true); | |
| 32 | − }); | |
| 33 | − | |
| 34 | − it("rejects stable releases on the nightly channel", () => { | |
| 35 | − expect(doesVersionMatchDesktopUpdateChannel("0.0.17", "nightly")).toBe(false); | |
| 36 | − }); | |
| 37 | − | |
| 38 | − it("rejects nightly releases on the stable channel", () => { | |
| 39 | − expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "latest")).toBe(false); | |
| 40 | − }); | |
| 41 | −}); | |
| @@ -1,175 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "vitest"; | |
| 2 | −import type { DesktopUpdateState } from "@t3tools/contracts"; | |
| 3 | − | |
| 4 | −import { | |
| 5 | − getCanRetryAfterDownloadFailure, | |
| 6 | − getAutoUpdateDisabledReason, | |
| 7 | − nextStatusAfterDownloadFailure, | |
| 8 | − shouldBroadcastDownloadProgress, | |
| 9 | −} from "./updateState.ts"; | |
| 10 | − | |
| 11 | −const baseState: DesktopUpdateState = { | |
| 12 | − enabled: true, | |
| 13 | − status: "idle", | |
| 14 | − channel: "latest", | |
| 15 | − currentVersion: "1.0.0", | |
| 16 | − hostArch: "x64", | |
| 17 | − appArch: "x64", | |
| 18 | − runningUnderArm64Translation: false, | |
| 19 | − availableVersion: null, | |
| 20 | − downloadedVersion: null, | |
| 21 | − downloadPercent: null, | |
| 22 | − checkedAt: null, | |
| 23 | − message: null, | |
| 24 | − errorContext: null, | |
| 25 | − canRetry: false, | |
| 26 | −}; | |
| 27 | − | |
| 28 | −describe("shouldBroadcastDownloadProgress", () => { | |
| 29 | − it("broadcasts the first downloading progress update", () => { | |
| 30 | − expect( | |
| 31 | − shouldBroadcastDownloadProgress( | |
| 32 | − { ...baseState, status: "downloading", downloadPercent: null }, | |
| 33 | − 1, | |
| 34 | − ), | |
| 35 | − ).toBe(true); | |
| 36 | − }); | |
| 37 | − | |
| 38 | − it("skips progress updates within the same 10% bucket", () => { | |
| 39 | − expect( | |
| 40 | − shouldBroadcastDownloadProgress( | |
| 41 | − { ...baseState, status: "downloading", downloadPercent: 11.2 }, | |
| 42 | − 18.7, | |
| 43 | − ), | |
| 44 | − ).toBe(false); | |
| 45 | − }); | |
| 46 | − | |
| 47 | − it("broadcasts progress updates when a new 10% bucket is reached", () => { | |
| 48 | − expect( | |
| 49 | − shouldBroadcastDownloadProgress( | |
| 50 | − { ...baseState, status: "downloading", downloadPercent: 19.9 }, | |
| 51 | − 20.1, | |
| 52 | − ), | |
| 53 | − ).toBe(true); | |
| 54 | − }); | |
| 55 | − | |
| 56 | − it("broadcasts progress updates when a retry resets the download percentage", () => { | |
| 57 | − expect( | |
| 58 | − shouldBroadcastDownloadProgress( | |
| 59 | − { ...baseState, status: "downloading", downloadPercent: 50.4 }, | |
| 60 | − 0.2, | |
| 61 | − ), | |
| 62 | − ).toBe(true); | |
| 63 | − }); | |
| 64 | −}); | |
| 65 | − | |
| 66 | −describe("getAutoUpdateDisabledReason", () => { | |
| 67 | − it("reports development builds as disabled", () => { | |
| 68 | − expect( | |
| 69 | − getAutoUpdateDisabledReason({ | |
| 70 | − isDevelopment: true, | |
| 71 | − isPackaged: false, | |
| 72 | − platform: "darwin", | |
| 73 | − appImage: undefined, | |
| 74 | − disabledByEnv: false, | |
| 75 | − hasUpdateFeedConfig: true, | |
| 76 | − }), | |
| 77 | − ).toContain("packaged production builds"); | |
| 78 | − }); | |
| 79 | − | |
| 80 | − it("reports packaged local builds without an update feed as disabled", () => { | |
| 81 | − expect( | |
| 82 | − getAutoUpdateDisabledReason({ | |
| 83 | − isDevelopment: false, | |
| 84 | − isPackaged: true, | |
| 85 | − platform: "darwin", | |
| 86 | − appImage: undefined, | |
| 87 | − disabledByEnv: false, | |
| 88 | − hasUpdateFeedConfig: false, | |
| 89 | − }), | |
| 90 | − ).toContain("no update feed"); | |
| 91 | − }); | |
| 92 | − | |
| 93 | − it("allows packaged builds with an update feed", () => { | |
| 94 | − expect( | |
| 95 | − getAutoUpdateDisabledReason({ | |
| 96 | − isDevelopment: false, | |
| 97 | − isPackaged: true, | |
| 98 | − platform: "darwin", | |
| 99 | − appImage: undefined, | |
| 100 | − disabledByEnv: false, | |
| 101 | − hasUpdateFeedConfig: true, | |
| 102 | − }), | |
| 103 | − ).toBeNull(); | |
| 104 | − }); | |
| 105 | − | |
| 106 | − it("reports env-disabled auto updates", () => { | |
| 107 | − expect( | |
| 108 | − getAutoUpdateDisabledReason({ | |
| 109 | − isDevelopment: false, | |
| 110 | − isPackaged: true, | |
| 111 | − platform: "darwin", | |
| 112 | − appImage: undefined, | |
| 113 | − disabledByEnv: true, | |
| 114 | − hasUpdateFeedConfig: true, | |
| 115 | − }), | |
| 116 | − ).toContain("T3CODE_DISABLE_AUTO_UPDATE"); | |
| 117 | − }); | |
| 118 | − | |
| 119 | − it("reports linux non-AppImage builds as disabled", () => { | |
| 120 | − expect( | |
| 121 | − getAutoUpdateDisabledReason({ | |
| 122 | − isDevelopment: false, | |
| 123 | − isPackaged: true, | |
| 124 | − platform: "linux", | |
| 125 | − appImage: undefined, | |
| 126 | − disabledByEnv: false, | |
| 127 | − hasUpdateFeedConfig: true, | |
| 128 | − }), | |
| 129 | − ).toContain("AppImage"); | |
| 130 | − }); | |
| 131 | −}); | |
| 132 | − | |
| 133 | −describe("nextStatusAfterDownloadFailure", () => { | |
| 134 | − it("returns available when an update version is still known", () => { | |
| 135 | − expect( | |
| 136 | − nextStatusAfterDownloadFailure({ | |
| 137 | − ...baseState, | |
| 138 | − status: "downloading", | |
| 139 | − availableVersion: "1.1.0", | |
| 140 | − }), | |
| 141 | − ).toBe("available"); | |
| 142 | − }); | |
| 143 | − | |
| 144 | − it("returns error when no update version can be retried", () => { | |
| 145 | − expect( | |
| 146 | − nextStatusAfterDownloadFailure({ | |
| 147 | − ...baseState, | |
| 148 | − status: "downloading", | |
| 149 | − availableVersion: null, | |
| 150 | − }), | |
| 151 | − ).toBe("error"); | |
| 152 | − }); | |
| 153 | −}); | |
| 154 | − | |
| 155 | −describe("getCanRetryAfterDownloadFailure", () => { | |
| 156 | − it("returns true when an available version is still present", () => { | |
| 157 | − expect( | |
| 158 | − getCanRetryAfterDownloadFailure({ | |
| 159 | − ...baseState, | |
| 160 | − status: "downloading", | |
| 161 | − availableVersion: "1.1.0", | |
| 162 | − }), | |
| 163 | − ).toBe(true); | |
| 164 | − }); | |
| 165 | − | |
| 166 | − it("returns false when no version is available to retry", () => { | |
| 167 | − expect( | |
| 168 | − getCanRetryAfterDownloadFailure({ | |
| 169 | − ...baseState, | |
| 170 | − status: "downloading", | |
| 171 | − availableVersion: null, | |
| 172 | − }), | |
| 173 | − ).toBe(false); | |
| 174 | − }); | |
| 175 | −}); | |
| @@ -1,52 +0,0 @@ | ||
| 1 | −import type { DesktopUpdateState } from "@t3tools/contracts"; | |
| 2 | − | |
| 3 | −export function shouldBroadcastDownloadProgress( | |
| 4 | − currentState: DesktopUpdateState, | |
| 5 | − nextPercent: number, | |
| 6 | −): boolean { | |
| 7 | − if (currentState.status !== "downloading") { | |
| 8 | − return true; | |
| 9 | − } | |
| 10 | − | |
| 11 | − const currentPercent = currentState.downloadPercent; | |
| 12 | − if (currentPercent === null) { | |
| 13 | − return true; | |
| 14 | − } | |
| 15 | − | |
| 16 | − const previousStep = Math.floor(currentPercent / 10); | |
| 17 | − const nextStep = Math.floor(nextPercent / 10); | |
| 18 | − return nextStep !== previousStep || nextPercent === 100; | |
| 19 | −} | |
| 20 | − | |
| 21 | −export function nextStatusAfterDownloadFailure( | |
| 22 | − currentState: DesktopUpdateState, | |
| 23 | −): DesktopUpdateState["status"] { | |
| 24 | − return currentState.availableVersion ? "available" : "error"; | |
| 25 | −} | |
| 26 | − | |
| 27 | −export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState): boolean { | |
| 28 | − return currentState.availableVersion !== null; | |
| 29 | −} | |
| 30 | − | |
| 31 | −export function getAutoUpdateDisabledReason(args: { | |
| 32 | − isDevelopment: boolean; | |
| 33 | − isPackaged: boolean; | |
| 34 | − platform: NodeJS.Platform; | |
| 35 | − appImage?: string | undefined; | |
| 36 | − disabledByEnv: boolean; | |
| 37 | − hasUpdateFeedConfig: boolean; | |
| 38 | −}): string | null { | |
| 39 | − if (!args.hasUpdateFeedConfig) { | |
| 40 | − return "Automatic updates are not available because no update feed is configured."; | |
| 41 | − } | |
| 42 | − if (args.isDevelopment || !args.isPackaged) { | |
| 43 | − return "Automatic updates are only available in packaged production builds."; | |
| 44 | − } | |
| 45 | − if (args.disabledByEnv) { | |
| 46 | − return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."; | |
| 47 | − } | |
| 48 | − if (args.platform === "linux" && !args.appImage) { | |
| 49 | − return "Automatic updates on Linux require running the AppImage build."; | |
| 50 | − } | |
| 51 | − return null; | |
| 52 | −} | |
| @@ -0,0 +1,295 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | +import type { DesktopUpdateState } from "@t3tools/contracts"; | |
| 4 | +import * as Cause from "effect/Cause"; | |
| 5 | +import * as Deferred from "effect/Deferred"; | |
| 6 | +import * as Duration from "effect/Duration"; | |
| 7 | +import * as Effect from "effect/Effect"; | |
| 8 | +import * as Fiber from "effect/Fiber"; | |
| 9 | +import * as Layer from "effect/Layer"; | |
| 10 | +import * as Option from "effect/Option"; | |
| 11 | +import { TestClock } from "effect/testing"; | |
| 12 | + | |
| 13 | +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; | |
| 14 | +import * as DesktopConfig from "../app/DesktopConfig.ts"; | |
| 15 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 16 | +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; | |
| 17 | +import * as ElectronWindow from "../electron/ElectronWindow.ts"; | |
| 18 | +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | |
| 19 | +import * as DesktopState from "../app/DesktopState.ts"; | |
| 20 | +import * as DesktopUpdates from "./DesktopUpdates.ts"; | |
| 21 | + | |
| 22 | +interface UpdatesHarnessOptions { | |
| 23 | + readonly checkForUpdates?: Effect.Effect< | |
| 24 | + void, | |
| 25 | + ElectronUpdater.ElectronUpdaterCheckForUpdatesError | |
| 26 | + >; | |
| 27 | + readonly env?: Record<string, string | undefined>; | |
| 28 | +} | |
| 29 | + | |
| 30 | +const flushCallbacks = Effect.yieldNow; | |
| 31 | + | |
| 32 | +function makeHarness(options: UpdatesHarnessOptions = {}) { | |
| 33 | + let checkCount = 0; | |
| 34 | + let allowDowngrade = false; | |
| 35 | + const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; | |
| 36 | + const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>(); | |
| 37 | + const sentStates: DesktopUpdateState[] = []; | |
| 38 | + | |
| 39 | + const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { | |
| 40 | + const eventListeners = listeners.get(eventName) ?? new Set(); | |
| 41 | + eventListeners.add(listener); | |
| 42 | + listeners.set(eventName, eventListeners); | |
| 43 | + }; | |
| 44 | + | |
| 45 | + const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { | |
| 46 | + const eventListeners = listeners.get(eventName); | |
| 47 | + if (!eventListeners) { | |
| 48 | + return; | |
| 49 | + } | |
| 50 | + eventListeners.delete(listener); | |
| 51 | + if (eventListeners.size === 0) { | |
| 52 | + listeners.delete(eventName); | |
| 53 | + } | |
| 54 | + }; | |
| 55 | + | |
| 56 | + const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, { | |
| 57 | + setFeedURL: (options) => | |
| 58 | + Effect.sync(() => { | |
| 59 | + feedUrls.push(options); | |
| 60 | + }), | |
| 61 | + setAutoDownload: () => Effect.void, | |
| 62 | + setAutoInstallOnAppQuit: () => Effect.void, | |
| 63 | + setChannel: () => Effect.void, | |
| 64 | + setAllowPrerelease: () => Effect.void, | |
| 65 | + allowDowngrade: Effect.sync(() => allowDowngrade), | |
| 66 | + setAllowDowngrade: (value) => | |
| 67 | + Effect.sync(() => { | |
| 68 | + allowDowngrade = value; | |
| 69 | + }), | |
| 70 | + setDisableDifferentialDownload: () => Effect.void, | |
| 71 | + checkForUpdates: Effect.sync(() => { | |
| 72 | + checkCount += 1; | |
| 73 | + }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), | |
| 74 | + downloadUpdate: Effect.void, | |
| 75 | + quitAndInstall: () => Effect.void, | |
| 76 | + on: (eventName, listener) => | |
| 77 | + Effect.acquireRelease( | |
| 78 | + Effect.sync(() => { | |
| 79 | + addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); | |
| 80 | + }), | |
| 81 | + () => | |
| 82 | + Effect.sync(() => { | |
| 83 | + removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); | |
| 84 | + }), | |
| 85 | + ).pipe(Effect.asVoid), | |
| 86 | + } satisfies ElectronUpdater.ElectronUpdaterShape); | |
| 87 | + | |
| 88 | + const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { | |
| 89 | + create: () => Effect.die("unexpected BrowserWindow creation"), | |
| 90 | + main: Effect.succeed(Option.none()), | |
| 91 | + currentMainOrFirst: Effect.succeed(Option.none()), | |
| 92 | + focusedMainOrFirst: Effect.succeed(Option.none()), | |
| 93 | + setMain: () => Effect.void, | |
| 94 | + clearMain: () => Effect.void, | |
| 95 | + reveal: () => Effect.void, | |
| 96 | + sendAll: (_channel, state) => | |
| 97 | + Effect.sync(() => { | |
| 98 | + sentStates.push(state as DesktopUpdateState); | |
| 99 | + }), | |
| 100 | + destroyAll: Effect.void, | |
| 101 | + syncAllAppearance: () => Effect.void, | |
| 102 | + } satisfies ElectronWindow.ElectronWindowShape); | |
| 103 | + | |
| 104 | + const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { | |
| 105 | + start: Effect.void, | |
| 106 | + stop: () => Effect.void, | |
| 107 | + currentConfig: Effect.succeed(Option.none()), | |
| 108 | + snapshot: Effect.succeed({ | |
| 109 | + desiredRunning: false, | |
| 110 | + ready: false, | |
| 111 | + activePid: Option.none(), | |
| 112 | + restartAttempt: 0, | |
| 113 | + restartScheduled: false, | |
| 114 | + }), | |
| 115 | + }); | |
| 116 | + | |
| 117 | + const environmentLayer = DesktopEnvironment.layer({ | |
| 118 | + dirname: "/repo/apps/desktop/src", | |
| 119 | + homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, | |
| 120 | + platform: "darwin", | |
| 121 | + processArch: "x64", | |
| 122 | + appVersion: "1.2.3", | |
| 123 | + appPath: "/repo", | |
| 124 | + isPackaged: true, | |
| 125 | + resourcesPath: "/missing/resources", | |
| 126 | + runningUnderArm64Translation: false, | |
| 127 | + }).pipe( | |
| 128 | + Layer.provide( | |
| 129 | + Layer.mergeAll( | |
| 130 | + NodeServices.layer, | |
| 131 | + DesktopConfig.layerTest({ | |
| 132 | + T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, | |
| 133 | + T3CODE_DESKTOP_MOCK_UPDATES: "true", | |
| 134 | + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", | |
| 135 | + ...options.env, | |
| 136 | + }), | |
| 137 | + ), | |
| 138 | + ), | |
| 139 | + ); | |
| 140 | + | |
| 141 | + const layer = DesktopUpdates.layer.pipe( | |
| 142 | + Layer.provideMerge(updaterLayer), | |
| 143 | + Layer.provideMerge(windowLayer), | |
| 144 | + Layer.provideMerge(backendLayer), | |
| 145 | + Layer.provideMerge(DesktopState.layer), | |
| 146 | + Layer.provideMerge(DesktopAppSettings.layer), | |
| 147 | + Layer.provideMerge( | |
| 148 | + DesktopConfig.layerTest({ | |
| 149 | + T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, | |
| 150 | + T3CODE_DESKTOP_MOCK_UPDATES: "true", | |
| 151 | + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", | |
| 152 | + ...options.env, | |
| 153 | + }), | |
| 154 | + ), | |
| 155 | + Layer.provideMerge(environmentLayer), | |
| 156 | + Layer.provideMerge(NodeServices.layer), | |
| 157 | + ); | |
| 158 | + | |
| 159 | + return { | |
| 160 | + layer, | |
| 161 | + checkCount: () => checkCount, | |
| 162 | + feedUrls: () => feedUrls, | |
| 163 | + listenerCount: () => | |
| 164 | + Array.from(listeners.values()).reduce( | |
| 165 | + (total, eventListeners) => total + eventListeners.size, | |
| 166 | + 0, | |
| 167 | + ), | |
| 168 | + sentStates, | |
| 169 | + emit: (eventName: string, payload?: unknown) => { | |
| 170 | + for (const listener of listeners.get(eventName) ?? []) { | |
| 171 | + listener(payload); | |
| 172 | + } | |
| 173 | + }, | |
| 174 | + }; | |
| 175 | +} | |
| 176 | + | |
| 177 | +describe("DesktopUpdates", () => { | |
| 178 | + it.effect("configures the updater and runs startup checks on the test clock", () => { | |
| 179 | + const harness = makeHarness(); | |
| 180 | + | |
| 181 | + return Effect.gen(function* () { | |
| 182 | + yield* Effect.scoped( | |
| 183 | + Effect.gen(function* () { | |
| 184 | + const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 185 | + yield* updates.configure; | |
| 186 | + | |
| 187 | + const state = yield* updates.getState; | |
| 188 | + assert.equal(state.enabled, true); | |
| 189 | + assert.equal(state.status, "idle"); | |
| 190 | + assert.deepEqual(harness.feedUrls(), [ | |
| 191 | + { provider: "generic", url: "http://localhost:4141" }, | |
| 192 | + ]); | |
| 193 | + assert.equal(harness.listenerCount(), 6); | |
| 194 | + assert.equal(harness.checkCount(), 0); | |
| 195 | + | |
| 196 | + yield* TestClock.adjust(Duration.millis(15_000)); | |
| 197 | + assert.equal(harness.checkCount(), 1); | |
| 198 | + }), | |
| 199 | + ); | |
| 200 | + | |
| 201 | + assert.equal(harness.listenerCount(), 0); | |
| 202 | + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); | |
| 203 | + }); | |
| 204 | + | |
| 205 | + it.effect("updates and broadcasts state from updater events", () => { | |
| 206 | + const harness = makeHarness(); | |
| 207 | + | |
| 208 | + return Effect.scoped( | |
| 209 | + Effect.gen(function* () { | |
| 210 | + const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 211 | + yield* updates.configure; | |
| 212 | + | |
| 213 | + harness.emit("update-available", { version: "1.2.4" }); | |
| 214 | + yield* flushCallbacks; | |
| 215 | + | |
| 216 | + const state = yield* updates.getState; | |
| 217 | + assert.equal(state.status, "available"); | |
| 218 | + assert.equal(state.availableVersion, "1.2.4"); | |
| 219 | + assert.isNotNull(state.checkedAt); | |
| 220 | + assert.equal(harness.sentStates.at(-1)?.status, "available"); | |
| 221 | + }), | |
| 222 | + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); | |
| 223 | + }); | |
| 224 | + | |
| 225 | + it.effect("persists channel changes through the settings service", () => { | |
| 226 | + const harness = makeHarness(); | |
| 227 | + | |
| 228 | + return Effect.scoped( | |
| 229 | + Effect.gen(function* () { | |
| 230 | + const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 231 | + const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 232 | + yield* updates.configure; | |
| 233 | + | |
| 234 | + const state = yield* updates.setChannel("nightly"); | |
| 235 | + const persistedSettings = yield* settings.get; | |
| 236 | + | |
| 237 | + assert.equal(state.channel, "nightly"); | |
| 238 | + assert.equal(persistedSettings.updateChannel, "nightly"); | |
| 239 | + assert.equal(persistedSettings.updateChannelConfiguredByUser, true); | |
| 240 | + }), | |
| 241 | + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); | |
| 242 | + }); | |
| 243 | + | |
| 244 | + it.effect("does not persist an unchanged update channel as a user preference", () => { | |
| 245 | + const harness = makeHarness(); | |
| 246 | + | |
| 247 | + return Effect.scoped( | |
| 248 | + Effect.gen(function* () { | |
| 249 | + const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 250 | + const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 251 | + yield* updates.configure; | |
| 252 | + | |
| 253 | + const state = yield* updates.setChannel("latest"); | |
| 254 | + const persistedSettings = yield* settings.get; | |
| 255 | + | |
| 256 | + assert.equal(state.channel, "latest"); | |
| 257 | + assert.equal(persistedSettings.updateChannel, "latest"); | |
| 258 | + assert.equal(persistedSettings.updateChannelConfiguredByUser, false); | |
| 259 | + }), | |
| 260 | + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); | |
| 261 | + }); | |
| 262 | + | |
| 263 | + it.effect("fails channel changes with a typed error while a check is in progress", () => | |
| 264 | + Effect.gen(function* () { | |
| 265 | + const checkStarted = yield* Deferred.make<void>(); | |
| 266 | + const releaseCheck = yield* Deferred.make<void>(); | |
| 267 | + const harness = makeHarness({ | |
| 268 | + checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe( | |
| 269 | + Effect.andThen(Deferred.await(releaseCheck)), | |
| 270 | + ), | |
| 271 | + }); | |
| 272 | + | |
| 273 | + yield* Effect.scoped( | |
| 274 | + Effect.gen(function* () { | |
| 275 | + const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 276 | + yield* updates.configure; | |
| 277 | + | |
| 278 | + const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped); | |
| 279 | + yield* Deferred.await(checkStarted); | |
| 280 | + | |
| 281 | + const exit = yield* Effect.exit(updates.setChannel("nightly")); | |
| 282 | + assert.equal(exit._tag, "Failure"); | |
| 283 | + if (exit._tag === "Failure") { | |
| 284 | + const error = Cause.squash(exit.cause); | |
| 285 | + assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError); | |
| 286 | + assert.equal(error.action, "check"); | |
| 287 | + } | |
| 288 | + | |
| 289 | + yield* Deferred.succeed(releaseCheck, undefined); | |
| 290 | + yield* Fiber.join(checkFiber); | |
| 291 | + }), | |
| 292 | + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); | |
| 293 | + }), | |
| 294 | + ); | |
| 295 | +}); | |
| @@ -0,0 +1,664 @@ | ||
| 1 | +import type { | |
| 2 | + DesktopRuntimeInfo, | |
| 3 | + DesktopUpdateActionResult, | |
| 4 | + DesktopUpdateChannel, | |
| 5 | + DesktopUpdateCheckResult, | |
| 6 | + DesktopUpdateState, | |
| 7 | +} from "@t3tools/contracts"; | |
| 8 | +import * as Cause from "effect/Cause"; | |
| 9 | +import * as Context from "effect/Context"; | |
| 10 | +import * as Data from "effect/Data"; | |
| 11 | +import * as DateTime from "effect/DateTime"; | |
| 12 | +import * as Duration from "effect/Duration"; | |
| 13 | +import * as Effect from "effect/Effect"; | |
| 14 | +import * as FileSystem from "effect/FileSystem"; | |
| 15 | +import * as Layer from "effect/Layer"; | |
| 16 | +import * as Option from "effect/Option"; | |
| 17 | +import * as Ref from "effect/Ref"; | |
| 18 | +import * as Schema from "effect/Schema"; | |
| 19 | +import * as Scope from "effect/Scope"; | |
| 20 | + | |
| 21 | +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; | |
| 22 | +import * as DesktopConfig from "../app/DesktopConfig.ts"; | |
| 23 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 24 | +import * as DesktopObservability from "../app/DesktopObservability.ts"; | |
| 25 | +import * as DesktopState from "../app/DesktopState.ts"; | |
| 26 | +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; | |
| 27 | +import * as ElectronWindow from "../electron/ElectronWindow.ts"; | |
| 28 | +import * as IpcChannels from "../ipc/channels.ts"; | |
| 29 | +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | |
| 30 | +import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; | |
| 31 | +import { | |
| 32 | + createInitialDesktopUpdateState, | |
| 33 | + reduceDesktopUpdateStateOnCheckFailure, | |
| 34 | + reduceDesktopUpdateStateOnCheckStart, | |
| 35 | + reduceDesktopUpdateStateOnDownloadComplete, | |
| 36 | + reduceDesktopUpdateStateOnDownloadFailure, | |
| 37 | + reduceDesktopUpdateStateOnDownloadProgress, | |
| 38 | + reduceDesktopUpdateStateOnDownloadStart, | |
| 39 | + reduceDesktopUpdateStateOnInstallFailure, | |
| 40 | + reduceDesktopUpdateStateOnNoUpdate, | |
| 41 | + reduceDesktopUpdateStateOnUpdateAvailable, | |
| 42 | +} from "./updateMachine.ts"; | |
| 43 | + | |
| 44 | +const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; | |
| 45 | +const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; | |
| 46 | + | |
| 47 | +const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); | |
| 48 | +type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; | |
| 49 | + | |
| 50 | +const UpdateInfo = Schema.Struct({ | |
| 51 | + version: Schema.String, | |
| 52 | +}); | |
| 53 | + | |
| 54 | +const DownloadProgressInfo = Schema.Struct({ | |
| 55 | + percent: Schema.Number, | |
| 56 | +}); | |
| 57 | + | |
| 58 | +const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); | |
| 59 | + | |
| 60 | +export class DesktopUpdateActionInProgressError extends Data.TaggedError( | |
| 61 | + "DesktopUpdateActionInProgressError", | |
| 62 | +)<{ | |
| 63 | + readonly action: "check" | "download" | "install"; | |
| 64 | +}> { | |
| 65 | + override get message() { | |
| 66 | + return `Cannot change update tracks while an update ${this.action} action is in progress.`; | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +export class DesktopUpdatePersistenceError extends Data.TaggedError( | |
| 71 | + "DesktopUpdatePersistenceError", | |
| 72 | +)<{ | |
| 73 | + readonly cause: DesktopAppSettings.DesktopSettingsWriteError; | |
| 74 | +}> { | |
| 75 | + override get message() { | |
| 76 | + return "Failed to persist desktop update settings."; | |
| 77 | + } | |
| 78 | +} | |
| 79 | + | |
| 80 | +export type DesktopUpdateConfigureError = never; | |
| 81 | + | |
| 82 | +export type DesktopUpdateSetChannelError = | |
| 83 | + | DesktopUpdateActionInProgressError | |
| 84 | + | DesktopUpdatePersistenceError; | |
| 85 | + | |
| 86 | +export interface DesktopUpdatesShape { | |
| 87 | + readonly getState: Effect.Effect<DesktopUpdateState>; | |
| 88 | + readonly emitState: Effect.Effect<void>; | |
| 89 | + readonly disabledReason: Effect.Effect<Option.Option<string>>; | |
| 90 | + readonly configure: Effect.Effect<void, DesktopUpdateConfigureError, Scope.Scope>; | |
| 91 | + readonly setChannel: ( | |
| 92 | + channel: DesktopUpdateChannel, | |
| 93 | + ) => Effect.Effect<DesktopUpdateState, DesktopUpdateSetChannelError>; | |
| 94 | + readonly check: (reason: string) => Effect.Effect<DesktopUpdateCheckResult>; | |
| 95 | + readonly download: Effect.Effect<DesktopUpdateActionResult>; | |
| 96 | + readonly install: Effect.Effect<DesktopUpdateActionResult>; | |
| 97 | +} | |
| 98 | + | |
| 99 | +export class DesktopUpdates extends Context.Service<DesktopUpdates, DesktopUpdatesShape>()( | |
| 100 | + "t3/desktop/Updates", | |
| 101 | +) {} | |
| 102 | + | |
| 103 | +const { | |
| 104 | + logInfo: logUpdaterInfo, | |
| 105 | + logWarning: logUpdaterWarning, | |
| 106 | + logError: logUpdaterError, | |
| 107 | +} = DesktopObservability.makeComponentLogger("desktop-updater"); | |
| 108 | + | |
| 109 | +function parseAppUpdateYml(raw: string): Effect.Effect<Option.Option<AppUpdateYmlConfig>> { | |
| 110 | + const entries: Record<string, string> = {}; | |
| 111 | + for (const line of raw.split("\n")) { | |
| 112 | + const match = line.match(/^(\w+):\s*(.+)$/); | |
| 113 | + if (match?.[1] && match[2]) { | |
| 114 | + entries[match[1]] = match[2].trim(); | |
| 115 | + } | |
| 116 | + } | |
| 117 | + | |
| 118 | + return Schema.decodeUnknownEffect(AppUpdateYmlConfig)(entries).pipe( | |
| 119 | + Effect.map((config) => (config.provider ? Option.some(config) : Option.none())), | |
| 120 | + Effect.catch(() => Effect.succeed(Option.none<AppUpdateYmlConfig>())), | |
| 121 | + ); | |
| 122 | +} | |
| 123 | + | |
| 124 | +function createBaseUpdateState( | |
| 125 | + channel: DesktopUpdateChannel, | |
| 126 | + enabled: boolean, | |
| 127 | + environment: DesktopEnvironment.DesktopEnvironmentShape, | |
| 128 | +): DesktopUpdateState { | |
| 129 | + return { | |
| 130 | + ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel), | |
| 131 | + enabled, | |
| 132 | + status: enabled ? "idle" : "disabled", | |
| 133 | + }; | |
| 134 | +} | |
| 135 | + | |
| 136 | +function getCanRetryFromState(state: DesktopUpdateState): boolean { | |
| 137 | + return state.availableVersion !== null || state.downloadedVersion !== null; | |
| 138 | +} | |
| 139 | + | |
| 140 | +function shouldBroadcastDownloadProgress( | |
| 141 | + currentState: DesktopUpdateState, | |
| 142 | + nextPercent: number, | |
| 143 | +): boolean { | |
| 144 | + if (currentState.status !== "downloading") { | |
| 145 | + return true; | |
| 146 | + } | |
| 147 | + | |
| 148 | + const currentPercent = currentState.downloadPercent; | |
| 149 | + if (currentPercent === null) { | |
| 150 | + return true; | |
| 151 | + } | |
| 152 | + | |
| 153 | + const previousStep = Math.floor(currentPercent / 10); | |
| 154 | + const nextStep = Math.floor(nextPercent / 10); | |
| 155 | + return nextStep !== previousStep || nextPercent === 100; | |
| 156 | +} | |
| 157 | + | |
| 158 | +function getAutoUpdateDisabledReason(args: { | |
| 159 | + isDevelopment: boolean; | |
| 160 | + isPackaged: boolean; | |
| 161 | + platform: NodeJS.Platform; | |
| 162 | + appImage?: string | undefined; | |
| 163 | + disabledByEnv: boolean; | |
| 164 | + hasUpdateFeedConfig: boolean; | |
| 165 | +}): string | null { | |
| 166 | + if (!args.hasUpdateFeedConfig) { | |
| 167 | + return "Automatic updates are not available because no update feed is configured."; | |
| 168 | + } | |
| 169 | + if (args.isDevelopment || !args.isPackaged) { | |
| 170 | + return "Automatic updates are only available in packaged production builds."; | |
| 171 | + } | |
| 172 | + if (args.disabledByEnv) { | |
| 173 | + return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."; | |
| 174 | + } | |
| 175 | + if (args.platform === "linux" && !args.appImage) { | |
| 176 | + return "Automatic updates on Linux require running the AppImage build."; | |
| 177 | + } | |
| 178 | + return null; | |
| 179 | +} | |
| 180 | + | |
| 181 | +function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean { | |
| 182 | + return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; | |
| 183 | +} | |
| 184 | + | |
| 185 | +const make = Effect.gen(function* () { | |
| 186 | + const config = yield* DesktopConfig.DesktopConfig; | |
| 187 | + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 188 | + const desktopState = yield* DesktopState.DesktopState; | |
| 189 | + const electronUpdater = yield* ElectronUpdater.ElectronUpdater; | |
| 190 | + const electronWindow = yield* ElectronWindow.ElectronWindow; | |
| 191 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 192 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 193 | + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 194 | + | |
| 195 | + const appUpdateYmlConfigRef = yield* Ref.make<Option.Option<AppUpdateYmlConfig>>(Option.none()); | |
| 196 | + const updateCheckInFlightRef = yield* Ref.make(false); | |
| 197 | + const updateDownloadInFlightRef = yield* Ref.make(false); | |
| 198 | + const updateInstallInFlightRef = yield* Ref.make(false); | |
| 199 | + const updaterConfiguredRef = yield* Ref.make(false); | |
| 200 | + const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); | |
| 201 | + const updateStateRef = yield* Ref.make<DesktopUpdateState>( | |
| 202 | + createInitialDesktopUpdateState( | |
| 203 | + environment.appVersion, | |
| 204 | + environment.runtimeInfo, | |
| 205 | + environment.defaultDesktopSettings.updateChannel, | |
| 206 | + ), | |
| 207 | + ); | |
| 208 | + | |
| 209 | + const emitState = Ref.get(updateStateRef).pipe( | |
| 210 | + Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)), | |
| 211 | + ); | |
| 212 | + | |
| 213 | + const setState = (state: DesktopUpdateState): Effect.Effect<void> => | |
| 214 | + Ref.set(updateStateRef, state).pipe(Effect.andThen(emitState)); | |
| 215 | + | |
| 216 | + const updateState = ( | |
| 217 | + f: (state: DesktopUpdateState) => DesktopUpdateState, | |
| 218 | + ): Effect.Effect<DesktopUpdateState> => | |
| 219 | + Ref.get(updateStateRef).pipe( | |
| 220 | + Effect.flatMap((state) => { | |
| 221 | + const nextState = f(state); | |
| 222 | + return setState(nextState).pipe(Effect.as(nextState)); | |
| 223 | + }), | |
| 224 | + ); | |
| 225 | + | |
| 226 | + const readAppUpdateYml = fileSystem.readFileString(environment.appUpdateYmlPath, "utf-8").pipe( | |
| 227 | + Effect.option, | |
| 228 | + Effect.flatMap( | |
| 229 | + Option.match({ | |
| 230 | + onNone: () => Effect.succeed(Option.none<AppUpdateYmlConfig>()), | |
| 231 | + onSome: parseAppUpdateYml, | |
| 232 | + }), | |
| 233 | + ), | |
| 234 | + ); | |
| 235 | + | |
| 236 | + const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe( | |
| 237 | + Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates), | |
| 238 | + ); | |
| 239 | + | |
| 240 | + const resolveDisabledReason = Effect.gen(function* () { | |
| 241 | + const hasFeedConfig = yield* hasUpdateFeedConfig; | |
| 242 | + return Option.fromNullishOr( | |
| 243 | + getAutoUpdateDisabledReason({ | |
| 244 | + isDevelopment: environment.isDevelopment, | |
| 245 | + isPackaged: environment.isPackaged, | |
| 246 | + platform: environment.platform, | |
| 247 | + appImage: Option.getOrUndefined(config.appImagePath), | |
| 248 | + disabledByEnv: config.disableAutoUpdate, | |
| 249 | + hasUpdateFeedConfig: hasFeedConfig, | |
| 250 | + }), | |
| 251 | + ); | |
| 252 | + }); | |
| 253 | + | |
| 254 | + const resolveUpdaterErrorContext = Effect.gen(function* () { | |
| 255 | + if (yield* Ref.get(updateInstallInFlightRef)) return "install" as const; | |
| 256 | + if (yield* Ref.get(updateDownloadInFlightRef)) return "download" as const; | |
| 257 | + if (yield* Ref.get(updateCheckInFlightRef)) return "check" as const; | |
| 258 | + return (yield* Ref.get(updateStateRef)).errorContext; | |
| 259 | + }); | |
| 260 | + | |
| 261 | + const activeUpdateAction = Effect.gen(function* () { | |
| 262 | + if (yield* Ref.get(updateInstallInFlightRef)) return Option.some("install" as const); | |
| 263 | + if (yield* Ref.get(updateDownloadInFlightRef)) return Option.some("download" as const); | |
| 264 | + if (yield* Ref.get(updateCheckInFlightRef)) return Option.some("check" as const); | |
| 265 | + return Option.none<"check" | "download" | "install">(); | |
| 266 | + }); | |
| 267 | + | |
| 268 | + const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* ( | |
| 269 | + channel: DesktopUpdateChannel, | |
| 270 | + ) { | |
| 271 | + yield* Effect.annotateCurrentSpan({ channel }); | |
| 272 | + const allowsPrerelease = channel === "nightly"; | |
| 273 | + yield* electronUpdater.setChannel(channel); | |
| 274 | + yield* electronUpdater.setAllowPrerelease(allowsPrerelease); | |
| 275 | + yield* electronUpdater.setAllowDowngrade(allowsPrerelease); | |
| 276 | + yield* logUpdaterInfo("using update channel", { | |
| 277 | + channel, | |
| 278 | + allowPrerelease: allowsPrerelease, | |
| 279 | + allowDowngrade: allowsPrerelease, | |
| 280 | + }); | |
| 281 | + }); | |
| 282 | + | |
| 283 | + const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone)); | |
| 284 | + | |
| 285 | + const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* (reason: string) { | |
| 286 | + yield* Effect.annotateCurrentSpan({ reason }); | |
| 287 | + if (yield* Ref.get(desktopState.quitting)) return false; | |
| 288 | + if (!(yield* Ref.get(updaterConfiguredRef))) return false; | |
| 289 | + if (yield* Ref.get(updateCheckInFlightRef)) return false; | |
| 290 | + | |
| 291 | + const state = yield* Ref.get(updateStateRef); | |
| 292 | + if (state.status === "downloading" || state.status === "downloaded") { | |
| 293 | + yield* logUpdaterInfo("skipping update check while update is active", { | |
| 294 | + reason, | |
| 295 | + status: state.status, | |
| 296 | + }); | |
| 297 | + return false; | |
| 298 | + } | |
| 299 | + | |
| 300 | + yield* Ref.set(updateCheckInFlightRef, true); | |
| 301 | + const checkedAt = yield* currentIsoTimestamp; | |
| 302 | + yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt)); | |
| 303 | + yield* logUpdaterInfo("checking for updates", { reason }); | |
| 304 | + | |
| 305 | + return yield* electronUpdater.checkForUpdates.pipe( | |
| 306 | + Effect.as(true), | |
| 307 | + Effect.catch( | |
| 308 | + Effect.fn("desktop.updates.handleCheckForUpdatesFailure")(function* (error) { | |
| 309 | + const failedAt = yield* currentIsoTimestamp; | |
| 310 | + yield* updateState((current) => | |
| 311 | + reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt), | |
| 312 | + ); | |
| 313 | + yield* logUpdaterError("failed to check for updates", { message: error.message }); | |
| 314 | + return true; | |
| 315 | + }), | |
| 316 | + ), | |
| 317 | + Effect.ensuring(Ref.set(updateCheckInFlightRef, false)), | |
| 318 | + ); | |
| 319 | + }); | |
| 320 | + | |
| 321 | + const downloadAvailableUpdate = Effect.gen(function* () { | |
| 322 | + const state = yield* Ref.get(updateStateRef); | |
| 323 | + if ( | |
| 324 | + !(yield* Ref.get(updaterConfiguredRef)) || | |
| 325 | + (yield* Ref.get(updateDownloadInFlightRef)) || | |
| 326 | + state.status !== "available" | |
| 327 | + ) { | |
| 328 | + return { accepted: false, completed: false }; | |
| 329 | + } | |
| 330 | + | |
| 331 | + yield* Ref.set(updateDownloadInFlightRef, true); | |
| 332 | + return yield* Effect.gen(function* () { | |
| 333 | + yield* setState(reduceDesktopUpdateStateOnDownloadStart(state)); | |
| 334 | + yield* electronUpdater.setDisableDifferentialDownload( | |
| 335 | + isArm64HostRunningIntelBuild(environment.runtimeInfo), | |
| 336 | + ); | |
| 337 | + yield* logUpdaterInfo("downloading update"); | |
| 338 | + yield* electronUpdater.downloadUpdate; | |
| 339 | + return { accepted: true, completed: true }; | |
| 340 | + }).pipe( | |
| 341 | + Effect.catch( | |
| 342 | + Effect.fn("desktop.updates.handleDownloadFailure")(function* (error) { | |
| 343 | + yield* updateState((current) => | |
| 344 | + reduceDesktopUpdateStateOnDownloadFailure(current, error.message), | |
| 345 | + ); | |
| 346 | + yield* logUpdaterError("failed to download update", { message: error.message }); | |
| 347 | + return { accepted: true, completed: false }; | |
| 348 | + }), | |
| 349 | + ), | |
| 350 | + Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)), | |
| 351 | + ); | |
| 352 | + }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate")); | |
| 353 | + | |
| 354 | + const installDownloadedUpdate = Effect.gen(function* () { | |
| 355 | + const state = yield* Ref.get(updateStateRef); | |
| 356 | + if ( | |
| 357 | + (yield* Ref.get(desktopState.quitting)) || | |
| 358 | + !(yield* Ref.get(updaterConfiguredRef)) || | |
| 359 | + state.status !== "downloaded" | |
| 360 | + ) { | |
| 361 | + return { accepted: false, completed: false }; | |
| 362 | + } | |
| 363 | + | |
| 364 | + yield* Ref.set(desktopState.quitting, true); | |
| 365 | + yield* Ref.set(updateInstallInFlightRef, true); | |
| 366 | + | |
| 367 | + return yield* Effect.gen(function* () { | |
| 368 | + yield* backendManager.stop({ timeout: Duration.seconds(5) }); | |
| 369 | + yield* electronWindow.destroyAll; | |
| 370 | + yield* electronUpdater.quitAndInstall({ | |
| 371 | + isSilent: true, | |
| 372 | + isForceRunAfter: true, | |
| 373 | + }); | |
| 374 | + return { accepted: true, completed: false }; | |
| 375 | + }).pipe( | |
| 376 | + Effect.catch( | |
| 377 | + Effect.fn("desktop.updates.handleInstallFailure")(function* (error) { | |
| 378 | + yield* Ref.set(updateInstallInFlightRef, false); | |
| 379 | + yield* updateState((current) => | |
| 380 | + reduceDesktopUpdateStateOnInstallFailure(current, error.message), | |
| 381 | + ); | |
| 382 | + yield* Ref.set(desktopState.quitting, false); | |
| 383 | + yield* logUpdaterError("failed to install update", { message: error.message }); | |
| 384 | + return { accepted: true, completed: false }; | |
| 385 | + }), | |
| 386 | + ), | |
| 387 | + ); | |
| 388 | + }).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate")); | |
| 389 | + | |
| 390 | + const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () { | |
| 391 | + yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( | |
| 392 | + Effect.andThen(checkForUpdates("startup")), | |
| 393 | + Effect.catchCause((cause) => | |
| 394 | + logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }), | |
| 395 | + ), | |
| 396 | + Effect.forkScoped, | |
| 397 | + ); | |
| 398 | + yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe( | |
| 399 | + Effect.andThen(checkForUpdates("poll")), | |
| 400 | + Effect.forever, | |
| 401 | + Effect.catchCause((cause) => | |
| 402 | + logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }), | |
| 403 | + ), | |
| 404 | + Effect.forkScoped, | |
| 405 | + ); | |
| 406 | + }).pipe(Effect.withSpan("desktop.updates.startPollers")); | |
| 407 | + | |
| 408 | + const handleUpdateAvailable = Effect.fn("desktop.updates.handleUpdateAvailable")(function* ( | |
| 409 | + raw: unknown, | |
| 410 | + ) { | |
| 411 | + yield* Schema.decodeUnknownEffect(UpdateInfo)(raw).pipe( | |
| 412 | + Effect.flatMap( | |
| 413 | + Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) { | |
| 414 | + const state = yield* Ref.get(updateStateRef); | |
| 415 | + if (resolveDefaultDesktopUpdateChannel(info.version) !== state.channel) { | |
| 416 | + yield* logUpdaterInfo("ignoring update that does not match selected channel", { | |
| 417 | + version: info.version, | |
| 418 | + channel: state.channel, | |
| 419 | + }); | |
| 420 | + const checkedAt = yield* currentIsoTimestamp; | |
| 421 | + yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); | |
| 422 | + yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); | |
| 423 | + return; | |
| 424 | + } | |
| 425 | + | |
| 426 | + const checkedAt = yield* currentIsoTimestamp; | |
| 427 | + yield* setState( | |
| 428 | + reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt), | |
| 429 | + ); | |
| 430 | + yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); | |
| 431 | + yield* logUpdaterInfo("update available", { version: info.version }); | |
| 432 | + }), | |
| 433 | + ), | |
| 434 | + Effect.catchCause((cause) => | |
| 435 | + logUpdaterWarning("ignored malformed update-available event", { | |
| 436 | + cause: Cause.pretty(cause), | |
| 437 | + }), | |
| 438 | + ), | |
| 439 | + ); | |
| 440 | + }); | |
| 441 | + | |
| 442 | + const handleUpdateNotAvailable = Effect.gen(function* () { | |
| 443 | + const checkedAt = yield* currentIsoTimestamp; | |
| 444 | + const state = yield* Ref.get(updateStateRef); | |
| 445 | + yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); | |
| 446 | + yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); | |
| 447 | + yield* logUpdaterInfo("no updates available"); | |
| 448 | + }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable")); | |
| 449 | + | |
| 450 | + const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* ( | |
| 451 | + error: unknown, | |
| 452 | + ) { | |
| 453 | + const message = error instanceof Error ? error.message : String(error); | |
| 454 | + if (yield* Ref.get(updateInstallInFlightRef)) { | |
| 455 | + yield* Ref.set(updateInstallInFlightRef, false); | |
| 456 | + yield* Ref.set(desktopState.quitting, false); | |
| 457 | + yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message)); | |
| 458 | + yield* logUpdaterError("updater error", { message }); | |
| 459 | + return; | |
| 460 | + } | |
| 461 | + | |
| 462 | + if (!(yield* Ref.get(updateCheckInFlightRef)) && !(yield* Ref.get(updateDownloadInFlightRef))) { | |
| 463 | + const errorContext = yield* resolveUpdaterErrorContext; | |
| 464 | + const checkedAt = yield* currentIsoTimestamp; | |
| 465 | + yield* updateState((current) => ({ | |
| 466 | + ...current, | |
| 467 | + status: "error", | |
| 468 | + message, | |
| 469 | + checkedAt, | |
| 470 | + downloadPercent: null, | |
| 471 | + errorContext, | |
| 472 | + canRetry: getCanRetryFromState(current), | |
| 473 | + })); | |
| 474 | + } | |
| 475 | + | |
| 476 | + yield* logUpdaterError("updater error", { message }); | |
| 477 | + }); | |
| 478 | + | |
| 479 | + const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* ( | |
| 480 | + raw: unknown, | |
| 481 | + ) { | |
| 482 | + yield* Schema.decodeUnknownEffect(DownloadProgressInfo)(raw).pipe( | |
| 483 | + Effect.flatMap( | |
| 484 | + Effect.fn("desktop.updates.applyDownloadProgress")(function* (progress) { | |
| 485 | + const state = yield* Ref.get(updateStateRef); | |
| 486 | + const percent = Math.floor(progress.percent); | |
| 487 | + if (shouldBroadcastDownloadProgress(state, progress.percent) || state.message !== null) { | |
| 488 | + yield* setState(reduceDesktopUpdateStateOnDownloadProgress(state, progress.percent)); | |
| 489 | + } | |
| 490 | + const milestone = percent - (percent % 10); | |
| 491 | + const lastLoggedMilestone = yield* Ref.get(lastLoggedDownloadMilestoneRef); | |
| 492 | + if (milestone > lastLoggedMilestone) { | |
| 493 | + yield* Ref.set(lastLoggedDownloadMilestoneRef, milestone); | |
| 494 | + yield* logUpdaterInfo("download progress", { percent }); | |
| 495 | + } | |
| 496 | + }), | |
| 497 | + ), | |
| 498 | + Effect.catchCause((cause) => | |
| 499 | + logUpdaterWarning("ignored malformed download-progress event", { | |
| 500 | + cause: Cause.pretty(cause), | |
| 501 | + }), | |
| 502 | + ), | |
| 503 | + ); | |
| 504 | + }); | |
| 505 | + | |
| 506 | + const handleUpdateDownloaded = Effect.fn("desktop.updates.handleUpdateDownloaded")(function* ( | |
| 507 | + raw: unknown, | |
| 508 | + ) { | |
| 509 | + yield* Schema.decodeUnknownEffect(UpdateInfo)(raw).pipe( | |
| 510 | + Effect.flatMap( | |
| 511 | + Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) { | |
| 512 | + const state = yield* Ref.get(updateStateRef); | |
| 513 | + yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); | |
| 514 | + yield* logUpdaterInfo("update downloaded", { version: info.version }); | |
| 515 | + }), | |
| 516 | + ), | |
| 517 | + Effect.catchCause((cause) => | |
| 518 | + logUpdaterWarning("ignored malformed update-downloaded event", { | |
| 519 | + cause: Cause.pretty(cause), | |
| 520 | + }), | |
| 521 | + ), | |
| 522 | + ); | |
| 523 | + }); | |
| 524 | + | |
| 525 | + return DesktopUpdates.of({ | |
| 526 | + getState: Ref.get(updateStateRef), | |
| 527 | + emitState, | |
| 528 | + disabledReason: resolveDisabledReason, | |
| 529 | + configure: Effect.gen(function* () { | |
| 530 | + const context = yield* Effect.context<never>(); | |
| 531 | + const runEffect = (effect: Effect.Effect<void>) => { | |
| 532 | + void Effect.runPromiseWith(context)(effect); | |
| 533 | + }; | |
| 534 | + | |
| 535 | + const appUpdateYmlConfig = yield* readAppUpdateYml; | |
| 536 | + yield* Ref.set(appUpdateYmlConfigRef, appUpdateYmlConfig); | |
| 537 | + | |
| 538 | + if (config.mockUpdates) { | |
| 539 | + yield* electronUpdater.setFeedURL({ | |
| 540 | + provider: "generic", | |
| 541 | + url: `http://localhost:${config.mockUpdateServerPort}`, | |
| 542 | + } as ElectronUpdater.ElectronUpdaterFeedUrl); | |
| 543 | + } | |
| 544 | + | |
| 545 | + const settings = yield* desktopSettings.get; | |
| 546 | + const enabled = yield* shouldEnableAutoUpdates; | |
| 547 | + yield* setState(createBaseUpdateState(settings.updateChannel, enabled, environment)); | |
| 548 | + if (!enabled) { | |
| 549 | + return; | |
| 550 | + } | |
| 551 | + yield* Ref.set(updaterConfiguredRef, true); | |
| 552 | + | |
| 553 | + yield* electronUpdater.setAutoDownload(false); | |
| 554 | + yield* electronUpdater.setAutoInstallOnAppQuit(false); | |
| 555 | + yield* applyAutoUpdaterChannel(settings.updateChannel); | |
| 556 | + yield* electronUpdater.setDisableDifferentialDownload( | |
| 557 | + isArm64HostRunningIntelBuild(environment.runtimeInfo), | |
| 558 | + ); | |
| 559 | + | |
| 560 | + if (isArm64HostRunningIntelBuild(environment.runtimeInfo)) { | |
| 561 | + yield* logUpdaterInfo( | |
| 562 | + "Apple Silicon host detected while running Intel build; updates will switch to arm64 packages", | |
| 563 | + ); | |
| 564 | + } | |
| 565 | + | |
| 566 | + yield* electronUpdater.on("checking-for-update", () => { | |
| 567 | + runEffect( | |
| 568 | + logUpdaterInfo("looking for updates").pipe( | |
| 569 | + Effect.withSpan("desktop.updates.handleCheckingForUpdate"), | |
| 570 | + ), | |
| 571 | + ); | |
| 572 | + }); | |
| 573 | + yield* electronUpdater.on("update-available", (info: unknown) => { | |
| 574 | + runEffect(handleUpdateAvailable(info)); | |
| 575 | + }); | |
| 576 | + yield* electronUpdater.on("update-not-available", () => { | |
| 577 | + runEffect(handleUpdateNotAvailable); | |
| 578 | + }); | |
| 579 | + yield* electronUpdater.on("error", (error: unknown) => { | |
| 580 | + runEffect(handleUpdaterError(error)); | |
| 581 | + }); | |
| 582 | + yield* electronUpdater.on("download-progress", (progress: unknown) => { | |
| 583 | + runEffect(handleDownloadProgress(progress)); | |
| 584 | + }); | |
| 585 | + yield* electronUpdater.on("update-downloaded", (info: unknown) => { | |
| 586 | + runEffect(handleUpdateDownloaded(info)); | |
| 587 | + }); | |
| 588 | + | |
| 589 | + yield* startUpdatePollers; | |
| 590 | + }).pipe(Effect.withSpan("desktop.updates.configure")), | |
| 591 | + setChannel: Effect.fn("desktop.updates.setChannel")(function* ( | |
| 592 | + nextChannel: DesktopUpdateChannel, | |
| 593 | + ) { | |
| 594 | + yield* Effect.annotateCurrentSpan({ channel: nextChannel }); | |
| 595 | + const activeAction = yield* activeUpdateAction; | |
| 596 | + if (Option.isSome(activeAction)) { | |
| 597 | + return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value }); | |
| 598 | + } | |
| 599 | + | |
| 600 | + const state = yield* Ref.get(updateStateRef); | |
| 601 | + if (nextChannel === state.channel) { | |
| 602 | + return state; | |
| 603 | + } | |
| 604 | + | |
| 605 | + yield* desktopSettings | |
| 606 | + .setUpdateChannel(nextChannel) | |
| 607 | + .pipe(Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause }))); | |
| 608 | + | |
| 609 | + const enabled = yield* shouldEnableAutoUpdates; | |
| 610 | + yield* setState(createBaseUpdateState(nextChannel, enabled, environment)); | |
| 611 | + | |
| 612 | + if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) { | |
| 613 | + return yield* Ref.get(updateStateRef); | |
| 614 | + } | |
| 615 | + | |
| 616 | + yield* applyAutoUpdaterChannel(nextChannel); | |
| 617 | + const allowDowngrade = yield* electronUpdater.allowDowngrade; | |
| 618 | + yield* electronUpdater.setAllowDowngrade(true); | |
| 619 | + yield* checkForUpdates("channel-change").pipe( | |
| 620 | + Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)), | |
| 621 | + ); | |
| 622 | + return yield* Ref.get(updateStateRef); | |
| 623 | + }), | |
| 624 | + check: Effect.fn("desktop.updates.check")(function* (reason: string) { | |
| 625 | + yield* Effect.annotateCurrentSpan({ reason }); | |
| 626 | + if (!(yield* Ref.get(updaterConfiguredRef))) { | |
| 627 | + return { | |
| 628 | + checked: false, | |
| 629 | + state: yield* Ref.get(updateStateRef), | |
| 630 | + }; | |
| 631 | + } | |
| 632 | + const checked = yield* checkForUpdates(reason); | |
| 633 | + return { | |
| 634 | + checked, | |
| 635 | + state: yield* Ref.get(updateStateRef), | |
| 636 | + }; | |
| 637 | + }), | |
| 638 | + download: Effect.gen(function* () { | |
| 639 | + const result = yield* downloadAvailableUpdate; | |
| 640 | + return { | |
| 641 | + accepted: result.accepted, | |
| 642 | + completed: result.completed, | |
| 643 | + state: yield* Ref.get(updateStateRef), | |
| 644 | + }; | |
| 645 | + }).pipe(Effect.withSpan("desktop.updates.download")), | |
| 646 | + install: Effect.gen(function* () { | |
| 647 | + if (yield* Ref.get(desktopState.quitting)) { | |
| 648 | + return { | |
| 649 | + accepted: false, | |
| 650 | + completed: false, | |
| 651 | + state: yield* Ref.get(updateStateRef), | |
| 652 | + }; | |
| 653 | + } | |
| 654 | + const result = yield* installDownloadedUpdate; | |
| 655 | + return { | |
| 656 | + accepted: result.accepted, | |
| 657 | + completed: result.completed, | |
| 658 | + state: yield* Ref.get(updateStateRef), | |
| 659 | + }; | |
| 660 | + }).pipe(Effect.withSpan("desktop.updates.install")), | |
| 661 | + }); | |
| 662 | +}); | |
| 663 | + | |
| 664 | +export const layer = Layer.effect(DesktopUpdates, make); | |
| @@ -9,10 +9,3 @@ export function isNightlyDesktopVersion(version: string): boolean { · 8 unmodified lines | ||
| 9 | 9 | export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { |
| 10 | 10 | return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; |
| 11 | 11 | } |
| 12 | − | |
| 13 | −export function doesVersionMatchDesktopUpdateChannel( | |
| 14 | − version: string, | |
| 15 | − channel: DesktopUpdateChannel, | |
| 16 | −): boolean { | |
| 17 | − return resolveDefaultDesktopUpdateChannel(version) === channel; | |
| 18 | −} | |
File renamed without changes.
| @@ -4,7 +4,15 @@ import type { · 3 unmodified lines | ||
| 4 | 4 | DesktopUpdateState, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | |
| 7 | −import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState.ts"; | |
| 7 | +export function nextStatusAfterDownloadFailure( | |
| 8 | + currentState: DesktopUpdateState, | |
| 9 | +): DesktopUpdateState["status"] { | |
| 10 | + return currentState.availableVersion ? "available" : "error"; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState): boolean { | |
| 14 | + return currentState.availableVersion !== null; | |
| 15 | +} | |
| 8 | 16 | |
| 9 | 17 | export function createInitialDesktopUpdateState( |
| 10 | 18 | currentVersion: string, |
| @@ -0,0 +1,132 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, describe, it } from "@effect/vitest"; | |
| 3 | +import * as Deferred from "effect/Deferred"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 5 | +import * as Layer from "effect/Layer"; | |
| 6 | +import * as Option from "effect/Option"; | |
| 7 | + | |
| 8 | +import type * as Electron from "electron"; | |
| 9 | + | |
| 10 | +import * as ElectronApp from "../electron/ElectronApp.ts"; | |
| 11 | +import * as ElectronDialog from "../electron/ElectronDialog.ts"; | |
| 12 | +import * as ElectronMenu from "../electron/ElectronMenu.ts"; | |
| 13 | +import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts"; | |
| 14 | +import * as DesktopConfig from "../app/DesktopConfig.ts"; | |
| 15 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 16 | +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; | |
| 17 | +import * as DesktopWindow from "./DesktopWindow.ts"; | |
| 18 | + | |
| 19 | +const environmentInput = { | |
| 20 | + dirname: "/repo/apps/desktop/dist-electron", | |
| 21 | + homeDirectory: "/Users/alice", | |
| 22 | + platform: "linux", | |
| 23 | + processArch: "arm64", | |
| 24 | + appVersion: "1.2.3", | |
| 25 | + appPath: "/repo", | |
| 26 | + isPackaged: false, | |
| 27 | + resourcesPath: "/repo/resources", | |
| 28 | + runningUnderArm64Translation: false, | |
| 29 | +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; | |
| 30 | + | |
| 31 | +const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { | |
| 32 | + metadata: Effect.die("unexpected metadata read"), | |
| 33 | + name: Effect.succeed("T3 Code"), | |
| 34 | + whenReady: Effect.void, | |
| 35 | + quit: Effect.void, | |
| 36 | + exit: () => Effect.void, | |
| 37 | + relaunch: () => Effect.void, | |
| 38 | + setPath: () => Effect.void, | |
| 39 | + setName: () => Effect.void, | |
| 40 | + setAboutPanelOptions: () => Effect.void, | |
| 41 | + setAppUserModelId: () => Effect.void, | |
| 42 | + setDesktopName: () => Effect.void, | |
| 43 | + setDockIcon: () => Effect.void, | |
| 44 | + appendCommandLineSwitch: () => Effect.void, | |
| 45 | + on: () => Effect.void, | |
| 46 | +} satisfies ElectronApp.ElectronAppShape); | |
| 47 | + | |
| 48 | +const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { | |
| 49 | + pickFolder: () => Effect.succeed(Option.none()), | |
| 50 | + confirm: () => Effect.succeed(false), | |
| 51 | + showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), | |
| 52 | + showErrorBox: () => Effect.void, | |
| 53 | +} satisfies ElectronDialog.ElectronDialogShape); | |
| 54 | + | |
| 55 | +const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { | |
| 56 | + getState: Effect.die("unexpected getState"), | |
| 57 | + emitState: Effect.void, | |
| 58 | + disabledReason: Effect.succeed(Option.none()), | |
| 59 | + configure: Effect.void, | |
| 60 | + setChannel: () => Effect.die("unexpected setChannel"), | |
| 61 | + check: () => Effect.die("unexpected check"), | |
| 62 | + download: Effect.die("unexpected download"), | |
| 63 | + install: Effect.die("unexpected install"), | |
| 64 | +} satisfies DesktopUpdates.DesktopUpdatesShape); | |
| 65 | + | |
| 66 | +const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) => | |
| 67 | + Layer.succeed(DesktopWindow.DesktopWindow, { | |
| 68 | + createMain: Effect.die("unexpected createMain"), | |
| 69 | + ensureMain: Effect.die("unexpected ensureMain"), | |
| 70 | + revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), | |
| 71 | + activate: Effect.void, | |
| 72 | + createMainIfBackendReady: Effect.void, | |
| 73 | + handleBackendReady: Effect.void, | |
| 74 | + dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), | |
| 75 | + syncAppearance: Effect.void, | |
| 76 | + } satisfies DesktopWindow.DesktopWindowShape); | |
| 77 | + | |
| 78 | +const makeElectronMenuLayer = ( | |
| 79 | + applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>, | |
| 80 | +) => | |
| 81 | + Layer.succeed(ElectronMenu.ElectronMenu, { | |
| 82 | + setApplicationMenu: (template) => | |
| 83 | + Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid), | |
| 84 | + popupTemplate: () => Effect.void, | |
| 85 | + showContextMenu: () => Effect.succeed(Option.none()), | |
| 86 | + } satisfies ElectronMenu.ElectronMenuShape); | |
| 87 | + | |
| 88 | +describe("DesktopApplicationMenu", () => { | |
| 89 | + it.effect("installs the native menu and routes Settings through DesktopWindow", () => | |
| 90 | + Effect.gen(function* () { | |
| 91 | + const selectedAction = yield* Deferred.make<string>(); | |
| 92 | + const applicationMenuTemplate = | |
| 93 | + yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>(); | |
| 94 | + | |
| 95 | + yield* Effect.gen(function* () { | |
| 96 | + const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; | |
| 97 | + yield* menu.configure; | |
| 98 | + }).pipe( | |
| 99 | + Effect.provide( | |
| 100 | + DesktopApplicationMenu.layer.pipe( | |
| 101 | + Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), | |
| 102 | + Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), | |
| 103 | + Layer.provideMerge(desktopUpdatesLayer), | |
| 104 | + Layer.provideMerge(electronDialogLayer), | |
| 105 | + Layer.provideMerge(electronAppLayer), | |
| 106 | + Layer.provideMerge( | |
| 107 | + DesktopEnvironment.layer(environmentInput).pipe( | |
| 108 | + Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), | |
| 109 | + ), | |
| 110 | + ), | |
| 111 | + ), | |
| 112 | + ), | |
| 113 | + ); | |
| 114 | + | |
| 115 | + const template = yield* Deferred.await(applicationMenuTemplate); | |
| 116 | + const fileMenu = template.find((item) => item.label === "File"); | |
| 117 | + assert.isDefined(fileMenu); | |
| 118 | + if (!Array.isArray(fileMenu.submenu)) { | |
| 119 | + throw new Error("Expected File menu submenu to be an array."); | |
| 120 | + } | |
| 121 | + const settingsItem = fileMenu.submenu.find((item) => item.label === "Settings..."); | |
| 122 | + assert.isDefined(settingsItem); | |
| 123 | + const settingsClick = settingsItem.click; | |
| 124 | + if (typeof settingsClick !== "function") { | |
| 125 | + throw new Error("Expected Settings menu item to have a click handler."); | |
| 126 | + } | |
| 127 | + | |
| 128 | + settingsClick({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); | |
| 129 | + assert.equal(yield* Deferred.await(selectedAction), "open-settings"); | |
| 130 | + }), | |
| 131 | + ); | |
| 132 | +}); | |
| @@ -0,0 +1,212 @@ | ||
| 1 | +import * as Cause from "effect/Cause"; | |
| 2 | +import * as Context from "effect/Context"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Layer from "effect/Layer"; | |
| 5 | +import * as Option from "effect/Option"; | |
| 6 | + | |
| 7 | +import type * as Electron from "electron"; | |
| 8 | + | |
| 9 | +import * as DesktopObservability from "../app/DesktopObservability.ts"; | |
| 10 | +import * as ElectronApp from "../electron/ElectronApp.ts"; | |
| 11 | +import * as ElectronDialog from "../electron/ElectronDialog.ts"; | |
| 12 | +import * as ElectronMenu from "../electron/ElectronMenu.ts"; | |
| 13 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 14 | +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; | |
| 15 | +import * as DesktopWindow from "./DesktopWindow.ts"; | |
| 16 | + | |
| 17 | +export interface DesktopApplicationMenuShape { | |
| 18 | + readonly configure: Effect.Effect<void>; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export class DesktopApplicationMenu extends Context.Service< | |
| 22 | + DesktopApplicationMenu, | |
| 23 | + DesktopApplicationMenuShape | |
| 24 | +>()("t3/desktop/ApplicationMenu") {} | |
| 25 | + | |
| 26 | +type DesktopApplicationMenuRuntimeServices = | |
| 27 | + | DesktopUpdates.DesktopUpdates | |
| 28 | + | DesktopWindow.DesktopWindow | |
| 29 | + | ElectronDialog.ElectronDialog; | |
| 30 | + | |
| 31 | +const { logInfo: logUpdaterInfo } = DesktopObservability.makeComponentLogger("desktop-updater"); | |
| 32 | + | |
| 33 | +const { logError: logMenuError } = DesktopObservability.makeComponentLogger("desktop-menu"); | |
| 34 | + | |
| 35 | +const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* ( | |
| 36 | + action: string, | |
| 37 | +): Effect.fn.Return<void, DesktopWindow.DesktopWindowError, DesktopWindow.DesktopWindow> { | |
| 38 | + const desktopWindow = yield* DesktopWindow.DesktopWindow; | |
| 39 | + yield* desktopWindow.dispatchMenuAction(action); | |
| 40 | +}); | |
| 41 | + | |
| 42 | +const checkForUpdatesFromMenu: Effect.Effect< | |
| 43 | + void, | |
| 44 | + never, | |
| 45 | + DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog | |
| 46 | +> = Effect.gen(function* () { | |
| 47 | + const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 48 | + const electronDialog = yield* ElectronDialog.ElectronDialog; | |
| 49 | + const result = yield* updates.check("menu"); | |
| 50 | + const updateState = result.state; | |
| 51 | + | |
| 52 | + if (updateState.status === "up-to-date") { | |
| 53 | + yield* electronDialog.showMessageBox({ | |
| 54 | + type: "info", | |
| 55 | + title: "You're up to date!", | |
| 56 | + message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, | |
| 57 | + buttons: ["OK"], | |
| 58 | + }); | |
| 59 | + } else if (updateState.status === "error") { | |
| 60 | + yield* electronDialog.showMessageBox({ | |
| 61 | + type: "warning", | |
| 62 | + title: "Update check failed", | |
| 63 | + message: "Could not check for updates.", | |
| 64 | + detail: updateState.message ?? "An unknown error occurred. Please try again later.", | |
| 65 | + buttons: ["OK"], | |
| 66 | + }); | |
| 67 | + } | |
| 68 | +}).pipe(Effect.withSpan("desktop.menu.checkForUpdates")); | |
| 69 | + | |
| 70 | +const handleCheckForUpdatesMenuClick: Effect.Effect< | |
| 71 | + void, | |
| 72 | + DesktopWindow.DesktopWindowError, | |
| 73 | + DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog | DesktopWindow.DesktopWindow | |
| 74 | +> = Effect.gen(function* () { | |
| 75 | + const updates = yield* DesktopUpdates.DesktopUpdates; | |
| 76 | + const electronDialog = yield* ElectronDialog.ElectronDialog; | |
| 77 | + const disabledReason = yield* updates.disabledReason; | |
| 78 | + if (Option.isSome(disabledReason)) { | |
| 79 | + yield* logUpdaterInfo("manual update check requested, but updates are disabled", { | |
| 80 | + disabledReason: disabledReason.value, | |
| 81 | + }); | |
| 82 | + yield* electronDialog.showMessageBox({ | |
| 83 | + type: "info", | |
| 84 | + title: "Updates unavailable", | |
| 85 | + message: "Automatic updates are not available right now.", | |
| 86 | + detail: disabledReason.value, | |
| 87 | + buttons: ["OK"], | |
| 88 | + }); | |
| 89 | + return; | |
| 90 | + } | |
| 91 | + | |
| 92 | + const desktopWindow = yield* DesktopWindow.DesktopWindow; | |
| 93 | + yield* desktopWindow.ensureMain; | |
| 94 | + yield* checkForUpdatesFromMenu; | |
| 95 | +}).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick")); | |
| 96 | + | |
| 97 | +const make = Effect.gen(function* () { | |
| 98 | + const electronApp = yield* ElectronApp.ElectronApp; | |
| 99 | + const electronMenu = yield* ElectronMenu.ElectronMenu; | |
| 100 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 101 | + const appName = yield* electronApp.name; | |
| 102 | + const context = yield* Effect.context<DesktopApplicationMenuRuntimeServices>(); | |
| 103 | + const runPromise = Effect.runPromiseWith(context); | |
| 104 | + | |
| 105 | + const runMenuEffect = <E>( | |
| 106 | + action: string, | |
| 107 | + effect: Effect.Effect<void, E, DesktopApplicationMenuRuntimeServices>, | |
| 108 | + ) => { | |
| 109 | + void runPromise( | |
| 110 | + effect.pipe( | |
| 111 | + Effect.annotateLogs({ action }), | |
| 112 | + Effect.withSpan("desktop.menu.action"), | |
| 113 | + Effect.catchCause((cause) => | |
| 114 | + logMenuError("desktop menu action failed", { | |
| 115 | + action, | |
| 116 | + cause: Cause.pretty(cause), | |
| 117 | + }), | |
| 118 | + ), | |
| 119 | + ), | |
| 120 | + ); | |
| 121 | + }; | |
| 122 | + | |
| 123 | + const configure = Effect.gen(function* () { | |
| 124 | + const checkForUpdatesClick = () => { | |
| 125 | + runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick); | |
| 126 | + }; | |
| 127 | + const settingsClick = () => { | |
| 128 | + runMenuEffect("open-settings", dispatchMenuAction("open-settings")); | |
| 129 | + }; | |
| 130 | + const template: Electron.MenuItemConstructorOptions[] = []; | |
| 131 | + | |
| 132 | + if (environment.platform === "darwin") { | |
| 133 | + template.push({ | |
| 134 | + label: appName, | |
| 135 | + submenu: [ | |
| 136 | + { role: "about" }, | |
| 137 | + { | |
| 138 | + label: "Check for Updates...", | |
| 139 | + click: checkForUpdatesClick, | |
| 140 | + }, | |
| 141 | + { type: "separator" }, | |
| 142 | + { | |
| 143 | + label: "Settings...", | |
| 144 | + accelerator: "CmdOrCtrl+,", | |
| 145 | + click: settingsClick, | |
| 146 | + }, | |
| 147 | + { type: "separator" }, | |
| 148 | + { role: "services" }, | |
| 149 | + { type: "separator" }, | |
| 150 | + { role: "hide" }, | |
| 151 | + { role: "hideOthers" }, | |
| 152 | + { role: "unhide" }, | |
| 153 | + { type: "separator" }, | |
| 154 | + { role: "quit" }, | |
| 155 | + ], | |
| 156 | + }); | |
| 157 | + } | |
| 158 | + | |
| 159 | + template.push( | |
| 160 | + { | |
| 161 | + label: "File", | |
| 162 | + submenu: [ | |
| 163 | + ...(environment.platform === "darwin" | |
| 164 | + ? [] | |
| 165 | + : [ | |
| 166 | + { | |
| 167 | + label: "Settings...", | |
| 168 | + accelerator: "CmdOrCtrl+,", | |
| 169 | + click: settingsClick, | |
| 170 | + }, | |
| 171 | + { type: "separator" as const }, | |
| 172 | + ]), | |
| 173 | + { role: environment.platform === "darwin" ? "close" : "quit" }, | |
| 174 | + ], | |
| 175 | + }, | |
| 176 | + { role: "editMenu" }, | |
| 177 | + { | |
| 178 | + label: "View", | |
| 179 | + submenu: [ | |
| 180 | + { role: "reload" }, | |
| 181 | + { role: "forceReload" }, | |
| 182 | + { role: "toggleDevTools" }, | |
| 183 | + { type: "separator" }, | |
| 184 | + { role: "resetZoom" }, | |
| 185 | + { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, | |
| 186 | + { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, | |
| 187 | + { role: "zoomOut" }, | |
| 188 | + { type: "separator" }, | |
| 189 | + { role: "togglefullscreen" }, | |
| 190 | + ], | |
| 191 | + }, | |
| 192 | + { role: "windowMenu" }, | |
| 193 | + { | |
| 194 | + role: "help", | |
| 195 | + submenu: [ | |
| 196 | + { | |
| 197 | + label: "Check for Updates...", | |
| 198 | + click: checkForUpdatesClick, | |
| 199 | + }, | |
| 200 | + ], | |
| 201 | + }, | |
| 202 | + ); | |
| 203 | + | |
| 204 | + yield* electronMenu.setApplicationMenu(template); | |
| 205 | + }).pipe(Effect.withSpan("desktop.menu.configure")); | |
| 206 | + | |
| 207 | + return DesktopApplicationMenu.of({ | |
| 208 | + configure, | |
| 209 | + }); | |
| 210 | +}); | |
| 211 | + | |
| 212 | +export const layer = Layer.effect(DesktopApplicationMenu, make); | |
| @@ -0,0 +1,180 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, describe, it } from "@effect/vitest"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Layer from "effect/Layer"; | |
| 5 | +import * as Option from "effect/Option"; | |
| 6 | +import * as Ref from "effect/Ref"; | |
| 7 | + | |
| 8 | +import type * as Electron from "electron"; | |
| 9 | +import { vi } from "vitest"; | |
| 10 | + | |
| 11 | +import * as DesktopAssets from "../app/DesktopAssets.ts"; | |
| 12 | +import * as DesktopConfig from "../app/DesktopConfig.ts"; | |
| 13 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 14 | +import * as DesktopState from "../app/DesktopState.ts"; | |
| 15 | +import * as ElectronMenu from "../electron/ElectronMenu.ts"; | |
| 16 | +import * as ElectronShell from "../electron/ElectronShell.ts"; | |
| 17 | +import * as ElectronTheme from "../electron/ElectronTheme.ts"; | |
| 18 | +import * as ElectronWindow from "../electron/ElectronWindow.ts"; | |
| 19 | +import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; | |
| 20 | +import * as DesktopWindow from "./DesktopWindow.ts"; | |
| 21 | + | |
| 22 | +const environmentInput = { | |
| 23 | + dirname: "/repo/apps/desktop/dist-electron", | |
| 24 | + homeDirectory: "/Users/alice", | |
| 25 | + platform: "darwin", | |
| 26 | + processArch: "arm64", | |
| 27 | + appVersion: "1.2.3", | |
| 28 | + appPath: "/repo", | |
| 29 | + isPackaged: false, | |
| 30 | + resourcesPath: "/repo/resources", | |
| 31 | + runningUnderArm64Translation: false, | |
| 32 | +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; | |
| 33 | + | |
| 34 | +function makeFakeBrowserWindow() { | |
| 35 | + const webContents = { | |
| 36 | + copyImageAt: vi.fn(), | |
| 37 | + isLoadingMainFrame: vi.fn(() => false), | |
| 38 | + on: vi.fn(), | |
| 39 | + once: vi.fn(), | |
| 40 | + openDevTools: vi.fn(), | |
| 41 | + replaceMisspelling: vi.fn(), | |
| 42 | + send: vi.fn(), | |
| 43 | + setWindowOpenHandler: vi.fn(), | |
| 44 | + }; | |
| 45 | + | |
| 46 | + const window = { | |
| 47 | + focus: vi.fn(), | |
| 48 | + isDestroyed: vi.fn(() => false), | |
| 49 | + isMinimized: vi.fn(() => false), | |
| 50 | + isVisible: vi.fn(() => true), | |
| 51 | + loadURL: vi.fn(() => Promise.resolve()), | |
| 52 | + on: vi.fn(), | |
| 53 | + once: vi.fn(), | |
| 54 | + restore: vi.fn(), | |
| 55 | + setBackgroundColor: vi.fn(), | |
| 56 | + setTitle: vi.fn(), | |
| 57 | + setTitleBarOverlay: vi.fn(), | |
| 58 | + show: vi.fn(), | |
| 59 | + webContents, | |
| 60 | + }; | |
| 61 | + | |
| 62 | + return { | |
| 63 | + window: window as unknown as Electron.BrowserWindow, | |
| 64 | + loadURL: window.loadURL, | |
| 65 | + openDevTools: webContents.openDevTools, | |
| 66 | + }; | |
| 67 | +} | |
| 68 | + | |
| 69 | +const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { | |
| 70 | + iconPaths: Effect.succeed({ | |
| 71 | + ico: Option.none<string>(), | |
| 72 | + icns: Option.none<string>(), | |
| 73 | + png: Option.none<string>(), | |
| 74 | + }), | |
| 75 | + resolveResourcePath: () => Effect.succeed(Option.none<string>()), | |
| 76 | +} satisfies DesktopAssets.DesktopAssetsShape); | |
| 77 | + | |
| 78 | +const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { | |
| 79 | + getState: Effect.die("unexpected getState"), | |
| 80 | + backendConfig: Effect.succeed({ | |
| 81 | + port: 3773, | |
| 82 | + bindHost: "127.0.0.1", | |
| 83 | + httpBaseUrl: new URL("http://127.0.0.1:3773"), | |
| 84 | + tailscaleServeEnabled: false, | |
| 85 | + tailscaleServePort: 443, | |
| 86 | + }), | |
| 87 | + configureFromSettings: () => Effect.die("unexpected configureFromSettings"), | |
| 88 | + setMode: () => Effect.die("unexpected setMode"), | |
| 89 | + setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), | |
| 90 | + getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"), | |
| 91 | +} satisfies DesktopServerExposure.DesktopServerExposureShape); | |
| 92 | + | |
| 93 | +const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { | |
| 94 | + setApplicationMenu: () => Effect.void, | |
| 95 | + popupTemplate: () => Effect.void, | |
| 96 | + showContextMenu: () => Effect.succeed(Option.none()), | |
| 97 | +} satisfies ElectronMenu.ElectronMenuShape); | |
| 98 | + | |
| 99 | +const electronShellLayer = Layer.succeed(ElectronShell.ElectronShell, { | |
| 100 | + openExternal: () => Effect.succeed(true), | |
| 101 | + copyText: () => Effect.void, | |
| 102 | +} satisfies ElectronShell.ElectronShellShape); | |
| 103 | + | |
| 104 | +const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { | |
| 105 | + shouldUseDarkColors: Effect.succeed(false), | |
| 106 | + setSource: () => Effect.void, | |
| 107 | + onUpdated: () => Effect.void, | |
| 108 | +} satisfies ElectronTheme.ElectronThemeShape); | |
| 109 | + | |
| 110 | +const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( | |
| 111 | + Layer.provide( | |
| 112 | + Layer.mergeAll( | |
| 113 | + NodeServices.layer, | |
| 114 | + DesktopConfig.layerTest({ | |
| 115 | + T3CODE_PORT: "3773", | |
| 116 | + VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", | |
| 117 | + }), | |
| 118 | + ), | |
| 119 | + ), | |
| 120 | +); | |
| 121 | + | |
| 122 | +function makeTestLayer(input: { | |
| 123 | + readonly window: Electron.BrowserWindow; | |
| 124 | + readonly createCount: Ref.Ref<number>; | |
| 125 | + readonly mainWindow: Ref.Ref<Option.Option<Electron.BrowserWindow>>; | |
| 126 | +}) { | |
| 127 | + const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { | |
| 128 | + create: () => Ref.update(input.createCount, (count) => count + 1).pipe(Effect.as(input.window)), | |
| 129 | + main: Ref.get(input.mainWindow), | |
| 130 | + currentMainOrFirst: Ref.get(input.mainWindow), | |
| 131 | + focusedMainOrFirst: Ref.get(input.mainWindow), | |
| 132 | + setMain: (window) => Ref.set(input.mainWindow, Option.some(window)), | |
| 133 | + clearMain: () => Ref.set(input.mainWindow, Option.none()), | |
| 134 | + reveal: () => Effect.void, | |
| 135 | + sendAll: () => Effect.void, | |
| 136 | + destroyAll: Effect.void, | |
| 137 | + syncAllAppearance: (sync) => sync(input.window), | |
| 138 | + } satisfies ElectronWindow.ElectronWindowShape); | |
| 139 | + | |
| 140 | + return DesktopWindow.layer.pipe( | |
| 141 | + Layer.provide( | |
| 142 | + Layer.mergeAll( | |
| 143 | + desktopAssetsLayer, | |
| 144 | + desktopEnvironmentLayer, | |
| 145 | + desktopServerExposureLayer, | |
| 146 | + DesktopState.layer, | |
| 147 | + electronMenuLayer, | |
| 148 | + electronShellLayer, | |
| 149 | + electronThemeLayer, | |
| 150 | + electronWindowLayer, | |
| 151 | + ), | |
| 152 | + ), | |
| 153 | + ); | |
| 154 | +} | |
| 155 | + | |
| 156 | +describe("DesktopWindow", () => { | |
| 157 | + it.effect("does not open a development window until the backend is ready", () => | |
| 158 | + Effect.gen(function* () { | |
| 159 | + const fakeWindow = makeFakeBrowserWindow(); | |
| 160 | + const createCount = yield* Ref.make(0); | |
| 161 | + const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | |
| 162 | + const layer = makeTestLayer({ | |
| 163 | + window: fakeWindow.window, | |
| 164 | + createCount, | |
| 165 | + mainWindow, | |
| 166 | + }); | |
| 167 | + | |
| 168 | + yield* Effect.gen(function* () { | |
| 169 | + const desktopWindow = yield* DesktopWindow.DesktopWindow; | |
| 170 | + yield* desktopWindow.activate; | |
| 171 | + assert.equal(yield* Ref.get(createCount), 0); | |
| 172 | + | |
| 173 | + yield* desktopWindow.handleBackendReady; | |
| 174 | + assert.equal(yield* Ref.get(createCount), 1); | |
| 175 | + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]); | |
| 176 | + assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); | |
| 177 | + }).pipe(Effect.provide(layer)); | |
| 178 | + }), | |
| 179 | + ); | |
| 180 | +}); | |
| @@ -0,0 +1,368 @@ | ||
| 1 | +import * as Context from "effect/Context"; | |
| 2 | +import * as Data from "effect/Data"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Layer from "effect/Layer"; | |
| 5 | +import * as Option from "effect/Option"; | |
| 6 | +import * as Ref from "effect/Ref"; | |
| 7 | + | |
| 8 | +import type * as Electron from "electron"; | |
| 9 | + | |
| 10 | +import * as DesktopAssets from "../app/DesktopAssets.ts"; | |
| 11 | +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 12 | +import * as DesktopObservability from "../app/DesktopObservability.ts"; | |
| 13 | +import * as DesktopState from "../app/DesktopState.ts"; | |
| 14 | +import * as ElectronMenu from "../electron/ElectronMenu.ts"; | |
| 15 | +import * as ElectronShell from "../electron/ElectronShell.ts"; | |
| 16 | +import * as ElectronTheme from "../electron/ElectronTheme.ts"; | |
| 17 | +import * as ElectronWindow from "../electron/ElectronWindow.ts"; | |
| 18 | +import * as IpcChannels from "../ipc/channels.ts"; | |
| 19 | +import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; | |
| 20 | + | |
| 21 | +const TITLEBAR_HEIGHT = 40; | |
| 22 | +const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux | |
| 23 | +const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; | |
| 24 | +const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; | |
| 25 | + | |
| 26 | +type WindowTitleBarOptions = Pick< | |
| 27 | + Electron.BrowserWindowConstructorOptions, | |
| 28 | + "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" | |
| 29 | +>; | |
| 30 | + | |
| 31 | +type DesktopWindowRuntimeServices = | |
| 32 | + | DesktopEnvironment.DesktopEnvironment | |
| 33 | + | DesktopAssets.DesktopAssets | |
| 34 | + | DesktopServerExposure.DesktopServerExposure | |
| 35 | + | DesktopState.DesktopState | |
| 36 | + | ElectronMenu.ElectronMenu | |
| 37 | + | ElectronShell.ElectronShell | |
| 38 | + | ElectronTheme.ElectronTheme | |
| 39 | + | ElectronWindow.ElectronWindow; | |
| 40 | + | |
| 41 | +export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( | |
| 42 | + "DesktopWindowDevServerUrlMissingError", | |
| 43 | +)<{}> { | |
| 44 | + override get message() { | |
| 45 | + return "VITE_DEV_SERVER_URL is required in desktop development."; | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +export type DesktopWindowError = | |
| 50 | + | DesktopWindowDevServerUrlMissingError | |
| 51 | + | ElectronWindow.ElectronWindowCreateError; | |
| 52 | + | |
| 53 | +export interface DesktopWindowShape { | |
| 54 | + readonly createMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>; | |
| 55 | + readonly ensureMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>; | |
| 56 | + readonly revealOrCreateMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>; | |
| 57 | + readonly activate: Effect.Effect<void, DesktopWindowError>; | |
| 58 | + readonly createMainIfBackendReady: Effect.Effect<void, DesktopWindowError>; | |
| 59 | + readonly handleBackendReady: Effect.Effect<void, DesktopWindowError>; | |
| 60 | + readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>; | |
| 61 | + readonly syncAppearance: Effect.Effect<void>; | |
| 62 | +} | |
| 63 | + | |
| 64 | +export class DesktopWindow extends Context.Service<DesktopWindow, DesktopWindowShape>()( | |
| 65 | + "t3/desktop/Window", | |
| 66 | +) {} | |
| 67 | + | |
| 68 | +const { logInfo: logWindowInfo, logWarning: logWindowWarning } = | |
| 69 | + DesktopObservability.makeComponentLogger("desktop-window"); | |
| 70 | + | |
| 71 | +function resolveDesktopDevServerUrl( | |
| 72 | + environment: DesktopEnvironment.DesktopEnvironmentShape, | |
| 73 | +): Effect.Effect<string, DesktopWindowDevServerUrlMissingError> { | |
| 74 | + return Option.match(environment.devServerUrl, { | |
| 75 | + onNone: () => Effect.fail(new DesktopWindowDevServerUrlMissingError()), | |
| 76 | + onSome: (url) => Effect.succeed(url.href), | |
| 77 | + }); | |
| 78 | +} | |
| 79 | + | |
| 80 | +function getIconOption( | |
| 81 | + iconPaths: DesktopAssets.DesktopIconPaths, | |
| 82 | +): { icon: string } | Record<string, never> { | |
| 83 | + if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle | |
| 84 | + const ext = process.platform === "win32" ? "ico" : "png"; | |
| 85 | + return Option.match(iconPaths[ext], { | |
| 86 | + onNone: () => ({}), | |
| 87 | + onSome: (icon) => ({ icon }), | |
| 88 | + }); | |
| 89 | +} | |
| 90 | + | |
| 91 | +function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { | |
| 92 | + return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; | |
| 93 | +} | |
| 94 | + | |
| 95 | +function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions { | |
| 96 | + if (process.platform === "darwin") { | |
| 97 | + return { | |
| 98 | + titleBarStyle: "hiddenInset", | |
| 99 | + trafficLightPosition: { x: 16, y: 18 }, | |
| 100 | + }; | |
| 101 | + } | |
| 102 | + | |
| 103 | + return { | |
| 104 | + titleBarStyle: "hidden", | |
| 105 | + titleBarOverlay: { | |
| 106 | + color: TITLEBAR_COLOR, | |
| 107 | + height: TITLEBAR_HEIGHT, | |
| 108 | + symbolColor: shouldUseDarkColors ? TITLEBAR_DARK_SYMBOL_COLOR : TITLEBAR_LIGHT_SYMBOL_COLOR, | |
| 109 | + }, | |
| 110 | + }; | |
| 111 | +} | |
| 112 | + | |
| 113 | +function syncWindowAppearance( | |
| 114 | + window: Electron.BrowserWindow, | |
| 115 | + shouldUseDarkColors: boolean, | |
| 116 | +): Effect.Effect<void> { | |
| 117 | + return Effect.sync(() => { | |
| 118 | + if (window.isDestroyed()) { | |
| 119 | + return; | |
| 120 | + } | |
| 121 | + | |
| 122 | + window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); | |
| 123 | + const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors); | |
| 124 | + if (typeof titleBarOverlay === "object") { | |
| 125 | + window.setTitleBarOverlay(titleBarOverlay); | |
| 126 | + } | |
| 127 | + }); | |
| 128 | +} | |
| 129 | + | |
| 130 | +type RevealSubscription = (listener: () => void) => void; | |
| 131 | + | |
| 132 | +function bindFirstRevealTrigger( | |
| 133 | + subscribers: readonly RevealSubscription[], | |
| 134 | + reveal: () => void, | |
| 135 | +): void { | |
| 136 | + let revealed = false; | |
| 137 | + const fire = () => { | |
| 138 | + if (revealed) return; | |
| 139 | + revealed = true; | |
| 140 | + reveal(); | |
| 141 | + }; | |
| 142 | + for (const subscribe of subscribers) { | |
| 143 | + subscribe(fire); | |
| 144 | + } | |
| 145 | +} | |
| 146 | + | |
| 147 | +const make = Effect.gen(function* () { | |
| 148 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 149 | + const assets = yield* DesktopAssets.DesktopAssets; | |
| 150 | + const electronMenu = yield* ElectronMenu.ElectronMenu; | |
| 151 | + const electronShell = yield* ElectronShell.ElectronShell; | |
| 152 | + const electronTheme = yield* ElectronTheme.ElectronTheme; | |
| 153 | + const electronWindow = yield* ElectronWindow.ElectronWindow; | |
| 154 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 155 | + const state = yield* DesktopState.DesktopState; | |
| 156 | + const context = yield* Effect.context<DesktopWindowRuntimeServices>(); | |
| 157 | + const runPromise = Effect.runPromiseWith(context); | |
| 158 | + | |
| 159 | + const createWindow = Effect.fn("desktop.window.createWindow")(function* ( | |
| 160 | + backendHttpUrl: URL, | |
| 161 | + ): Effect.fn.Return<Electron.BrowserWindow, DesktopWindowError> { | |
| 162 | + const iconPaths = yield* assets.iconPaths; | |
| 163 | + const iconOption = getIconOption(iconPaths); | |
| 164 | + const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; | |
| 165 | + const window = yield* electronWindow.create({ | |
| 166 | + width: 1100, | |
| 167 | + height: 780, | |
| 168 | + minWidth: 840, | |
| 169 | + minHeight: 620, | |
| 170 | + show: false, | |
| 171 | + autoHideMenuBar: true, | |
| 172 | + backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), | |
| 173 | + ...iconOption, | |
| 174 | + title: environment.displayName, | |
| 175 | + ...getWindowTitleBarOptions(shouldUseDarkColors), | |
| 176 | + webPreferences: { | |
| 177 | + preload: environment.preloadPath, | |
| 178 | + contextIsolation: true, | |
| 179 | + nodeIntegration: false, | |
| 180 | + sandbox: true, | |
| 181 | + }, | |
| 182 | + }); | |
| 183 | + | |
| 184 | + window.webContents.on("context-menu", (event, params) => { | |
| 185 | + event.preventDefault(); | |
| 186 | + | |
| 187 | + const menuTemplate: Electron.MenuItemConstructorOptions[] = []; | |
| 188 | + | |
| 189 | + if (params.misspelledWord) { | |
| 190 | + for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { | |
| 191 | + menuTemplate.push({ | |
| 192 | + label: suggestion, | |
| 193 | + click: () => window.webContents.replaceMisspelling(suggestion), | |
| 194 | + }); | |
| 195 | + } | |
| 196 | + if (params.dictionarySuggestions.length === 0) { | |
| 197 | + menuTemplate.push({ label: "No suggestions", enabled: false }); | |
| 198 | + } | |
| 199 | + menuTemplate.push({ type: "separator" }); | |
| 200 | + } | |
| 201 | + | |
| 202 | + if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) { | |
| 203 | + menuTemplate.push( | |
| 204 | + { | |
| 205 | + label: "Copy Link", | |
| 206 | + click: () => { | |
| 207 | + void runPromise(electronShell.copyText(params.linkURL)); | |
| 208 | + }, | |
| 209 | + }, | |
| 210 | + { type: "separator" }, | |
| 211 | + ); | |
| 212 | + } | |
| 213 | + | |
| 214 | + if (params.mediaType === "image") { | |
| 215 | + menuTemplate.push({ | |
| 216 | + label: "Copy Image", | |
| 217 | + click: () => window.webContents.copyImageAt(params.x, params.y), | |
| 218 | + }); | |
| 219 | + menuTemplate.push({ type: "separator" }); | |
| 220 | + } | |
| 221 | + | |
| 222 | + menuTemplate.push( | |
| 223 | + { role: "cut", enabled: params.editFlags.canCut }, | |
| 224 | + { role: "copy", enabled: params.editFlags.canCopy }, | |
| 225 | + { role: "paste", enabled: params.editFlags.canPaste }, | |
| 226 | + { role: "selectAll", enabled: params.editFlags.canSelectAll }, | |
| 227 | + ); | |
| 228 | + | |
| 229 | + void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate })); | |
| 230 | + }); | |
| 231 | + | |
| 232 | + window.webContents.setWindowOpenHandler(({ url }) => { | |
| 233 | + if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) { | |
| 234 | + void runPromise(electronShell.openExternal(url)); | |
| 235 | + } | |
| 236 | + return { action: "deny" }; | |
| 237 | + }); | |
| 238 | + | |
| 239 | + window.on("page-title-updated", (event) => { | |
| 240 | + event.preventDefault(); | |
| 241 | + window.setTitle(environment.displayName); | |
| 242 | + }); | |
| 243 | + window.webContents.on("did-finish-load", () => { | |
| 244 | + window.setTitle(environment.displayName); | |
| 245 | + }); | |
| 246 | + window.webContents.on( | |
| 247 | + "did-fail-load", | |
| 248 | + (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { | |
| 249 | + if (!isMainFrame) { | |
| 250 | + return; | |
| 251 | + } | |
| 252 | + void runPromise( | |
| 253 | + logWindowWarning("main window failed to load", { | |
| 254 | + errorCode, | |
| 255 | + errorDescription, | |
| 256 | + url: validatedURL, | |
| 257 | + }), | |
| 258 | + ); | |
| 259 | + }, | |
| 260 | + ); | |
| 261 | + window.webContents.on("render-process-gone", (_event, details) => { | |
| 262 | + void runPromise( | |
| 263 | + logWindowWarning("main window render process gone", { | |
| 264 | + reason: details.reason, | |
| 265 | + exitCode: details.exitCode, | |
| 266 | + }), | |
| 267 | + ); | |
| 268 | + }); | |
| 269 | + | |
| 270 | + const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; | |
| 271 | + if (process.platform === "linux") { | |
| 272 | + revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); | |
| 273 | + } | |
| 274 | + bindFirstRevealTrigger(revealSubscribers, () => { | |
| 275 | + void runPromise(electronWindow.reveal(window)); | |
| 276 | + }); | |
| 277 | + | |
| 278 | + if (environment.isDevelopment) { | |
| 279 | + const devServerUrl = yield* resolveDesktopDevServerUrl(environment); | |
| 280 | + void window.loadURL(devServerUrl); | |
| 281 | + window.webContents.openDevTools({ mode: "detach" }); | |
| 282 | + } else { | |
| 283 | + void window.loadURL(backendHttpUrl.href); | |
| 284 | + } | |
| 285 | + | |
| 286 | + window.on("closed", () => { | |
| 287 | + void runPromise(electronWindow.clearMain(Option.some(window))); | |
| 288 | + }); | |
| 289 | + | |
| 290 | + return window; | |
| 291 | + }); | |
| 292 | + | |
| 293 | + const createMain = Effect.gen(function* () { | |
| 294 | + const backendConfig = yield* serverExposure.backendConfig; | |
| 295 | + const window = yield* createWindow(backendConfig.httpBaseUrl); | |
| 296 | + yield* electronWindow.setMain(window); | |
| 297 | + yield* logWindowInfo("main window created"); | |
| 298 | + return window; | |
| 299 | + }).pipe(Effect.withSpan("desktop.window.createMain")); | |
| 300 | + | |
| 301 | + const ensureMain = Effect.gen(function* () { | |
| 302 | + const existingWindow = yield* electronWindow.currentMainOrFirst; | |
| 303 | + if (Option.isSome(existingWindow)) { | |
| 304 | + return existingWindow.value; | |
| 305 | + } | |
| 306 | + return yield* createMain; | |
| 307 | + }).pipe(Effect.withSpan("desktop.window.ensureMain")); | |
| 308 | + | |
| 309 | + const revealOrCreateMain = Effect.gen(function* () { | |
| 310 | + const window = yield* ensureMain; | |
| 311 | + yield* electronWindow.reveal(window); | |
| 312 | + return window; | |
| 313 | + }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); | |
| 314 | + | |
| 315 | + const createMainIfBackendReady = Effect.gen(function* () { | |
| 316 | + const backendReady = yield* Ref.get(state.backendReady); | |
| 317 | + if (!backendReady) return; | |
| 318 | + const existingWindow = yield* electronWindow.currentMainOrFirst; | |
| 319 | + if (Option.isSome(existingWindow)) return; | |
| 320 | + yield* createMain; | |
| 321 | + }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); | |
| 322 | + | |
| 323 | + return DesktopWindow.of({ | |
| 324 | + createMain, | |
| 325 | + ensureMain, | |
| 326 | + revealOrCreateMain, | |
| 327 | + activate: Effect.gen(function* () { | |
| 328 | + const existingWindow = yield* electronWindow.currentMainOrFirst; | |
| 329 | + if (Option.isSome(existingWindow)) { | |
| 330 | + yield* electronWindow.reveal(existingWindow.value); | |
| 331 | + } else { | |
| 332 | + yield* createMainIfBackendReady; | |
| 333 | + } | |
| 334 | + }).pipe(Effect.withSpan("desktop.window.activate")), | |
| 335 | + createMainIfBackendReady, | |
| 336 | + handleBackendReady: Effect.gen(function* () { | |
| 337 | + yield* Ref.set(state.backendReady, true); | |
| 338 | + yield* logWindowInfo("backend ready", { source: "http" }); | |
| 339 | + yield* createMainIfBackendReady; | |
| 340 | + }).pipe(Effect.withSpan("desktop.window.handleBackendReady")), | |
| 341 | + dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { | |
| 342 | + yield* Effect.annotateCurrentSpan({ action }); | |
| 343 | + const existingWindow = yield* electronWindow.focusedMainOrFirst; | |
| 344 | + const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* createMain; | |
| 345 | + | |
| 346 | + const send = () => { | |
| 347 | + if (targetWindow.isDestroyed()) return; | |
| 348 | + targetWindow.webContents.send(IpcChannels.MENU_ACTION_CHANNEL, action); | |
| 349 | + void runPromise(electronWindow.reveal(targetWindow)); | |
| 350 | + }; | |
| 351 | + | |
| 352 | + if (targetWindow.webContents.isLoadingMainFrame()) { | |
| 353 | + targetWindow.webContents.once("did-finish-load", send); | |
| 354 | + return; | |
| 355 | + } | |
| 356 | + | |
| 357 | + send(); | |
| 358 | + }), | |
| 359 | + syncAppearance: Effect.gen(function* () { | |
| 360 | + const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; | |
| 361 | + yield* electronWindow.syncAllAppearance((window) => | |
| 362 | + syncWindowAppearance(window, shouldUseDarkColors), | |
| 363 | + ); | |
| 364 | + }).pipe(Effect.withSpan("desktop.window.syncAppearance")), | |
| 365 | + }); | |
| 366 | +}); | |
| 367 | + | |
| 368 | +export const layer = Layer.effect(DesktopWindow, make); | |
| @@ -1,74 +0,0 @@ | ||
| 1 | −import { EventEmitter } from "node:events"; | |
| 2 | − | |
| 3 | −import { describe, expect, it, vi } from "vitest"; | |
| 4 | − | |
| 5 | −import { bindFirstRevealTrigger } from "./windowReveal.ts"; | |
| 6 | − | |
| 7 | −describe("bindFirstRevealTrigger", () => { | |
| 8 | − it("reveals when the first trigger fires", () => { | |
| 9 | − const window = new EventEmitter(); | |
| 10 | − const webContents = new EventEmitter(); | |
| 11 | − const reveal = vi.fn(); | |
| 12 | − | |
| 13 | − bindFirstRevealTrigger( | |
| 14 | − [ | |
| 15 | − (fire) => window.once("ready-to-show", fire), | |
| 16 | − (fire) => webContents.once("did-finish-load", fire), | |
| 17 | − ], | |
| 18 | − reveal, | |
| 19 | − ); | |
| 20 | − | |
| 21 | − window.emit("ready-to-show"); | |
| 22 | − | |
| 23 | − expect(reveal).toHaveBeenCalledTimes(1); | |
| 24 | − }); | |
| 25 | − | |
| 26 | − it("reveals when only the fallback trigger fires (Wayland deadlock case)", () => { | |
| 27 | − const window = new EventEmitter(); | |
| 28 | − const webContents = new EventEmitter(); | |
| 29 | − const reveal = vi.fn(); | |
| 30 | − | |
| 31 | − bindFirstRevealTrigger( | |
| 32 | − [ | |
| 33 | − (fire) => window.once("ready-to-show", fire), | |
| 34 | − (fire) => webContents.once("did-finish-load", fire), | |
| 35 | − ], | |
| 36 | − reveal, | |
| 37 | − ); | |
| 38 | − | |
| 39 | − webContents.emit("did-finish-load"); | |
| 40 | − | |
| 41 | − expect(reveal).toHaveBeenCalledTimes(1); | |
| 42 | − }); | |
| 43 | − | |
| 44 | − it("only reveals once when multiple triggers fire", () => { | |
| 45 | − const window = new EventEmitter(); | |
| 46 | − const webContents = new EventEmitter(); | |
| 47 | − const reveal = vi.fn(); | |
| 48 | − | |
| 49 | − bindFirstRevealTrigger( | |
| 50 | − [ | |
| 51 | − (fire) => window.once("ready-to-show", fire), | |
| 52 | − (fire) => webContents.once("did-finish-load", fire), | |
| 53 | − ], | |
| 54 | − reveal, | |
| 55 | − ); | |
| 56 | − | |
| 57 | − webContents.emit("did-finish-load"); | |
| 58 | − window.emit("ready-to-show"); | |
| 59 | − | |
| 60 | − expect(reveal).toHaveBeenCalledTimes(1); | |
| 61 | − }); | |
| 62 | − | |
| 63 | − it("subscribers using `once` ignore re-emitted events after reveal", () => { | |
| 64 | − const window = new EventEmitter(); | |
| 65 | − const reveal = vi.fn(); | |
| 66 | − | |
| 67 | − bindFirstRevealTrigger([(fire) => window.once("ready-to-show", fire)], reveal); | |
| 68 | − | |
| 69 | − window.emit("ready-to-show"); | |
| 70 | − window.emit("ready-to-show"); | |
| 71 | − | |
| 72 | − expect(reveal).toHaveBeenCalledTimes(1); | |
| 73 | − }); | |
| 74 | −}); | |
| @@ -1,28 +0,0 @@ | ||
| 1 | −export type RevealSubscription = (listener: () => void) => void; | |
| 2 | − | |
| 3 | −/** | |
| 4 | − * Wire a reveal callback to fire exactly once, on whichever of the provided | |
| 5 | − * event subscribers fires first. Each subscriber is responsible for binding | |
| 6 | − * its own event source. | |
| 7 | − * | |
| 8 | − * Used by the desktop main window's first-paint reveal logic. The standard | |
| 9 | − * Electron pattern is to wait for `ready-to-show` before calling `show()`, | |
| 10 | − * but on Linux/Wayland with `show: false`, `ready-to-show` only fires after | |
| 11 | − * `show()` is called, deadlocking that pattern. Subscribing to both | |
| 12 | − * `ready-to-show` and `did-finish-load` (or any other "renderer is alive" | |
| 13 | − * signal) lets the window surface reliably across platforms. | |
| 14 | − */ | |
| 15 | −export function bindFirstRevealTrigger( | |
| 16 | − subscribers: readonly RevealSubscription[], | |
| 17 | − reveal: () => void, | |
| 18 | −): void { | |
| 19 | − let revealed = false; | |
| 20 | − const fire = () => { | |
| 21 | − if (revealed) return; | |
| 22 | − revealed = true; | |
| 23 | − reveal(); | |
| 24 | − }; | |
| 25 | − for (const subscribe of subscribers) { | |
| 26 | − subscribe(fire); | |
| 27 | − } | |
| 28 | −} | |
| @@ -3,7 +3,19 @@ · 2 unmodified lines | ||
| 3 | 3 | "compilerOptions": { |
| 4 | 4 | "composite": true, |
| 5 | 5 | "types": ["node", "electron"], |
| 6 | − "lib": ["ESNext", "DOM", "esnext.disposable"] | |
| 6 | + "lib": ["ESNext", "DOM", "esnext.disposable"], | |
| 7 | + "plugins": [ | |
| 8 | + { | |
| 9 | + "name": "@effect/language-service", | |
| 10 | + "namespaceImportPackages": ["@effect/platform-node", "effect"], | |
| 11 | + "diagnosticSeverity": { | |
| 12 | + "importFromBarrel": "error", | |
| 13 | + "anyUnknownInErrorContext": "error", | |
| 14 | + "instanceOfSchema": "warning", | |
| 15 | + "deterministicKeys": "warning" | |
| 16 | + } | |
| 17 | + } | |
| 18 | + ] | |
| 7 | 19 | }, |
| 8 | 20 | "include": ["src", "tsdown.config.ts"] |
| 9 | 21 | } |
| @@ -1,13 +1,33 @@ | ||
| 1 | 1 | import os from "node:os"; |
| 2 | 2 | |
| 3 | 3 | import { assert, expect, it } from "@effect/vitest"; |
| 4 | −import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; | |
| 4 | +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; | |
| 5 | 5 | |
| 6 | +import { | |
| 7 | + DesktopBackendBootstrap, | |
| 8 | + type DesktopBackendBootstrap as DesktopBackendBootstrapValue, | |
| 9 | +} from "@t3tools/contracts"; | |
| 6 | 10 | import { NetService } from "@t3tools/shared/Net"; |
| 7 | 11 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | 12 | import { deriveServerPaths } from "../config.ts"; |
| 9 | 13 | import { resolveServerConfig } from "./config.ts"; |
| 10 | 14 | |
| 15 | +const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); | |
| 16 | + | |
| 17 | +const makeDesktopBootstrap = ( | |
| 18 | + overrides: Partial<DesktopBackendBootstrapValue> = {}, | |
| 19 | +): DesktopBackendBootstrapValue => ({ | |
| 20 | + mode: "desktop", | |
| 21 | + noBrowser: true, | |
| 22 | + port: 4888, | |
| 23 | + t3Home: "/tmp/t3-bootstrap-home", | |
| 24 | + host: "127.0.0.1", | |
| 25 | + desktopBootstrapToken: "desktop-bootstrap-token", | |
| 26 | + tailscaleServeEnabled: false, | |
| 27 | + tailscaleServePort: 443, | |
| 28 | + ...overrides, | |
| 29 | +}); | |
| 30 | + | |
| 11 | 31 | it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 12 | 32 | const defaultObservabilityConfig = { |
| 13 | 33 | traceMinLevel: "Info", |
| @@ -21,10 +41,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 7 unmodified lines | ||
| 21 | 41 | otlpServiceName: "t3-server", |
| 22 | 42 | } as const; |
| 23 | 43 | |
| 24 | − const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) { | |
| 44 | + const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) { | |
| 25 | 45 | const fs = yield* FileSystem.FileSystem; |
| 26 | 46 | const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); |
| 27 | − yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`); | |
| 47 | + const encoded = yield* encodeDesktopBootstrap(payload); | |
| 48 | + yield* fs.writeFileString(filePath, `${encoded}\n`); | |
| 28 | 49 | const { fd } = yield* fs.open(filePath, { flag: "r" }); |
| 29 | 50 | return fd; |
| 30 | 51 | }); |
| @@ -165,13 +186,13 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 134 unmodified lines | ||
| 165 | 186 | Effect.gen(function* () { |
| 166 | 187 | const { join } = yield* Path.Path; |
| 167 | 188 | const baseDir = join(os.tmpdir(), "t3-cli-config-false-flags"); |
| 168 | − const fd = yield* openBootstrapFd({ | |
| 169 | − noBrowser: true, | |
| 170 | − autoBootstrapProjectFromCwd: true, | |
| 171 | − logWebSocketEvents: true, | |
| 172 | − tailscaleServeEnabled: false, | |
| 173 | − tailscaleServePort: 443, | |
| 174 | − }); | |
| 189 | + const fd = yield* openBootstrapFd( | |
| 190 | + makeDesktopBootstrap({ | |
| 191 | + noBrowser: true, | |
| 192 | + tailscaleServeEnabled: false, | |
| 193 | + tailscaleServePort: 443, | |
| 194 | + }), | |
| 195 | + ); | |
| 175 | 196 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); |
| 176 | 197 | |
| 177 | 198 | const resolved = yield* resolveServerConfig( |
| @@ -221,7 +242,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 43 unmodified lines | ||
| 221 | 242 | devUrl: new URL("http://127.0.0.1:4173"), |
| 222 | 243 | noBrowser: false, |
| 223 | 244 | startupPresentation: "browser", |
| 224 | − desktopBootstrapToken: undefined, | |
| 245 | + desktopBootstrapToken: "desktop-bootstrap-token", | |
| 225 | 246 | autoBootstrapProjectFromCwd: false, |
| 226 | 247 | logWebSocketEvents: false, |
| 227 | 248 | tailscaleServeEnabled: false, |
| @@ -234,21 +255,20 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 6 unmodified lines | ||
| 234 | 255 | Effect.gen(function* () { |
| 235 | 256 | const { join } = yield* Path.Path; |
| 236 | 257 | const baseDir = "/tmp/t3-bootstrap-home"; |
| 237 | − const fd = yield* openBootstrapFd({ | |
| 238 | − mode: "desktop", | |
| 239 | − port: 4888, | |
| 240 | − host: "127.0.0.2", | |
| 241 | − t3Home: baseDir, | |
| 242 | − devUrl: "http://127.0.0.1:5173", | |
| 243 | − noBrowser: true, | |
| 244 | − autoBootstrapProjectFromCwd: false, | |
| 245 | − logWebSocketEvents: true, | |
| 246 | − tailscaleServeEnabled: false, | |
| 247 | − tailscaleServePort: 443, | |
| 248 | − otlpTracesUrl: "http://localhost:4318/v1/traces", | |
| 249 | − otlpMetricsUrl: "http://localhost:4318/v1/metrics", | |
| 250 | − }); | |
| 251 | − const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); | |
| 258 | + const fd = yield* openBootstrapFd( | |
| 259 | + makeDesktopBootstrap({ | |
| 260 | + port: 4888, | |
| 261 | + host: "127.0.0.2", | |
| 262 | + t3Home: baseDir, | |
| 263 | + noBrowser: true, | |
| 264 | + desktopBootstrapToken: "desktop-token", | |
| 265 | + tailscaleServeEnabled: false, | |
| 266 | + tailscaleServePort: 443, | |
| 267 | + otlpTracesUrl: "http://localhost:4318/v1/traces", | |
| 268 | + otlpMetricsUrl: "http://localhost:4318/v1/metrics", | |
| 269 | + }), | |
| 270 | + ); | |
| 271 | + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); | |
| 252 | 272 | |
| 253 | 273 | const resolved = yield* resolveServerConfig( |
| 254 | 274 | { |
| @@ -292,17 +312,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 37 unmodified lines | ||
| 292 | 312 | baseDir, |
| 293 | 313 | ...derivedPaths, |
| 294 | 314 | host: "127.0.0.2", |
| 295 | − staticDir: undefined, | |
| 296 | − devUrl: new URL("http://127.0.0.1:5173"), | |
| 315 | + staticDir: resolved.staticDir, | |
| 316 | + devUrl: undefined, | |
| 297 | 317 | noBrowser: true, |
| 298 | 318 | startupPresentation: "browser", |
| 299 | − desktopBootstrapToken: undefined, | |
| 319 | + desktopBootstrapToken: "desktop-token", | |
| 300 | 320 | autoBootstrapProjectFromCwd: false, |
| 301 | − logWebSocketEvents: true, | |
| 321 | + logWebSocketEvents: false, | |
| 302 | 322 | tailscaleServeEnabled: false, |
| 303 | 323 | tailscaleServePort: 443, |
| 304 | 324 | }); |
| 305 | − assert.equal(join(baseDir, "dev"), resolved.stateDir); | |
| 325 | + assert.equal(join(baseDir, "userdata"), resolved.stateDir); | |
| 306 | 326 | }), |
| 307 | 327 | ); |
| 308 | 328 | |
| @@ -359,18 +379,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 50 unmodified lines | ||
| 359 | 379 | Effect.gen(function* () { |
| 360 | 380 | const { join } = yield* Path.Path; |
| 361 | 381 | const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins"); |
| 362 | − const fd = yield* openBootstrapFd({ | |
| 363 | − mode: "desktop", | |
| 364 | − port: 4888, | |
| 365 | − host: "127.0.0.2", | |
| 366 | − t3Home: "/tmp/t3-bootstrap-home", | |
| 367 | − devUrl: "http://127.0.0.1:5173", | |
| 368 | − noBrowser: false, | |
| 369 | − autoBootstrapProjectFromCwd: false, | |
| 370 | − logWebSocketEvents: false, | |
| 371 | − tailscaleServeEnabled: false, | |
| 372 | − tailscaleServePort: 443, | |
| 373 | − }); | |
| 382 | + const fd = yield* openBootstrapFd( | |
| 383 | + makeDesktopBootstrap({ | |
| 384 | + port: 4888, | |
| 385 | + host: "127.0.0.2", | |
| 386 | + t3Home: "/tmp/t3-bootstrap-home", | |
| 387 | + noBrowser: false, | |
| 388 | + desktopBootstrapToken: "desktop-token", | |
| 389 | + tailscaleServeEnabled: false, | |
| 390 | + tailscaleServePort: 443, | |
| 391 | + }), | |
| 392 | + ); | |
| 374 | 393 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); |
| 375 | 394 | |
| 376 | 395 | const resolved = yield* resolveServerConfig( |
| @@ -422,7 +441,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 45 unmodified lines | ||
| 422 | 441 | devUrl: new URL("http://127.0.0.1:4173"), |
| 423 | 442 | noBrowser: true, |
| 424 | 443 | startupPresentation: "browser", |
| 425 | − desktopBootstrapToken: undefined, | |
| 444 | + desktopBootstrapToken: "desktop-token", | |
| 426 | 445 | autoBootstrapProjectFromCwd: true, |
| 427 | 446 | logWebSocketEvents: true, |
| 428 | 447 | tailscaleServeEnabled: false, |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { NetService } from "@t3tools/shared/Net"; |
| 2 | 2 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 3 | +import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; | |
| 3 | 4 | import { |
| 4 | 5 | Config, |
| 5 | 6 | Duration, |
| @@ -26,24 +27,6 @@ import { · 20 unmodified lines | ||
| 26 | 27 | } from "../config.ts"; |
| 27 | 28 | import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; |
| 28 | 29 | |
| 29 | −export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); | |
| 30 | − | |
| 31 | −const BootstrapEnvelopeSchema = Schema.Struct({ | |
| 32 | − mode: Schema.optional(RuntimeMode), | |
| 33 | − port: Schema.optional(PortSchema), | |
| 34 | − host: Schema.optional(Schema.String), | |
| 35 | − t3Home: Schema.optional(Schema.String), | |
| 36 | − devUrl: Schema.optional(Schema.URLFromString), | |
| 37 | − noBrowser: Schema.optional(Schema.Boolean), | |
| 38 | − desktopBootstrapToken: Schema.optional(Schema.String), | |
| 39 | − autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), | |
| 40 | − logWebSocketEvents: Schema.optional(Schema.Boolean), | |
| 41 | − tailscaleServeEnabled: Schema.optional(Schema.Boolean), | |
| 42 | − tailscaleServePort: Schema.optional(PortSchema), | |
| 43 | − otlpTracesUrl: Schema.optional(Schema.String), | |
| 44 | − otlpMetricsUrl: Schema.optional(Schema.String), | |
| 45 | −}); | |
| 46 | − | |
| 47 | 30 | export const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe( |
| 48 | 31 | Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), |
| 49 | 32 | Flag.optional, |
| @@ -253,7 +236,7 @@ export const resolveServerConfig = ( · 203 unmodified lines | ||
| 253 | 236 | const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; |
| 254 | 237 | const bootstrapEnvelope = |
| 255 | 238 | bootstrapFd !== undefined |
| 256 | − ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) | |
| 239 | + ? yield* readBootstrapEnvelope(DesktopBackendBootstrap, bootstrapFd) | |
| 257 | 240 | : Option.none(); |
| 258 | 241 | const bootstrap = Option.getOrUndefined(bootstrapEnvelope); |
| 259 | 242 | |
| @@ -283,11 +266,7 @@ export const resolveServerConfig = ( · 23 unmodified lines | ||
| 283 | 266 | }, |
| 284 | 267 | ); |
| 285 | 268 | const devUrl = Option.getOrElse( |
| 286 | − resolveOptionPrecedence( | |
| 287 | − normalizedFlags.devUrl, | |
| 288 | − Option.fromUndefinedOr(env.devUrl), | |
| 289 | − Option.fromUndefinedOr(bootstrap?.devUrl), | |
| 290 | − ), | |
| 269 | + resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)), | |
| 291 | 270 | () => undefined, |
| 292 | 271 | ); |
| 293 | 272 | const baseDir = yield* resolveBaseDir( |
| @@ -327,7 +306,6 @@ export const resolveServerConfig = ( · 33 unmodified lines | ||
| 327 | 306 | isHeadlessStartup ? Option.some(false) : Option.none(), |
| 328 | 307 | normalizedFlags.autoBootstrapProjectFromCwd, |
| 329 | 308 | Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), |
| 330 | − Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd), | |
| 331 | 309 | ), |
| 332 | 310 | () => mode === "web", |
| 333 | 311 | ); |
| @@ -335,7 +313,6 @@ export const resolveServerConfig = ( · 1 unmodified line | ||
| 335 | 313 | resolveOptionPrecedence( |
| 336 | 314 | normalizedFlags.logWebSocketEvents, |
| 337 | 315 | Option.fromUndefinedOr(env.logWebSocketEvents), |
| 338 | − Option.fromUndefinedOr(bootstrap?.logWebSocketEvents), | |
| 339 | 316 | ), |
| 340 | 317 | () => Boolean(devUrl), |
| 341 | 318 | ); |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import Mime from "@effect/platform-node/Mime"; |
| 2 | +import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; | |
| 2 | 3 | import { Data, Effect, FileSystem, Option, Path } from "effect"; |
| 3 | 4 | import { cast } from "effect/Function"; |
| 4 | 5 | import { |
| @@ -18,7 +19,6 @@ import { · 13 unmodified lines | ||
| 18 | 19 | } from "./attachmentPaths.ts"; |
| 19 | 20 | import { resolveAttachmentPathById } from "./attachmentStore.ts"; |
| 20 | 21 | import { resolveStaticDir, ServerConfig } from "./config.ts"; |
| 21 | −import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; | |
| 22 | 22 | import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; |
| 23 | 23 | import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; |
| 24 | 24 | import { ServerAuth } from "./auth/Services/ServerAuth.ts"; |
| @@ -1,43 +1,8 @@ | ||
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | −import { compactTraceAttributes, normalizeModelMetricLabel } from "./Attributes.ts"; | |
| 3 | +import { normalizeModelMetricLabel } from "./Attributes.ts"; | |
| 4 | 4 | |
| 5 | 5 | describe("Attributes", () => { |
| 6 | − it("normalizes circular arrays, maps, and sets without recursing forever", () => { | |
| 7 | − const array: Array<unknown> = ["alpha"]; | |
| 8 | − array.push(array); | |
| 9 | − | |
| 10 | − const map = new Map<string, unknown>(); | |
| 11 | − map.set("self", map); | |
| 12 | − | |
| 13 | − const set = new Set<unknown>(); | |
| 14 | − set.add(set); | |
| 15 | − | |
| 16 | − assert.deepStrictEqual( | |
| 17 | − compactTraceAttributes({ | |
| 18 | − array, | |
| 19 | − map, | |
| 20 | − set, | |
| 21 | − }), | |
| 22 | − { | |
| 23 | − array: ["alpha", "[Circular]"], | |
| 24 | − map: { self: "[Circular]" }, | |
| 25 | − set: ["[Circular]"], | |
| 26 | − }, | |
| 27 | − ); | |
| 28 | − }); | |
| 29 | − | |
| 30 | − it("normalizes invalid dates without throwing", () => { | |
| 31 | − assert.deepStrictEqual( | |
| 32 | − compactTraceAttributes({ | |
| 33 | − invalidDate: new Date("not-a-real-date"), | |
| 34 | − }), | |
| 35 | − { | |
| 36 | − invalidDate: "Invalid Date", | |
| 37 | − }, | |
| 38 | − ); | |
| 39 | − }); | |
| 40 | − | |
| 41 | 6 | it("groups GPT-family models under a shared metric label", () => { |
| 42 | 7 | assert.strictEqual(normalizeModelMetricLabel("gpt-4o"), "gpt"); |
| 43 | 8 | assert.strictEqual(normalizeModelMetricLabel("gpt-5.4"), "gpt"); |
| @@ -2,88 +2,8 @@ import { Cause, Exit } from "effect"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | export type MetricAttributeValue = string; |
| 4 | 4 | export type MetricAttributes = Readonly<Record<string, MetricAttributeValue>>; |
| 5 | −export type TraceAttributes = Readonly<Record<string, unknown>>; | |
| 6 | 5 | export type ObservabilityOutcome = "success" | "failure" | "interrupt"; |
| 7 | 6 | |
| 8 | −function isPlainObject(value: unknown): value is Record<string, unknown> { | |
| 9 | − return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 10 | −} | |
| 11 | − | |
| 12 | −function markSeen(value: object, seen: WeakSet<object>): boolean { | |
| 13 | − if (seen.has(value)) { | |
| 14 | − return true; | |
| 15 | − } | |
| 16 | − seen.add(value); | |
| 17 | − return false; | |
| 18 | −} | |
| 19 | − | |
| 20 | −function normalizeJsonValue(value: unknown, seen: WeakSet<object> = new WeakSet()): unknown { | |
| 21 | − if ( | |
| 22 | − value === null || | |
| 23 | − value === undefined || | |
| 24 | − typeof value === "string" || | |
| 25 | − typeof value === "number" || | |
| 26 | − typeof value === "boolean" | |
| 27 | − ) { | |
| 28 | − return value ?? null; | |
| 29 | − } | |
| 30 | − if (typeof value === "bigint") { | |
| 31 | − return value.toString(); | |
| 32 | − } | |
| 33 | − if (value instanceof Date) { | |
| 34 | − return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString(); | |
| 35 | − } | |
| 36 | − if (value instanceof Error) { | |
| 37 | − return { | |
| 38 | − name: value.name, | |
| 39 | − message: value.message, | |
| 40 | − ...(value.stack ? { stack: value.stack } : {}), | |
| 41 | − }; | |
| 42 | − } | |
| 43 | − if (Array.isArray(value)) { | |
| 44 | − if (markSeen(value, seen)) { | |
| 45 | − return "[Circular]"; | |
| 46 | − } | |
| 47 | − return value.map((entry) => normalizeJsonValue(entry, seen)); | |
| 48 | − } | |
| 49 | − if (value instanceof Map) { | |
| 50 | − if (markSeen(value, seen)) { | |
| 51 | − return "[Circular]"; | |
| 52 | − } | |
| 53 | − return Object.fromEntries( | |
| 54 | − Array.from(value.entries(), ([key, entryValue]) => [ | |
| 55 | − String(key), | |
| 56 | − normalizeJsonValue(entryValue, seen), | |
| 57 | − ]), | |
| 58 | − ); | |
| 59 | − } | |
| 60 | − if (value instanceof Set) { | |
| 61 | − if (markSeen(value, seen)) { | |
| 62 | − return "[Circular]"; | |
| 63 | − } | |
| 64 | − return Array.from(value.values(), (entry) => normalizeJsonValue(entry, seen)); | |
| 65 | − } | |
| 66 | − if (!isPlainObject(value)) { | |
| 67 | − return String(value); | |
| 68 | − } | |
| 69 | − if (markSeen(value, seen)) { | |
| 70 | − return "[Circular]"; | |
| 71 | − } | |
| 72 | − return Object.fromEntries( | |
| 73 | − Object.entries(value).map(([key, entryValue]) => [key, normalizeJsonValue(entryValue, seen)]), | |
| 74 | − ); | |
| 75 | −} | |
| 76 | − | |
| 77 | −export function compactTraceAttributes( | |
| 78 | − attributes: Readonly<Record<string, unknown>>, | |
| 79 | −): TraceAttributes { | |
| 80 | − return Object.fromEntries( | |
| 81 | − Object.entries(attributes) | |
| 82 | − .filter(([, value]) => value !== undefined) | |
| 83 | − .map(([key, value]) => [key, normalizeJsonValue(value)]), | |
| 84 | − ); | |
| 85 | −} | |
| 86 | − | |
| 87 | 7 | export function compactMetricAttributes( |
| 88 | 8 | attributes: Readonly<Record<string, unknown>>, |
| 89 | 9 | ): MetricAttributes { |
| @@ -1,11 +1,10 @@ | ||
| 1 | +import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; | |
| 1 | 2 | import { Effect, Layer, References, Tracer } from "effect"; |
| 2 | 3 | import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; |
| 3 | 4 | |
| 4 | 5 | import { ServerConfig } from "../../config.ts"; |
| 5 | 6 | import { ServerLoggerLive } from "../../serverLogger.ts"; |
| 6 | −import { makeLocalFileTracer } from "../LocalFileTracer.ts"; | |
| 7 | 7 | import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts"; |
| 8 | −import { makeTraceSink } from "../TraceSink.ts"; | |
| 9 | 8 | |
| 10 | 9 | const otlpSerializationLayer = OtlpSerialization.layerJson; |
| 11 | 10 | |
| @@ -1,117 +0,0 @@ | ||
| 1 | −import fs from "node:fs"; | |
| 2 | −import os from "node:os"; | |
| 3 | −import path from "node:path"; | |
| 4 | − | |
| 5 | −import { assert, describe, it } from "@effect/vitest"; | |
| 6 | −import { Effect, Layer, Logger, References, Tracer } from "effect"; | |
| 7 | − | |
| 8 | −import type { EffectTraceRecord } from "./TraceRecord.ts"; | |
| 9 | −import { makeLocalFileTracer } from "./LocalFileTracer.ts"; | |
| 10 | − | |
| 11 | −const makeTestLayer = (tracePath: string) => | |
| 12 | − Layer.mergeAll( | |
| 13 | − Layer.effect( | |
| 14 | − Tracer.Tracer, | |
| 15 | − makeLocalFileTracer({ | |
| 16 | − filePath: tracePath, | |
| 17 | − maxBytes: 1024 * 1024, | |
| 18 | − maxFiles: 2, | |
| 19 | − batchWindowMs: 10_000, | |
| 20 | − }), | |
| 21 | − ), | |
| 22 | − Logger.layer([Logger.tracerLogger], { mergeWithExisting: false }), | |
| 23 | − Layer.succeed(References.MinimumLogLevel, "Info"), | |
| 24 | − ); | |
| 25 | − | |
| 26 | −const readTraceRecords = (tracePath: string): Array<EffectTraceRecord> => | |
| 27 | − fs | |
| 28 | − .readFileSync(tracePath, "utf8") | |
| 29 | − .trim() | |
| 30 | − .split("\n") | |
| 31 | − .filter((line) => line.length > 0) | |
| 32 | − .map((line) => JSON.parse(line) as EffectTraceRecord); | |
| 33 | − | |
| 34 | −describe("LocalFileTracer", () => { | |
| 35 | − it.effect("writes nested spans to disk and captures log messages as span events", () => | |
| 36 | − Effect.gen(function* () { | |
| 37 | − const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); | |
| 38 | − const tracePath = path.join(tempDir, "server.trace.ndjson"); | |
| 39 | − | |
| 40 | − try { | |
| 41 | − yield* Effect.scoped( | |
| 42 | − Effect.gen(function* () { | |
| 43 | − const program = Effect.gen(function* () { | |
| 44 | − yield* Effect.annotateCurrentSpan({ | |
| 45 | − "demo.parent": true, | |
| 46 | − }); | |
| 47 | − yield* Effect.logInfo("parent event"); | |
| 48 | − yield* Effect.gen(function* () { | |
| 49 | − yield* Effect.annotateCurrentSpan({ | |
| 50 | − "demo.child": true, | |
| 51 | − }); | |
| 52 | − yield* Effect.logInfo("child event"); | |
| 53 | − }).pipe(Effect.withSpan("child-span")); | |
| 54 | − }).pipe(Effect.withSpan("parent-span")); | |
| 55 | − | |
| 56 | − yield* program.pipe(Effect.provide(makeTestLayer(tracePath))); | |
| 57 | − }), | |
| 58 | − ); | |
| 59 | − | |
| 60 | − const records = readTraceRecords(tracePath); | |
| 61 | − assert.equal(records.length, 2); | |
| 62 | − | |
| 63 | − const parent = records.find((record) => record.name === "parent-span"); | |
| 64 | − const child = records.find((record) => record.name === "child-span"); | |
| 65 | − | |
| 66 | − assert.notEqual(parent, undefined); | |
| 67 | − assert.notEqual(child, undefined); | |
| 68 | − if (!parent || !child) { | |
| 69 | − return; | |
| 70 | − } | |
| 71 | − | |
| 72 | − assert.equal(child.parentSpanId, parent.spanId); | |
| 73 | − assert.equal(parent.attributes["demo.parent"], true); | |
| 74 | − assert.equal(child.attributes["demo.child"], true); | |
| 75 | − assert.equal( | |
| 76 | − parent.events.some((event) => event.name === "parent event"), | |
| 77 | − true, | |
| 78 | − ); | |
| 79 | − assert.equal( | |
| 80 | − child.events.some((event) => event.name === "child event"), | |
| 81 | − true, | |
| 82 | − ); | |
| 83 | − assert.equal( | |
| 84 | − child.events.some((event) => event.attributes["effect.logLevel"] === "INFO"), | |
| 85 | − true, | |
| 86 | − ); | |
| 87 | − } finally { | |
| 88 | − fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 89 | − } | |
| 90 | − }), | |
| 91 | − ); | |
| 92 | − | |
| 93 | − it.effect("serializes interrupted spans with an interrupted exit status", () => | |
| 94 | − Effect.gen(function* () { | |
| 95 | − const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); | |
| 96 | − const tracePath = path.join(tempDir, "server.trace.ndjson"); | |
| 97 | − | |
| 98 | − try { | |
| 99 | − yield* Effect.scoped( | |
| 100 | − Effect.exit( | |
| 101 | − Effect.interrupt.pipe( | |
| 102 | − Effect.withSpan("interrupt-span"), | |
| 103 | − Effect.provide(makeTestLayer(tracePath)), | |
| 104 | − ), | |
| 105 | − ), | |
| 106 | − ); | |
| 107 | − | |
| 108 | − const records = readTraceRecords(tracePath); | |
| 109 | − assert.equal(records.length, 1); | |
| 110 | − assert.equal(records[0]?.name, "interrupt-span"); | |
| 111 | − assert.equal(records[0]?.exit._tag, "Interrupted"); | |
| 112 | − } finally { | |
| 113 | − fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 114 | − } | |
| 115 | − }), | |
| 116 | − ); | |
| 117 | −}); | |
| @@ -1,112 +0,0 @@ | ||
| 1 | −import type * as Exit from "effect/Exit"; | |
| 2 | −import { Effect, Option, Tracer } from "effect"; | |
| 3 | − | |
| 4 | −import { spanToTraceRecord } from "./TraceRecord.ts"; | |
| 5 | −import type { EffectTraceRecord } from "./TraceRecord.ts"; | |
| 6 | −import { makeTraceSink, type TraceSink } from "./TraceSink.ts"; | |
| 7 | − | |
| 8 | −export interface LocalFileTracerOptions { | |
| 9 | − readonly filePath: string; | |
| 10 | − readonly maxBytes: number; | |
| 11 | − readonly maxFiles: number; | |
| 12 | − readonly batchWindowMs: number; | |
| 13 | − readonly delegate?: Tracer.Tracer; | |
| 14 | − readonly sink?: TraceSink; | |
| 15 | −} | |
| 16 | − | |
| 17 | −class LocalFileSpan implements Tracer.Span { | |
| 18 | − readonly _tag = "Span"; | |
| 19 | − readonly name: string; | |
| 20 | − readonly spanId: string; | |
| 21 | − readonly traceId: string; | |
| 22 | − readonly parent: Option.Option<Tracer.AnySpan>; | |
| 23 | − readonly annotations: Tracer.Span["annotations"]; | |
| 24 | − readonly links: Array<Tracer.SpanLink>; | |
| 25 | − readonly sampled: boolean; | |
| 26 | − readonly kind: Tracer.SpanKind; | |
| 27 | − | |
| 28 | − status: Tracer.SpanStatus; | |
| 29 | − attributes: Map<string, unknown>; | |
| 30 | − events: Array<[name: string, startTime: bigint, attributes: Record<string, unknown>]>; | |
| 31 | − private readonly delegate: Tracer.Span; | |
| 32 | − private readonly push: (record: EffectTraceRecord) => void; | |
| 33 | − | |
| 34 | − constructor( | |
| 35 | − options: Parameters<Tracer.Tracer["span"]>[0], | |
| 36 | − delegate: Tracer.Span, | |
| 37 | − push: (record: EffectTraceRecord) => void, | |
| 38 | − ) { | |
| 39 | − this.delegate = delegate; | |
| 40 | − this.push = push; | |
| 41 | − this.name = delegate.name; | |
| 42 | − this.spanId = delegate.spanId; | |
| 43 | − this.traceId = delegate.traceId; | |
| 44 | − this.parent = options.parent; | |
| 45 | − this.annotations = options.annotations; | |
| 46 | − this.links = [...options.links]; | |
| 47 | − this.sampled = delegate.sampled; | |
| 48 | − this.kind = delegate.kind; | |
| 49 | − this.status = { | |
| 50 | − _tag: "Started", | |
| 51 | − startTime: options.startTime, | |
| 52 | − }; | |
| 53 | − this.attributes = new Map(); | |
| 54 | − this.events = []; | |
| 55 | − } | |
| 56 | − | |
| 57 | − end(endTime: bigint, exit: Exit.Exit<unknown, unknown>): void { | |
| 58 | − this.status = { | |
| 59 | − _tag: "Ended", | |
| 60 | − startTime: this.status.startTime, | |
| 61 | − endTime, | |
| 62 | − exit, | |
| 63 | − }; | |
| 64 | − this.delegate.end(endTime, exit); | |
| 65 | − | |
| 66 | − if (this.sampled) { | |
| 67 | − this.push(spanToTraceRecord(this)); | |
| 68 | − } | |
| 69 | − } | |
| 70 | − | |
| 71 | − attribute(key: string, value: unknown): void { | |
| 72 | − this.attributes.set(key, value); | |
| 73 | − this.delegate.attribute(key, value); | |
| 74 | − } | |
| 75 | − | |
| 76 | − event(name: string, startTime: bigint, attributes?: Record<string, unknown>): void { | |
| 77 | − const nextAttributes = attributes ?? {}; | |
| 78 | − this.events.push([name, startTime, nextAttributes]); | |
| 79 | − this.delegate.event(name, startTime, nextAttributes); | |
| 80 | − } | |
| 81 | − | |
| 82 | − addLinks(links: ReadonlyArray<Tracer.SpanLink>): void { | |
| 83 | − this.links.push(...links); | |
| 84 | − this.delegate.addLinks(links); | |
| 85 | − } | |
| 86 | −} | |
| 87 | − | |
| 88 | −export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* ( | |
| 89 | − options: LocalFileTracerOptions, | |
| 90 | −) { | |
| 91 | − const sink = | |
| 92 | − options.sink ?? | |
| 93 | − (yield* makeTraceSink({ | |
| 94 | − filePath: options.filePath, | |
| 95 | − maxBytes: options.maxBytes, | |
| 96 | − maxFiles: options.maxFiles, | |
| 97 | − batchWindowMs: options.batchWindowMs, | |
| 98 | − })); | |
| 99 | − | |
| 100 | − const delegate = | |
| 101 | − options.delegate ?? | |
| 102 | − Tracer.make({ | |
| 103 | − span: (spanOptions) => new Tracer.NativeSpan(spanOptions), | |
| 104 | − }); | |
| 105 | − | |
| 106 | − return Tracer.make({ | |
| 107 | − span(spanOptions) { | |
| 108 | − return new LocalFileSpan(spanOptions, delegate.span(spanOptions), sink.push); | |
| 109 | − }, | |
| 110 | − ...(delegate.context ? { context: delegate.context } : {}), | |
| 111 | − }); | |
| 112 | −}); | |
| @@ -1,8 +1,7 @@ | ||
| 1 | +import type { TraceRecord } from "@t3tools/shared/observability"; | |
| 1 | 2 | import { Context } from "effect"; |
| 2 | 3 | import type { Effect } from "effect"; |
| 3 | 4 | |
| 4 | −import type { TraceRecord } from "../TraceRecord.ts"; | |
| 5 | − | |
| 6 | 5 | export interface BrowserTraceCollectorShape { |
| 7 | 6 | readonly record: (records: ReadonlyArray<TraceRecord>) => Effect.Effect<void>; |
| 8 | 7 | } |
| @@ -1,152 +0,0 @@ | ||
| 1 | −import fs from "node:fs"; | |
| 2 | −import os from "node:os"; | |
| 3 | −import path from "node:path"; | |
| 4 | − | |
| 5 | −import { assert, describe, it } from "@effect/vitest"; | |
| 6 | −import { Effect } from "effect"; | |
| 7 | − | |
| 8 | −import type { TraceRecord } from "./TraceRecord.ts"; | |
| 9 | −import { makeTraceSink } from "./TraceSink.ts"; | |
| 10 | − | |
| 11 | −const makeRecord = (name: string, suffix = ""): TraceRecord => ({ | |
| 12 | − type: "effect-span", | |
| 13 | − name, | |
| 14 | − traceId: `trace-${name}-${suffix}`, | |
| 15 | − spanId: `span-${name}-${suffix}`, | |
| 16 | − sampled: true, | |
| 17 | − kind: "internal", | |
| 18 | − startTimeUnixNano: "1", | |
| 19 | − endTimeUnixNano: "2", | |
| 20 | − durationMs: 1, | |
| 21 | − attributes: { | |
| 22 | − payload: suffix, | |
| 23 | − }, | |
| 24 | − events: [], | |
| 25 | − links: [], | |
| 26 | − exit: { | |
| 27 | − _tag: "Success", | |
| 28 | − }, | |
| 29 | −}); | |
| 30 | − | |
| 31 | −describe("TraceSink", () => { | |
| 32 | − it.effect("flushes buffered records on close", () => | |
| 33 | − Effect.scoped( | |
| 34 | − Effect.gen(function* () { | |
| 35 | − const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); | |
| 36 | − const tracePath = path.join(tempDir, "server.trace.ndjson"); | |
| 37 | − | |
| 38 | − try { | |
| 39 | − const sink = yield* makeTraceSink({ | |
| 40 | − filePath: tracePath, | |
| 41 | − maxBytes: 1024, | |
| 42 | − maxFiles: 2, | |
| 43 | − batchWindowMs: 10_000, | |
| 44 | − }); | |
| 45 | − | |
| 46 | − sink.push(makeRecord("alpha")); | |
| 47 | − sink.push(makeRecord("beta")); | |
| 48 | − yield* sink.close(); | |
| 49 | − | |
| 50 | − const lines = fs | |
| 51 | − .readFileSync(tracePath, "utf8") | |
| 52 | − .trim() | |
| 53 | − .split("\n") | |
| 54 | − .map((line) => JSON.parse(line) as TraceRecord); | |
| 55 | − | |
| 56 | − assert.equal(lines.length, 2); | |
| 57 | − assert.equal(lines[0]?.name, "alpha"); | |
| 58 | − assert.equal(lines[1]?.name, "beta"); | |
| 59 | − } finally { | |
| 60 | − fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 61 | − } | |
| 62 | − }), | |
| 63 | − ), | |
| 64 | − ); | |
| 65 | − | |
| 66 | − it.effect("rotates the trace file when the configured max size is exceeded", () => | |
| 67 | − Effect.scoped( | |
| 68 | − Effect.gen(function* () { | |
| 69 | − const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); | |
| 70 | − const tracePath = path.join(tempDir, "server.trace.ndjson"); | |
| 71 | − | |
| 72 | − try { | |
| 73 | − const sink = yield* makeTraceSink({ | |
| 74 | − filePath: tracePath, | |
| 75 | − maxBytes: 180, | |
| 76 | − maxFiles: 2, | |
| 77 | − batchWindowMs: 10_000, | |
| 78 | − }); | |
| 79 | − | |
| 80 | − for (let index = 0; index < 8; index += 1) { | |
| 81 | − sink.push(makeRecord("rotate", `${index}-${"x".repeat(48)}`)); | |
| 82 | − yield* sink.flush; | |
| 83 | − } | |
| 84 | − yield* sink.close(); | |
| 85 | − | |
| 86 | − const matchingFiles = fs | |
| 87 | − .readdirSync(tempDir) | |
| 88 | − .filter( | |
| 89 | − (entry) => | |
| 90 | − entry === "server.trace.ndjson" || entry.startsWith("server.trace.ndjson."), | |
| 91 | − ) | |
| 92 | − .toSorted(); | |
| 93 | − | |
| 94 | − assert.equal( | |
| 95 | − matchingFiles.some((entry) => entry === "server.trace.ndjson.1"), | |
| 96 | − true, | |
| 97 | − ); | |
| 98 | − assert.equal( | |
| 99 | − matchingFiles.some((entry) => entry === "server.trace.ndjson.3"), | |
| 100 | − false, | |
| 101 | − ); | |
| 102 | − } finally { | |
| 103 | − fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 104 | − } | |
| 105 | − }), | |
| 106 | − ), | |
| 107 | − ); | |
| 108 | − | |
| 109 | − it.effect("drops only the invalid record when serialization fails", () => | |
| 110 | − Effect.scoped( | |
| 111 | − Effect.gen(function* () { | |
| 112 | − const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); | |
| 113 | − const tracePath = path.join(tempDir, "server.trace.ndjson"); | |
| 114 | − | |
| 115 | − try { | |
| 116 | − const sink = yield* makeTraceSink({ | |
| 117 | − filePath: tracePath, | |
| 118 | − maxBytes: 1024, | |
| 119 | − maxFiles: 2, | |
| 120 | − batchWindowMs: 10_000, | |
| 121 | − }); | |
| 122 | − | |
| 123 | − const circular: Array<unknown> = []; | |
| 124 | − circular.push(circular); | |
| 125 | − | |
| 126 | − sink.push(makeRecord("alpha")); | |
| 127 | − sink.push({ | |
| 128 | − ...makeRecord("invalid"), | |
| 129 | − attributes: { | |
| 130 | − circular, | |
| 131 | − }, | |
| 132 | − } as TraceRecord); | |
| 133 | − sink.push(makeRecord("beta")); | |
| 134 | − yield* sink.close(); | |
| 135 | − | |
| 136 | − const lines = fs | |
| 137 | − .readFileSync(tracePath, "utf8") | |
| 138 | − .trim() | |
| 139 | − .split("\n") | |
| 140 | − .map((line) => JSON.parse(line) as TraceRecord); | |
| 141 | − | |
| 142 | − assert.deepStrictEqual( | |
| 143 | − lines.map((line) => line.name), | |
| 144 | − ["alpha", "beta"], | |
| 145 | − ); | |
| 146 | − } finally { | |
| 147 | − fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 148 | − } | |
| 149 | − }), | |
| 150 | − ), | |
| 151 | − ); | |
| 152 | −}); | |
| @@ -1,68 +0,0 @@ | ||
| 1 | −import { RotatingFileSink } from "@t3tools/shared/logging"; | |
| 2 | −import { Effect } from "effect"; | |
| 3 | − | |
| 4 | −import type { TraceRecord } from "./TraceRecord.ts"; | |
| 5 | − | |
| 6 | −const FLUSH_BUFFER_THRESHOLD = 32; | |
| 7 | − | |
| 8 | −export interface TraceSinkOptions { | |
| 9 | − readonly filePath: string; | |
| 10 | − readonly maxBytes: number; | |
| 11 | − readonly maxFiles: number; | |
| 12 | − readonly batchWindowMs: number; | |
| 13 | −} | |
| 14 | − | |
| 15 | −export interface TraceSink { | |
| 16 | − readonly filePath: string; | |
| 17 | − push: (record: TraceRecord) => void; | |
| 18 | − flush: Effect.Effect<void>; | |
| 19 | − close: () => Effect.Effect<void>; | |
| 20 | −} | |
| 21 | − | |
| 22 | −export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: TraceSinkOptions) { | |
| 23 | − const sink = new RotatingFileSink({ | |
| 24 | − filePath: options.filePath, | |
| 25 | − maxBytes: options.maxBytes, | |
| 26 | − maxFiles: options.maxFiles, | |
| 27 | − }); | |
| 28 | − | |
| 29 | − let buffer: Array<string> = []; | |
| 30 | − | |
| 31 | − const flushUnsafe = () => { | |
| 32 | − if (buffer.length === 0) { | |
| 33 | − return; | |
| 34 | − } | |
| 35 | − | |
| 36 | − const chunk = buffer.join(""); | |
| 37 | − buffer = []; | |
| 38 | − | |
| 39 | − try { | |
| 40 | − sink.write(chunk); | |
| 41 | − } catch { | |
| 42 | − buffer.unshift(chunk); | |
| 43 | − } | |
| 44 | − }; | |
| 45 | − | |
| 46 | − const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false)); | |
| 47 | − | |
| 48 | − yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore)); | |
| 49 | − yield* Effect.forkScoped( | |
| 50 | − Effect.sleep(`${options.batchWindowMs} millis`).pipe(Effect.andThen(flush), Effect.forever), | |
| 51 | − ); | |
| 52 | − | |
| 53 | − return { | |
| 54 | − filePath: options.filePath, | |
| 55 | − push(record) { | |
| 56 | − try { | |
| 57 | − buffer.push(`${JSON.stringify(record)}\n`); | |
| 58 | − if (buffer.length >= FLUSH_BUFFER_THRESHOLD) { | |
| 59 | − flushUnsafe(); | |
| 60 | − } | |
| 61 | − } catch { | |
| 62 | − return; | |
| 63 | − } | |
| 64 | − }, | |
| 65 | − flush, | |
| 66 | − close: () => flush, | |
| 67 | − } satisfies TraceSink; | |
| 68 | −}); | |
| @@ -3,7 +3,7 @@ import type { · 2 unmodified lines | ||
| 3 | 3 | OrchestrationEvent, |
| 4 | 4 | OrchestrationReadModel, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | −import { Effect } from "effect"; | |
| 6 | +import { DateTime, Effect } from "effect"; | |
| 7 | 7 | |
| 8 | 8 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; |
| 9 | 9 | import { |
| @@ -17,17 +17,7 @@ import { · 7 unmodified lines | ||
| 17 | 17 | } from "./commandInvariants.ts"; |
| 18 | 18 | import { projectEvent } from "./projector.ts"; |
| 19 | 19 | |
| 20 | −const nowIso = () => new Date().toISOString(); | |
| 21 | −const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = { | |
| 22 | − eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], | |
| 23 | − aggregateKind: "thread", | |
| 24 | − aggregateId: "" as OrchestrationEvent["aggregateId"], | |
| 25 | − occurredAt: nowIso(), | |
| 26 | − commandId: null, | |
| 27 | − causationEventId: null, | |
| 28 | − correlationId: null, | |
| 29 | − metadata: {}, | |
| 30 | −}; | |
| 20 | +const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); | |
| 31 | 21 | |
| 32 | 22 | function withEventBase( |
| 33 | 23 | input: Pick<OrchestrationCommand, "commandId"> & { |
| @@ -38,12 +28,12 @@ function withEventBase( · 4 unmodified lines | ||
| 38 | 28 | }, |
| 39 | 29 | ): Omit<OrchestrationEvent, "sequence" | "type" | "payload"> { |
| 40 | 30 | return { |
| 41 | − ...defaultMetadata, | |
| 42 | 31 | eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], |
| 43 | 32 | aggregateKind: input.aggregateKind, |
| 44 | 33 | aggregateId: input.aggregateId, |
| 45 | 34 | occurredAt: input.occurredAt, |
| 46 | 35 | commandId: input.commandId, |
| 36 | + causationEventId: null, | |
| 47 | 37 | correlationId: input.commandId, |
| 48 | 38 | metadata: input.metadata ?? {}, |
| 49 | 39 | }; |
| @@ -126,7 +116,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 76 unmodified lines | ||
| 126 | 116 | command, |
| 127 | 117 | projectId: command.projectId, |
| 128 | 118 | }); |
| 129 | − const occurredAt = nowIso(); | |
| 119 | + const occurredAt = yield* currentIso; | |
| 130 | 120 | return { |
| 131 | 121 | ...withEventBase({ |
| 132 | 122 | aggregateKind: "project", |
| @@ -183,7 +173,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 50 unmodified lines | ||
| 183 | 173 | }); |
| 184 | 174 | } |
| 185 | 175 | |
| 186 | − const occurredAt = nowIso(); | |
| 176 | + const occurredAt = yield* currentIso; | |
| 187 | 177 | return { |
| 188 | 178 | ...withEventBase({ |
| 189 | 179 | aggregateKind: "project", |
| @@ -239,7 +229,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 49 unmodified lines | ||
| 239 | 229 | command, |
| 240 | 230 | threadId: command.threadId, |
| 241 | 231 | }); |
| 242 | − const occurredAt = nowIso(); | |
| 232 | + const occurredAt = yield* currentIso; | |
| 243 | 233 | return { |
| 244 | 234 | ...withEventBase({ |
| 245 | 235 | aggregateKind: "thread", |
| @@ -261,7 +251,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 15 unmodified lines | ||
| 261 | 251 | command, |
| 262 | 252 | threadId: command.threadId, |
| 263 | 253 | }); |
| 264 | − const occurredAt = nowIso(); | |
| 254 | + const occurredAt = yield* currentIso; | |
| 265 | 255 | return { |
| 266 | 256 | ...withEventBase({ |
| 267 | 257 | aggregateKind: "thread", |
| @@ -284,7 +274,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 16 unmodified lines | ||
| 284 | 274 | command, |
| 285 | 275 | threadId: command.threadId, |
| 286 | 276 | }); |
| 287 | − const occurredAt = nowIso(); | |
| 277 | + const occurredAt = yield* currentIso; | |
| 288 | 278 | return { |
| 289 | 279 | ...withEventBase({ |
| 290 | 280 | aggregateKind: "thread", |
| @@ -306,7 +296,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 15 unmodified lines | ||
| 306 | 296 | command, |
| 307 | 297 | threadId: command.threadId, |
| 308 | 298 | }); |
| 309 | − const occurredAt = nowIso(); | |
| 299 | + const occurredAt = yield* currentIso; | |
| 310 | 300 | return { |
| 311 | 301 | ...withEventBase({ |
| 312 | 302 | aggregateKind: "thread", |
| @@ -334,7 +324,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 21 unmodified lines | ||
| 334 | 324 | command, |
| 335 | 325 | threadId: command.threadId, |
| 336 | 326 | }); |
| 337 | − const occurredAt = nowIso(); | |
| 327 | + const occurredAt = yield* currentIso; | |
| 338 | 328 | return { |
| 339 | 329 | ...withEventBase({ |
| 340 | 330 | aggregateKind: "thread", |
| @@ -357,7 +347,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 16 unmodified lines | ||
| 357 | 347 | command, |
| 358 | 348 | threadId: command.threadId, |
| 359 | 349 | }); |
| 360 | − const occurredAt = nowIso(); | |
| 350 | + const occurredAt = yield* currentIso; | |
| 361 | 351 | return { |
| 362 | 352 | ...withEventBase({ |
| 363 | 353 | aggregateKind: "thread", |
| @@ -452,6 +452,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { · 451 unmodified lines | ||
| 452 | 452 | turnId: asTurnId("turn-1"), |
| 453 | 453 | itemId: asItemId("msg_1"), |
| 454 | 454 | payload: { |
| 455 | + completedAtMs: 1_778_000_000_000, | |
| 455 | 456 | threadId: "thread-1", |
| 456 | 457 | turnId: "turn-1", |
| 457 | 458 | item: { |
| @@ -494,6 +495,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { · 36 unmodified lines | ||
| 494 | 495 | turnId: asTurnId("turn-1"), |
| 495 | 496 | itemId: asItemId("plan_1"), |
| 496 | 497 | payload: { |
| 498 | + completedAtMs: 1_778_000_000_000, | |
| 497 | 499 | threadId: "thread-1", |
| 498 | 500 | turnId: "turn-1", |
| 499 | 501 | item: { |
| @@ -660,6 +662,40 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { · 160 unmodified lines | ||
| 660 | 662 | }), |
| 661 | 663 | ); |
| 662 | 664 | |
| 665 | + it.effect("maps realtime started notifications with upstream realtime session ids", () => | |
| 666 | + Effect.gen(function* () { | |
| 667 | + const { adapter, runtime } = yield* startLifecycleRuntime(); | |
| 668 | + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); | |
| 669 | + | |
| 670 | + yield* runtime.emit({ | |
| 671 | + id: asEventId("evt-realtime-started"), | |
| 672 | + kind: "notification", | |
| 673 | + provider: ProviderDriverKind.make("codex"), | |
| 674 | + threadId: asThreadId("thread-1"), | |
| 675 | + createdAt: new Date().toISOString(), | |
| 676 | + method: "thread/realtime/started", | |
| 677 | + payload: { | |
| 678 | + threadId: "thread-1", | |
| 679 | + realtimeSessionId: "realtime-session-1", | |
| 680 | + version: "v2", | |
| 681 | + }, | |
| 682 | + } satisfies ProviderEvent); | |
| 683 | + | |
| 684 | + const firstEvent = yield* Fiber.join(firstEventFiber); | |
| 685 | + | |
| 686 | + assert.equal(firstEvent._tag, "Some"); | |
| 687 | + if (firstEvent._tag !== "Some") { | |
| 688 | + return; | |
| 689 | + } | |
| 690 | + assert.equal(firstEvent.value.type, "thread.realtime.started"); | |
| 691 | + if (firstEvent.value.type !== "thread.realtime.started") { | |
| 692 | + return; | |
| 693 | + } | |
| 694 | + assert.equal(firstEvent.value.threadId, "thread-1"); | |
| 695 | + assert.equal(firstEvent.value.payload.realtimeSessionId, "realtime-session-1"); | |
| 696 | + }), | |
| 697 | + ); | |
| 698 | + | |
| 663 | 699 | it.effect("maps fatal websocket stderr notifications to runtime.error", () => |
| 664 | 700 | Effect.gen(function* () { |
| 665 | 701 | const { adapter, runtime } = yield* startLifecycleRuntime(); |
| @@ -1151,7 +1151,7 @@ function mapToRuntimeEvents( · 1150 unmodified lines | ||
| 1151 | 1151 | type: "thread.realtime.started", |
| 1152 | 1152 | ...runtimeEventBase(event, canonicalThreadId), |
| 1153 | 1153 | payload: { |
| 1154 | − realtimeSessionId: payload.sessionId ?? undefined, | |
| 1154 | + realtimeSessionId: payload.realtimeSessionId ?? undefined, | |
| 1155 | 1155 | }, |
| 1156 | 1156 | }, |
| 1157 | 1157 | ]; |
| @@ -45,6 +45,8 @@ const REASONING_EFFORT_LABELS: Record<CodexSchema.V2ModelListResponse__Reasoning · 44 unmodified lines | ||
| 45 | 45 | function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["account"]) { |
| 46 | 46 | if (!account) return undefined; |
| 47 | 47 | if (account.type === "apiKey") return "OpenAI API Key"; |
| 48 | + if (account.type === "amazonBedrock") return "Amazon Bedrock"; | |
| 49 | + if (account.type !== "chatgpt") return undefined; | |
| 48 | 50 | |
| 49 | 51 | switch (account.planType) { |
| 50 | 52 | case "free": |
| @@ -70,6 +70,7 @@ export type CodexTurnStartParamsWithCollaborationMode = · 69 unmodified lines | ||
| 70 | 70 | const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); |
| 71 | 71 | |
| 72 | 72 | export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type; |
| 73 | +type CodexServiceTier = NonNullable<EffectCodexSchema.V2ThreadStartParams["serviceTier"]>; | |
| 73 | 74 | type CodexThreadItem = |
| 74 | 75 | | EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number]["items"][number] |
| 75 | 76 | | EffectCodexSchema.V2ThreadRollbackResponse["thread"]["turns"][number]["items"][number]; |
| @@ -83,7 +84,7 @@ export interface CodexSessionRuntimeOptions { · 7 unmodified lines | ||
| 83 | 84 | readonly cwd: string; |
| 84 | 85 | readonly runtimeMode: RuntimeMode; |
| 85 | 86 | readonly model?: string; |
| 86 | − readonly serviceTier?: EffectCodexSchema.V2ThreadStartParams__ServiceTier | undefined; | |
| 87 | + readonly serviceTier?: CodexServiceTier | undefined; | |
| 87 | 88 | readonly resumeCursor?: CodexResumeCursor; |
| 88 | 89 | } |
| 89 | 90 | |
| @@ -94,7 +95,7 @@ export interface CodexSessionRuntimeSendTurnInput { · 4 unmodified lines | ||
| 94 | 95 | readonly url: string; |
| 95 | 96 | }>; |
| 96 | 97 | readonly model?: string; |
| 97 | − readonly serviceTier?: EffectCodexSchema.V2TurnStartParams__ServiceTier | undefined; | |
| 98 | + readonly serviceTier?: CodexServiceTier | undefined; | |
| 98 | 99 | readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort | undefined; |
| 99 | 100 | readonly interactionMode?: ProviderInteractionMode; |
| 100 | 101 | } |
| @@ -268,7 +269,7 @@ function buildThreadStartParams(input: { · 167 unmodified lines | ||
| 268 | 269 | readonly cwd: string; |
| 269 | 270 | readonly runtimeMode: RuntimeMode; |
| 270 | 271 | readonly model: string | undefined; |
| 271 | − readonly serviceTier: EffectCodexSchema.V2ThreadStartParams__ServiceTier | undefined; | |
| 272 | + readonly serviceTier: CodexServiceTier | undefined; | |
| 272 | 273 | }): EffectCodexSchema.V2ThreadStartParams { |
| 273 | 274 | const config = runtimeModeToThreadConfig(input.runtimeMode); |
| 274 | 275 | return { |
| @@ -331,7 +332,7 @@ export function buildTurnStartParams(input: { · 56 unmodified lines | ||
| 331 | 332 | readonly url: string; |
| 332 | 333 | }>; |
| 333 | 334 | readonly model?: string; |
| 334 | − readonly serviceTier?: EffectCodexSchema.V2TurnStartParams__ServiceTier; | |
| 335 | + readonly serviceTier?: CodexServiceTier; | |
| 335 | 336 | readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; |
| 336 | 337 | readonly interactionMode?: ProviderInteractionMode; |
| 337 | 338 | }): Effect.Effect< |
| @@ -417,7 +418,7 @@ export const openCodexThread = (input: { · 79 unmodified lines | ||
| 417 | 418 | readonly runtimeMode: RuntimeMode; |
| 418 | 419 | readonly cwd: string; |
| 419 | 420 | readonly requestedModel: string | undefined; |
| 420 | − readonly serviceTier: EffectCodexSchema.V2ThreadStartParams__ServiceTier | undefined; | |
| 421 | + readonly serviceTier: CodexServiceTier | undefined; | |
| 421 | 422 | readonly resumeThreadId: string | undefined; |
| 422 | 423 | }): Effect.Effect<CodexThreadOpenResponse, CodexErrors.CodexAppServerError> => { |
| 423 | 424 | const resumeThreadId = input.resumeThreadId; |
| @@ -113,6 +113,7 @@ async function waitForFileContent(filePath: string, attempts = 40) { · 112 unmodified lines | ||
| 113 | 113 | // tests assumed. |
| 114 | 114 | const makeResolveCursorSettings = Effect.gen(function* () { |
| 115 | 115 | const serverSettings = yield* ServerSettingsService; |
| 116 | + // @effect-diagnostics effect/returnEffectInGen:off | |
| 116 | 117 | return serverSettings.getSettings.pipe( |
| 117 | 118 | Effect.map((snapshot) => snapshot.providers.cursor), |
| 118 | 119 | Effect.orDie, |
| @@ -193,9 +193,7 @@ const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({ · 192 unmodified lines | ||
| 193 | 193 | |
| 194 | 194 | const OpenCodeAdapterTestLayer = Layer.effect( |
| 195 | 195 | OpenCodeAdapter, |
| 196 | − Effect.gen(function* () { | |
| 197 | − return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings); | |
| 198 | − }), | |
| 196 | + makeOpenCodeAdapter(openCodeAdapterTestSettings), | |
| 199 | 197 | ).pipe( |
| 200 | 198 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), |
| 201 | 199 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| @@ -390,14 +388,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { · 188 unmodified lines | ||
| 390 | 388 | ); |
| 391 | 389 | |
| 392 | 390 | it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { |
| 393 | − const customInstanceId = ProviderInstanceId.make("opencode_zen"); | |
| 391 | + const instanceId = ProviderInstanceId.make("opencode_zen"); | |
| 394 | 392 | const adapterLayer = Layer.effect( |
| 395 | 393 | OpenCodeAdapter, |
| 396 | − Effect.gen(function* () { | |
| 397 | − return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { | |
| 398 | − instanceId: customInstanceId, | |
| 399 | − }); | |
| 400 | − }), | |
| 394 | + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), | |
| 401 | 395 | ).pipe( |
| 402 | 396 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), |
| 403 | 397 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| @@ -441,14 +435,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { · 37 unmodified lines | ||
| 441 | 435 | }); |
| 442 | 436 | |
| 443 | 437 | it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => { |
| 444 | − const customInstanceId = ProviderInstanceId.make("opencode_zen"); | |
| 438 | + const instanceId = ProviderInstanceId.make("opencode_zen"); | |
| 445 | 439 | const adapterLayer = Layer.effect( |
| 446 | 440 | OpenCodeAdapter, |
| 447 | − Effect.gen(function* () { | |
| 448 | − return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { | |
| 449 | − instanceId: customInstanceId, | |
| 450 | − }); | |
| 451 | − }), | |
| 441 | + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), | |
| 452 | 442 | ).pipe( |
| 453 | 443 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), |
| 454 | 444 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| @@ -487,14 +477,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { · 32 unmodified lines | ||
| 487 | 477 | }); |
| 488 | 478 | |
| 489 | 479 | it.effect("rejects sendTurn model selections for another instance id", () => { |
| 490 | − const customInstanceId = ProviderInstanceId.make("opencode_zen"); | |
| 480 | + const instanceId = ProviderInstanceId.make("opencode_zen"); | |
| 491 | 481 | const adapterLayer = Layer.effect( |
| 492 | 482 | OpenCodeAdapter, |
| 493 | − Effect.gen(function* () { | |
| 494 | − return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { | |
| 495 | − instanceId: customInstanceId, | |
| 496 | − }); | |
| 497 | − }), | |
| 483 | + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), | |
| 498 | 484 | ).pipe( |
| 499 | 485 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), |
| 500 | 486 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| @@ -634,10 +620,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { · 133 unmodified lines | ||
| 634 | 620 | |
| 635 | 621 | const adapterLayer = Layer.effect( |
| 636 | 622 | OpenCodeAdapter, |
| 637 | − Effect.gen(function* () { | |
| 638 | − return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { | |
| 639 | − nativeEventLogger, | |
| 640 | − }); | |
| 623 | + makeOpenCodeAdapter(openCodeAdapterTestSettings, { | |
| 624 | + nativeEventLogger, | |
| 641 | 625 | }), |
| 642 | 626 | ).pipe( |
| 643 | 627 | Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), |
| @@ -368,6 +368,26 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T · 367 unmodified lines | ||
| 368 | 368 | }), |
| 369 | 369 | ); |
| 370 | 370 | |
| 371 | + it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () => | |
| 372 | + Effect.gen(function* () { | |
| 373 | + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => | |
| 374 | + Effect.succeed( | |
| 375 | + makeCodexProbeSnapshot({ | |
| 376 | + account: { | |
| 377 | + account: { type: "amazonBedrock" }, | |
| 378 | + requiresOpenaiAuth: false, | |
| 379 | + }, | |
| 380 | + }), | |
| 381 | + ), | |
| 382 | + ); | |
| 383 | + | |
| 384 | + assert.strictEqual(status.status, "ready"); | |
| 385 | + assert.strictEqual(status.auth.status, "authenticated"); | |
| 386 | + assert.strictEqual(status.auth.type, "amazonBedrock"); | |
| 387 | + assert.strictEqual(status.auth.label, "Amazon Bedrock"); | |
| 388 | + }), | |
| 389 | + ); | |
| 390 | + | |
| 371 | 391 | it.effect("returns unavailable when codex is missing", () => |
| 372 | 392 | Effect.gen(function* () { |
| 373 | 393 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => |
| @@ -153,13 +153,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () · 152 unmodified lines | ||
| 153 | 153 | function* (destinationPath: string) { |
| 154 | 154 | const trimmed = destinationPath.trim(); |
| 155 | 155 | if (trimmed.length === 0) { |
| 156 | − return yield* Effect.fail( | |
| 157 | − repositoryError({ | |
| 158 | − operation: "cloneRepository", | |
| 159 | − provider: "unknown", | |
| 160 | − detail: "Choose a destination path before cloning.", | |
| 161 | − }), | |
| 162 | − ); | |
| 156 | + return yield* repositoryError({ | |
| 157 | + operation: "cloneRepository", | |
| 158 | + provider: "unknown", | |
| 159 | + detail: "Choose a destination path before cloning.", | |
| 160 | + }); | |
| 163 | 161 | } |
| 164 | 162 | |
| 165 | 163 | return path.resolve(expandHomePath(trimmed, path)); |
| @@ -183,13 +181,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () · 17 unmodified lines | ||
| 183 | 181 | ), |
| 184 | 182 | ); |
| 185 | 183 | if (entries.length > 0) { |
| 186 | − return yield* Effect.fail( | |
| 187 | − repositoryError({ | |
| 188 | − operation: "cloneRepository", | |
| 189 | − provider: "unknown", | |
| 190 | − detail: "Destination path already exists and is not empty.", | |
| 191 | − }), | |
| 192 | − ); | |
| 184 | + return yield* repositoryError({ | |
| 185 | + operation: "cloneRepository", | |
| 186 | + provider: "unknown", | |
| 187 | + detail: "Destination path already exists and is not empty.", | |
| 188 | + }); | |
| 193 | 189 | } |
| 194 | 190 | } else { |
| 195 | 191 | yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true }); |
| @@ -222,13 +218,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () · 26 unmodified lines | ||
| 222 | 218 | } |
| 223 | 219 | |
| 224 | 220 | if (!remoteUrl) { |
| 225 | − return yield* Effect.fail( | |
| 226 | − repositoryError({ | |
| 227 | − operation: "cloneRepository", | |
| 228 | − provider, | |
| 229 | − detail: "Enter a repository path or clone URL before cloning.", | |
| 230 | − }), | |
| 231 | − ); | |
| 221 | + return yield* repositoryError({ | |
| 222 | + operation: "cloneRepository", | |
| 223 | + provider, | |
| 224 | + detail: "Enter a repository path or clone URL before cloning.", | |
| 225 | + }); | |
| 232 | 226 | } |
| 233 | 227 | |
| 234 | 228 | yield* git.execute({ |
| @@ -26,7 +26,8 @@ import { · 25 unmodified lines | ||
| 26 | 26 | type VcsRef, |
| 27 | 27 | } from "@t3tools/contracts"; |
| 28 | 28 | import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; |
| 29 | −import { compactTraceAttributes } from "../observability/Attributes.ts"; | |
| 29 | +import { compactTraceAttributes } from "@t3tools/shared/observability"; | |
| 30 | +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 30 | 31 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; |
| 31 | 32 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 32 | 33 | import { |
| @@ -35,7 +36,6 @@ import { · 2 unmodified lines | ||
| 35 | 36 | parseRemoteRefWithRemoteNames, |
| 36 | 37 | } from "../git/remoteRefs.ts"; |
| 37 | 38 | import { ServerConfig } from "../config.ts"; |
| 38 | −import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 39 | 39 | |
| 40 | 40 | const DEFAULT_TIMEOUT_MS = 30_000; |
1 more lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 62 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 249 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 221 lines aren’t shown here.
This diff is large: its 88 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 55 lines aren’t shown here.
This diff is large: its 157 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 55 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 311 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 41 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 117 lines aren’t shown here.
This diff is large: its 345 lines aren’t shown here.
This diff is large: its 113 lines aren’t shown here.
This diff is large: its 10522 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 88 lines aren’t shown here.
This diff is large: its 218 lines aren’t shown here.
This diff is large: its 315 lines aren’t shown here.
This diff is large: its 306 lines aren’t shown here.
This diff is large: its 24 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 59 lines aren’t shown here.
This diff is large: its 12 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.