| author | |
| committer | |
| log | aa219be713e534fa79a179e2cffdd773caa9cdc5 |
| tree | 14d6cbb98f3ba543dced0b05961ff4b3c94899e1 |
| parent | dd32f52655d08d1cbab9149ac44b0a63665779db |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: codex <codex@users.noreply.github.com>190 files changed, 14118 insertions(+), 7654 deletions(-)
.gitignore+2| ... | ... | @@ -23,3 +23,5 @@ __screenshots__/ |
| 23 | 23 | .tanstack |
| 24 | 24 | squashfs-root/ |
| 25 | 25 | .vercel |
| 26 | dist-electron/ | |
| 27 | .electron-runtime/ |
apps/desktop/.gitignore deleted-2| ... | ... | @@ -1,2 +0,0 @@ |
| 1 | dist-electron/ | |
| 2 | .electron-runtime/ |
apps/desktop/package.json+3-1| ... | ... | @@ -17,10 +17,12 @@ |
| 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:*", |
apps/desktop/src/app/DesktopApp.ts created+240| ... | ... | @@ -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 "../serverExposure/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")); |
apps/desktop/src/app/DesktopAppIdentity.test.ts created+176| ... | ... | @@ -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 | }); |
apps/desktop/src/app/DesktopAppIdentity.ts created+127| ... | ... | @@ -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); |
apps/desktop/src/app/DesktopAssets.ts created+85| ... | ... | @@ -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); |
apps/desktop/src/app/DesktopConfig.ts created+58| ... | ... | @@ -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) })); |
apps/desktop/src/app/DesktopEnvironment.test.ts created+117| ... | ... | @@ -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 | }); |
apps/desktop/src/app/DesktopEnvironment.ts created+249| ... | ... | @@ -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)); |
apps/desktop/src/app/DesktopLifecycle.ts created+233| ... | ... | @@ -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 | ); |
apps/desktop/src/app/DesktopObservability.test.ts created+162| ... | ... | @@ -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 | }); |
apps/desktop/src/app/DesktopObservability.ts created+395| ... | ... | @@ -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 | ); |
apps/desktop/src/app/DesktopState.ts created+21| ... | ... | @@ -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 | ); |
apps/desktop/src/appBranding.test.ts deleted-47| ... | ... | @@ -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 | }); |
apps/desktop/src/appBranding.ts deleted-28| ... | ... | @@ -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 | } |
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts created+195| ... | ... | @@ -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 "../serverExposure/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 | }); |
apps/desktop/src/backend/DesktopBackendConfiguration.ts created+170| ... | ... | @@ -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 "../serverExposure/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 | ); |
apps/desktop/src/backend/DesktopBackendManager.test.ts created+490| ... | ... | @@ -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 | }); |
apps/desktop/src/backend/DesktopBackendManager.ts created+596| ... | ... | @@ -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()); |
apps/desktop/src/backendPort.test.ts deleted-109| ... | ... | @@ -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 | }); |
apps/desktop/src/backendPort.ts deleted-83| ... | ... | @@ -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 | } |
apps/desktop/src/backendReadiness.test.ts deleted-114| ... | ... | @@ -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 | }); |
apps/desktop/src/backendReadiness.ts deleted-107| ... | ... | @@ -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 | } |
apps/desktop/src/backendStartupReadiness.test.ts deleted-58| ... | ... | @@ -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 | }); |
apps/desktop/src/backendStartupReadiness.ts deleted-56| ... | ... | @@ -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 | } |
apps/desktop/src/clientPersistence.test.ts deleted-250| ... | ... | @@ -1,250 +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 | sidebarThreadPreviewCount: 6, | |
| 67 | timestampFormat: "24-hour", | |
| 68 | }; | |
| 69 | ||
| 70 | const savedRegistryRecord: PersistedSavedEnvironmentRecord = { | |
| 71 | environmentId: EnvironmentId.make("environment-1"), | |
| 72 | label: "Remote environment", | |
| 73 | httpBaseUrl: "https://remote.example.com/", | |
| 74 | wsBaseUrl: "wss://remote.example.com/", | |
| 75 | createdAt: "2026-04-09T00:00:00.000Z", | |
| 76 | lastConnectedAt: "2026-04-09T01:00:00.000Z", | |
| 77 | desktopSsh: { | |
| 78 | alias: "devbox", | |
| 79 | hostname: "devbox.example.com", | |
| 80 | username: "julius", | |
| 81 | port: 22, | |
| 82 | }, | |
| 83 | }; | |
| 84 | ||
| 85 | describe("clientPersistence", () => { | |
| 86 | it("persists and reloads client settings", () => { | |
| 87 | const settingsPath = makeTempPath("client-settings.json"); | |
| 88 | ||
| 89 | writeClientSettings(settingsPath, clientSettings); | |
| 90 | ||
| 91 | expect(readClientSettings(settingsPath)).toEqual(clientSettings); | |
| 92 | }); | |
| 93 | ||
| 94 | it("persists and reloads saved environment metadata", () => { | |
| 95 | const registryPath = makeTempPath("saved-environments.json"); | |
| 96 | ||
| 97 | writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 98 | ||
| 99 | expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); | |
| 100 | }); | |
| 101 | ||
| 102 | it("persists encrypted saved environment secrets when encryption is available", () => { | |
| 103 | const registryPath = makeTempPath("saved-environments.json"); | |
| 104 | const secretStorage = makeSecretStorage(true); | |
| 105 | ||
| 106 | writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 107 | ||
| 108 | expect( | |
| 109 | writeSavedEnvironmentSecret({ | |
| 110 | registryPath, | |
| 111 | environmentId: savedRegistryRecord.environmentId, | |
| 112 | secret: "bearer-token", | |
| 113 | secretStorage, | |
| 114 | }), | |
| 115 | ).toBe(true); | |
| 116 | ||
| 117 | expect( | |
| 118 | readSavedEnvironmentSecret({ | |
| 119 | registryPath, | |
| 120 | environmentId: savedRegistryRecord.environmentId, | |
| 121 | secretStorage, | |
| 122 | }), | |
| 123 | ).toBe("bearer-token"); | |
| 124 | ||
| 125 | expect(JSON.parse(fs.readFileSync(registryPath, "utf8"))).toEqual({ | |
| 126 | records: [ | |
| 127 | { | |
| 128 | ...savedRegistryRecord, | |
| 129 | encryptedBearerToken: Buffer.from("enc:bearer-token", "utf8").toString("base64"), | |
| 130 | }, | |
| 131 | ], | |
| 132 | }); | |
| 133 | }); | |
| 134 | ||
| 135 | it("preserves existing secrets when encryption is unavailable", () => { | |
| 136 | const registryPath = makeTempPath("saved-environments.json"); | |
| 137 | const availableSecretStorage = makeSecretStorage(true); | |
| 138 | ||
| 139 | writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 140 | ||
| 141 | writeSavedEnvironmentSecret({ | |
| 142 | registryPath, | |
| 143 | environmentId: savedRegistryRecord.environmentId, | |
| 144 | secret: "bearer-token", | |
| 145 | secretStorage: availableSecretStorage, | |
| 146 | }); | |
| 147 | ||
| 148 | expect( | |
| 149 | writeSavedEnvironmentSecret({ | |
| 150 | registryPath, | |
| 151 | environmentId: savedRegistryRecord.environmentId, | |
| 152 | secret: "next-token", | |
| 153 | secretStorage: makeSecretStorage(false), | |
| 154 | }), | |
| 155 | ).toBe(false); | |
| 156 | ||
| 157 | expect( | |
| 158 | readSavedEnvironmentSecret({ | |
| 159 | registryPath, | |
| 160 | environmentId: savedRegistryRecord.environmentId, | |
| 161 | secretStorage: availableSecretStorage, | |
| 162 | }), | |
| 163 | ).toBe("bearer-token"); | |
| 164 | }); | |
| 165 | ||
| 166 | it("removes saved environment secrets", () => { | |
| 167 | const registryPath = makeTempPath("saved-environments.json"); | |
| 168 | const secretStorage = makeSecretStorage(true); | |
| 169 | ||
| 170 | writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 171 | ||
| 172 | writeSavedEnvironmentSecret({ | |
| 173 | registryPath, | |
| 174 | environmentId: savedRegistryRecord.environmentId, | |
| 175 | secret: "bearer-token", | |
| 176 | secretStorage, | |
| 177 | }); | |
| 178 | ||
| 179 | removeSavedEnvironmentSecret({ | |
| 180 | registryPath, | |
| 181 | environmentId: savedRegistryRecord.environmentId, | |
| 182 | }); | |
| 183 | ||
| 184 | expect( | |
| 185 | readSavedEnvironmentSecret({ | |
| 186 | registryPath, | |
| 187 | environmentId: savedRegistryRecord.environmentId, | |
| 188 | secretStorage, | |
| 189 | }), | |
| 190 | ).toBeNull(); | |
| 191 | }); | |
| 192 | ||
| 193 | it("treats malformed secrets documents as empty", () => { | |
| 194 | const registryPath = makeTempPath("saved-environments.json"); | |
| 195 | fs.writeFileSync(registryPath, "{}\n", "utf8"); | |
| 196 | ||
| 197 | expect( | |
| 198 | readSavedEnvironmentSecret({ | |
| 199 | registryPath, | |
| 200 | environmentId: savedRegistryRecord.environmentId, | |
| 201 | secretStorage: makeSecretStorage(true), | |
| 202 | }), | |
| 203 | ).toBeNull(); | |
| 204 | ||
| 205 | expect(() => | |
| 206 | removeSavedEnvironmentSecret({ | |
| 207 | registryPath, | |
| 208 | environmentId: savedRegistryRecord.environmentId, | |
| 209 | }), | |
| 210 | ).not.toThrow(); | |
| 211 | }); | |
| 212 | ||
| 213 | it("returns false when writing a secret without metadata", () => { | |
| 214 | const registryPath = makeTempPath("saved-environments.json"); | |
| 215 | ||
| 216 | expect( | |
| 217 | writeSavedEnvironmentSecret({ | |
| 218 | registryPath, | |
| 219 | environmentId: savedRegistryRecord.environmentId, | |
| 220 | secret: "bearer-token", | |
| 221 | secretStorage: makeSecretStorage(true), | |
| 222 | }), | |
| 223 | ).toBe(false); | |
| 224 | }); | |
| 225 | ||
| 226 | it("preserves encrypted secrets when metadata is rewritten", () => { | |
| 227 | const registryPath = makeTempPath("saved-environments.json"); | |
| 228 | const secretStorage = makeSecretStorage(true); | |
| 229 | ||
| 230 | writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 231 | ||
| 232 | writeSavedEnvironmentSecret({ | |
| 233 | registryPath, | |
| 234 | environmentId: savedRegistryRecord.environmentId, | |
| 235 | secret: "bearer-token", | |
| 236 | secretStorage, | |
| 237 | }); | |
| 238 | ||
| 239 | writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 240 | ||
| 241 | expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); | |
| 242 | expect( | |
| 243 | readSavedEnvironmentSecret({ | |
| 244 | registryPath, | |
| 245 | environmentId: savedRegistryRecord.environmentId, | |
| 246 | secretStorage, | |
| 247 | }), | |
| 248 | ).toBe("bearer-token"); | |
| 249 | }); | |
| 250 | }); |
apps/desktop/src/clientPersistence.ts deleted-238| ... | ... | @@ -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 | } |
apps/desktop/src/confirmDialog.test.ts deleted-57| ... | ... | @@ -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 | }); |
apps/desktop/src/confirmDialog.ts deleted-26| ... | ... | @@ -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 | } |
apps/desktop/src/desktopSettings.test.ts deleted-230| ... | ... | @@ -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 | }); |
apps/desktop/src/desktopSettings.ts deleted-125| ... | ... | @@ -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 | } |
apps/desktop/src/electron/ElectronApp.test.ts created+109| ... | ... | @@ -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 | }); |
apps/desktop/src/electron/ElectronApp.ts created+118| ... | ... | @@ -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); |
apps/desktop/src/electron/ElectronDialog.test.ts created+93| ... | ... | @@ -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 | }); |
apps/desktop/src/electron/ElectronDialog.ts created+84| ... | ... | @@ -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); |
apps/desktop/src/electron/ElectronMenu.test.ts created+119| ... | ... | @@ -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 | }); |
apps/desktop/src/electron/ElectronMenu.ts created+181| ... | ... | @@ -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 | }); |
apps/desktop/src/electron/ElectronProtocol.test.ts created+105| ... | ... | @@ -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 | }); |
apps/desktop/src/electron/ElectronProtocol.ts created+272| ... | ... | @@ -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); |
apps/desktop/src/electron/ElectronSafeStorage.ts created+70| ... | ... | @@ -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); |
apps/desktop/src/electron/ElectronShell.test.ts created+59| ... | ... | @@ -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 | }); |
apps/desktop/src/electron/ElectronShell.ts created+50| ... | ... | @@ -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); |
apps/desktop/src/electron/ElectronTheme.test.ts created+52| ... | ... | @@ -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 | }); |
apps/desktop/src/electron/ElectronTheme.ts created+40| ... | ... | @@ -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); |
apps/desktop/src/electron/ElectronUpdater.test.ts created+79| ... | ... | @@ -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 | }); |
apps/desktop/src/electron/ElectronUpdater.ts created+139| ... | ... | @@ -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); |
apps/desktop/src/electron/ElectronWindow.test.ts created+51| ... | ... | @@ -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 | }); |
apps/desktop/src/electron/ElectronWindow.ts created+135| ... | ... | @@ -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); |
apps/desktop/src/ipc/DesktopIpc.ts created+219| ... | ... | @@ -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 | }; |
apps/desktop/src/ipc/DesktopIpcHandlers.ts created+84| ... | ... | @@ -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")); |
apps/desktop/src/ipc/channels.ts created+35| ... | ... | @@ -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"; |
apps/desktop/src/ipc/methods/clientSettings.ts created+28| ... | ... | @@ -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 | }); |
apps/desktop/src/ipc/methods/savedEnvironments.ts created+76| ... | ... | @@ -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 | }); |
apps/desktop/src/ipc/methods/serverExposure.ts created+69| ... | ... | @@ -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 "../../serverExposure/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 | }); |
apps/desktop/src/ipc/methods/sshEnvironment.ts created+127| ... | ... | @@ -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 | }); |
apps/desktop/src/ipc/methods/updates.ts created+62| ... | ... | @@ -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 | }); |
apps/desktop/src/ipc/methods/window.ts created+135| ... | ... | @@ -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 | }); |
apps/desktop/src/main.ts+138-2287| ... | ... | @@ -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 "./serverExposure/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); |
apps/desktop/src/preload.ts+54-74| ... | ... | @@ -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) { |
| 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); |
apps/desktop/src/rotatingFileSink.test.ts deleted-74| ... | ... | @@ -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 | }); |
apps/desktop/src/runtimeArch.test.ts deleted-49| ... | ... | @@ -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 | }); |
apps/desktop/src/runtimeArch.ts deleted-39| ... | ... | @@ -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 | } |
apps/desktop/src/serverExposure.test.ts deleted-258| ... | ... | @@ -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 | }); |
apps/desktop/src/serverExposure.ts deleted-188| ... | ... | @@ -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 | } |
apps/desktop/src/serverExposure/DesktopServerExposure.test.ts created+365| ... | ... | @@ -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 | }); |
apps/desktop/src/serverExposure/DesktopServerExposure.ts created+548| ... | ... | @@ -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 | ); |
apps/desktop/src/serverExposure/tailscaleEndpointProvider.test.ts created+142| ... | ... | @@ -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 | }); |
apps/desktop/src/serverExposure/tailscaleEndpointProvider.ts created+138| ... | ... | @@ -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 | ); |
apps/desktop/src/serverListeningDetector.test.ts deleted-31| ... | ... | @@ -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 | }); |
apps/desktop/src/serverListeningDetector.ts deleted-56| ... | ... | @@ -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 | } |
apps/desktop/src/settings/DesktopAppSettings.test.ts created+284| ... | ... | @@ -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 | }); |
apps/desktop/src/settings/DesktopAppSettings.ts created+318| ... | ... | @@ -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 | ); |
apps/desktop/src/settings/DesktopClientSettings.test.ts created+185| ... | ... | @@ -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 | }); |
apps/desktop/src/settings/DesktopClientSettings.ts created+118| ... | ... | @@ -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 | ); |
apps/desktop/src/settings/DesktopSavedEnvironments.test.ts created+344| ... | ... | @@ -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 | }); |
apps/desktop/src/settings/DesktopSavedEnvironments.ts created+390| ... | ... | @@ -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 | ); |
apps/desktop/src/shell/DesktopShellEnvironment.test.ts created+232| ... | ... | @@ -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 | }); |
apps/desktop/src/shell/DesktopShellEnvironment.ts created+356| ... | ... | @@ -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 | ); |
apps/desktop/src/ssh/DesktopSshEnvironment.test.ts created+118| ... | ... | @@ -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 | }); |
apps/desktop/src/ssh/DesktopSshEnvironment.ts created+150| ... | ... | @@ -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 | ); |
apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts created+144| ... | ... | @@ -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 | }); |
apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts created+332| ... | ... | @@ -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)); |
apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts created+79| ... | ... | @@ -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 | }); |
apps/desktop/src/ssh/DesktopSshRemoteApi.ts created+124| ... | ... | @@ -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); |
apps/desktop/src/sshEnvironment.test.ts deleted-98| ... | ... | @@ -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 | }); |
apps/desktop/src/sshEnvironment.ts deleted-420| ... | ... | @@ -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 | } |
apps/desktop/src/syncShellEnvironment.test.ts deleted-289| ... | ... | @@ -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 | }); |
apps/desktop/src/syncShellEnvironment.ts deleted-109| ... | ... | @@ -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 | } |
apps/desktop/src/tailscaleEndpointProvider.test.ts deleted-122| ... | ... | @@ -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 | }); |
apps/desktop/src/tailscaleEndpointProvider.ts deleted-142| ... | ... | @@ -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 | } |
apps/desktop/src/updateChannels.test.ts deleted-41| ... | ... | @@ -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 | }); |
apps/desktop/src/updateChannels.ts deleted-18| ... | ... | @@ -1,18 +0,0 @@ |
| 1 | import type { DesktopUpdateChannel } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; | |
| 4 | ||
| 5 | export function isNightlyDesktopVersion(version: string): boolean { | |
| 6 | return NIGHTLY_VERSION_PATTERN.test(version); | |
| 7 | } | |
| 8 | ||
| 9 | export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { | |
| 10 | return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; | |
| 11 | } | |
| 12 | ||
| 13 | export function doesVersionMatchDesktopUpdateChannel( | |
| 14 | version: string, | |
| 15 | channel: DesktopUpdateChannel, | |
| 16 | ): boolean { | |
| 17 | return resolveDefaultDesktopUpdateChannel(version) === channel; | |
| 18 | } |
apps/desktop/src/updateMachine.test.ts deleted-140| ... | ... | @@ -1,140 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | createInitialDesktopUpdateState, | |
| 5 | reduceDesktopUpdateStateOnCheckFailure, | |
| 6 | reduceDesktopUpdateStateOnCheckStart, | |
| 7 | reduceDesktopUpdateStateOnDownloadComplete, | |
| 8 | reduceDesktopUpdateStateOnDownloadFailure, | |
| 9 | reduceDesktopUpdateStateOnDownloadProgress, | |
| 10 | reduceDesktopUpdateStateOnDownloadStart, | |
| 11 | reduceDesktopUpdateStateOnInstallFailure, | |
| 12 | reduceDesktopUpdateStateOnNoUpdate, | |
| 13 | reduceDesktopUpdateStateOnUpdateAvailable, | |
| 14 | } from "./updateMachine.ts"; | |
| 15 | ||
| 16 | const runtimeInfo = { | |
| 17 | hostArch: "x64", | |
| 18 | appArch: "x64", | |
| 19 | runningUnderArm64Translation: false, | |
| 20 | } as const; | |
| 21 | ||
| 22 | describe("updateMachine", () => { | |
| 23 | it("clears transient errors when a check starts", () => { | |
| 24 | const state = reduceDesktopUpdateStateOnCheckStart( | |
| 25 | { | |
| 26 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 27 | enabled: true, | |
| 28 | status: "error", | |
| 29 | message: "network", | |
| 30 | errorContext: "check", | |
| 31 | canRetry: true, | |
| 32 | }, | |
| 33 | "2026-03-04T00:00:00.000Z", | |
| 34 | ); | |
| 35 | ||
| 36 | expect(state.status).toBe("checking"); | |
| 37 | expect(state.message).toBeNull(); | |
| 38 | expect(state.errorContext).toBeNull(); | |
| 39 | expect(state.canRetry).toBe(false); | |
| 40 | }); | |
| 41 | ||
| 42 | it("records a check failure without exposing an action", () => { | |
| 43 | const state = reduceDesktopUpdateStateOnCheckFailure( | |
| 44 | { | |
| 45 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 46 | enabled: true, | |
| 47 | status: "checking", | |
| 48 | }, | |
| 49 | "network unavailable", | |
| 50 | "2026-03-04T00:00:00.000Z", | |
| 51 | ); | |
| 52 | ||
| 53 | expect(state.status).toBe("error"); | |
| 54 | expect(state.errorContext).toBe("check"); | |
| 55 | expect(state.canRetry).toBe(true); | |
| 56 | }); | |
| 57 | ||
| 58 | it("preserves available version on download failure for retry", () => { | |
| 59 | const state = reduceDesktopUpdateStateOnDownloadFailure( | |
| 60 | { | |
| 61 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 62 | enabled: true, | |
| 63 | status: "downloading", | |
| 64 | availableVersion: "1.1.0", | |
| 65 | downloadPercent: 43, | |
| 66 | }, | |
| 67 | "checksum mismatch", | |
| 68 | ); | |
| 69 | ||
| 70 | expect(state.status).toBe("available"); | |
| 71 | expect(state.availableVersion).toBe("1.1.0"); | |
| 72 | expect(state.errorContext).toBe("download"); | |
| 73 | expect(state.canRetry).toBe(true); | |
| 74 | }); | |
| 75 | ||
| 76 | it("transitions to downloaded and then preserves install retry state", () => { | |
| 77 | const downloaded = reduceDesktopUpdateStateOnDownloadComplete( | |
| 78 | { | |
| 79 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 80 | enabled: true, | |
| 81 | status: "downloading", | |
| 82 | availableVersion: "1.1.0", | |
| 83 | }, | |
| 84 | "1.1.0", | |
| 85 | ); | |
| 86 | const failedInstall = reduceDesktopUpdateStateOnInstallFailure( | |
| 87 | downloaded, | |
| 88 | "backend shutdown timed out", | |
| 89 | ); | |
| 90 | ||
| 91 | expect(downloaded.status).toBe("downloaded"); | |
| 92 | expect(downloaded.downloadedVersion).toBe("1.1.0"); | |
| 93 | expect(failedInstall.status).toBe("downloaded"); | |
| 94 | expect(failedInstall.errorContext).toBe("install"); | |
| 95 | expect(failedInstall.canRetry).toBe(true); | |
| 96 | }); | |
| 97 | ||
| 98 | it("clears stale download state when no update is available", () => { | |
| 99 | const state = reduceDesktopUpdateStateOnNoUpdate( | |
| 100 | { | |
| 101 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 102 | enabled: true, | |
| 103 | status: "error", | |
| 104 | availableVersion: "1.1.0", | |
| 105 | downloadedVersion: "1.1.0", | |
| 106 | message: "old failure", | |
| 107 | errorContext: "download", | |
| 108 | canRetry: true, | |
| 109 | }, | |
| 110 | "2026-03-04T00:00:00.000Z", | |
| 111 | ); | |
| 112 | ||
| 113 | expect(state.status).toBe("up-to-date"); | |
| 114 | expect(state.availableVersion).toBeNull(); | |
| 115 | expect(state.downloadedVersion).toBeNull(); | |
| 116 | expect(state.message).toBeNull(); | |
| 117 | expect(state.errorContext).toBeNull(); | |
| 118 | }); | |
| 119 | ||
| 120 | it("tracks available, download start, and progress cleanly", () => { | |
| 121 | const available = reduceDesktopUpdateStateOnUpdateAvailable( | |
| 122 | { | |
| 123 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 124 | enabled: true, | |
| 125 | status: "checking", | |
| 126 | }, | |
| 127 | "1.1.0", | |
| 128 | "2026-03-04T00:00:00.000Z", | |
| 129 | ); | |
| 130 | const downloading = reduceDesktopUpdateStateOnDownloadStart(available); | |
| 131 | const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); | |
| 132 | ||
| 133 | expect(available.status).toBe("available"); | |
| 134 | expect(available.channel).toBe("latest"); | |
| 135 | expect(downloading.status).toBe("downloading"); | |
| 136 | expect(downloading.downloadPercent).toBe(0); | |
| 137 | expect(progress.downloadPercent).toBe(55.5); | |
| 138 | expect(progress.errorContext).toBeNull(); | |
| 139 | }); | |
| 140 | }); |
apps/desktop/src/updateMachine.ts deleted-166| ... | ... | @@ -1,166 +0,0 @@ |
| 1 | import type { | |
| 2 | DesktopRuntimeInfo, | |
| 3 | DesktopUpdateChannel, | |
| 4 | DesktopUpdateState, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | ||
| 7 | import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState.ts"; | |
| 8 | ||
| 9 | export function createInitialDesktopUpdateState( | |
| 10 | currentVersion: string, | |
| 11 | runtimeInfo: DesktopRuntimeInfo, | |
| 12 | channel: DesktopUpdateChannel, | |
| 13 | ): DesktopUpdateState { | |
| 14 | return { | |
| 15 | enabled: false, | |
| 16 | status: "disabled", | |
| 17 | channel, | |
| 18 | currentVersion, | |
| 19 | hostArch: runtimeInfo.hostArch, | |
| 20 | appArch: runtimeInfo.appArch, | |
| 21 | runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation, | |
| 22 | availableVersion: null, | |
| 23 | downloadedVersion: null, | |
| 24 | downloadPercent: null, | |
| 25 | checkedAt: null, | |
| 26 | message: null, | |
| 27 | errorContext: null, | |
| 28 | canRetry: false, | |
| 29 | }; | |
| 30 | } | |
| 31 | ||
| 32 | export function reduceDesktopUpdateStateOnCheckStart( | |
| 33 | state: DesktopUpdateState, | |
| 34 | checkedAt: string, | |
| 35 | ): DesktopUpdateState { | |
| 36 | return { | |
| 37 | ...state, | |
| 38 | status: "checking", | |
| 39 | checkedAt, | |
| 40 | message: null, | |
| 41 | downloadPercent: null, | |
| 42 | errorContext: null, | |
| 43 | canRetry: false, | |
| 44 | }; | |
| 45 | } | |
| 46 | ||
| 47 | export function reduceDesktopUpdateStateOnCheckFailure( | |
| 48 | state: DesktopUpdateState, | |
| 49 | message: string, | |
| 50 | checkedAt: string, | |
| 51 | ): DesktopUpdateState { | |
| 52 | return { | |
| 53 | ...state, | |
| 54 | status: "error", | |
| 55 | message, | |
| 56 | checkedAt, | |
| 57 | downloadPercent: null, | |
| 58 | errorContext: "check", | |
| 59 | canRetry: true, | |
| 60 | }; | |
| 61 | } | |
| 62 | ||
| 63 | export function reduceDesktopUpdateStateOnUpdateAvailable( | |
| 64 | state: DesktopUpdateState, | |
| 65 | version: string, | |
| 66 | checkedAt: string, | |
| 67 | ): DesktopUpdateState { | |
| 68 | return { | |
| 69 | ...state, | |
| 70 | status: "available", | |
| 71 | availableVersion: version, | |
| 72 | downloadedVersion: null, | |
| 73 | downloadPercent: null, | |
| 74 | checkedAt, | |
| 75 | message: null, | |
| 76 | errorContext: null, | |
| 77 | canRetry: false, | |
| 78 | }; | |
| 79 | } | |
| 80 | ||
| 81 | export function reduceDesktopUpdateStateOnNoUpdate( | |
| 82 | state: DesktopUpdateState, | |
| 83 | checkedAt: string, | |
| 84 | ): DesktopUpdateState { | |
| 85 | return { | |
| 86 | ...state, | |
| 87 | status: "up-to-date", | |
| 88 | availableVersion: null, | |
| 89 | downloadedVersion: null, | |
| 90 | downloadPercent: null, | |
| 91 | checkedAt, | |
| 92 | message: null, | |
| 93 | errorContext: null, | |
| 94 | canRetry: false, | |
| 95 | }; | |
| 96 | } | |
| 97 | ||
| 98 | export function reduceDesktopUpdateStateOnDownloadStart( | |
| 99 | state: DesktopUpdateState, | |
| 100 | ): DesktopUpdateState { | |
| 101 | return { | |
| 102 | ...state, | |
| 103 | status: "downloading", | |
| 104 | downloadPercent: 0, | |
| 105 | message: null, | |
| 106 | errorContext: null, | |
| 107 | canRetry: false, | |
| 108 | }; | |
| 109 | } | |
| 110 | ||
| 111 | export function reduceDesktopUpdateStateOnDownloadFailure( | |
| 112 | state: DesktopUpdateState, | |
| 113 | message: string, | |
| 114 | ): DesktopUpdateState { | |
| 115 | return { | |
| 116 | ...state, | |
| 117 | status: nextStatusAfterDownloadFailure(state), | |
| 118 | message, | |
| 119 | downloadPercent: null, | |
| 120 | errorContext: "download", | |
| 121 | canRetry: getCanRetryAfterDownloadFailure(state), | |
| 122 | }; | |
| 123 | } | |
| 124 | ||
| 125 | export function reduceDesktopUpdateStateOnDownloadProgress( | |
| 126 | state: DesktopUpdateState, | |
| 127 | percent: number, | |
| 128 | ): DesktopUpdateState { | |
| 129 | return { | |
| 130 | ...state, | |
| 131 | status: "downloading", | |
| 132 | downloadPercent: percent, | |
| 133 | message: null, | |
| 134 | errorContext: null, | |
| 135 | canRetry: false, | |
| 136 | }; | |
| 137 | } | |
| 138 | ||
| 139 | export function reduceDesktopUpdateStateOnDownloadComplete( | |
| 140 | state: DesktopUpdateState, | |
| 141 | version: string, | |
| 142 | ): DesktopUpdateState { | |
| 143 | return { | |
| 144 | ...state, | |
| 145 | status: "downloaded", | |
| 146 | availableVersion: version, | |
| 147 | downloadedVersion: version, | |
| 148 | downloadPercent: 100, | |
| 149 | message: null, | |
| 150 | errorContext: null, | |
| 151 | canRetry: true, | |
| 152 | }; | |
| 153 | } | |
| 154 | ||
| 155 | export function reduceDesktopUpdateStateOnInstallFailure( | |
| 156 | state: DesktopUpdateState, | |
| 157 | message: string, | |
| 158 | ): DesktopUpdateState { | |
| 159 | return { | |
| 160 | ...state, | |
| 161 | status: "downloaded", | |
| 162 | message, | |
| 163 | errorContext: "install", | |
| 164 | canRetry: true, | |
| 165 | }; | |
| 166 | } |
apps/desktop/src/updateState.test.ts deleted-175| ... | ... | @@ -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 | }); |
apps/desktop/src/updateState.ts deleted-52| ... | ... | @@ -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 | } |
apps/desktop/src/updates/DesktopUpdates.test.ts created+295| ... | ... | @@ -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 | }); |
apps/desktop/src/updates/DesktopUpdates.ts created+664| ... | ... | @@ -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); |
apps/desktop/src/updates/updateChannels.ts created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | import type { DesktopUpdateChannel } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; | |
| 4 | ||
| 5 | export function isNightlyDesktopVersion(version: string): boolean { | |
| 6 | return NIGHTLY_VERSION_PATTERN.test(version); | |
| 7 | } | |
| 8 | ||
| 9 | export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { | |
| 10 | return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; | |
| 11 | } |
apps/desktop/src/updates/updateMachine.test.ts created+140| ... | ... | @@ -0,0 +1,140 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | createInitialDesktopUpdateState, | |
| 5 | reduceDesktopUpdateStateOnCheckFailure, | |
| 6 | reduceDesktopUpdateStateOnCheckStart, | |
| 7 | reduceDesktopUpdateStateOnDownloadComplete, | |
| 8 | reduceDesktopUpdateStateOnDownloadFailure, | |
| 9 | reduceDesktopUpdateStateOnDownloadProgress, | |
| 10 | reduceDesktopUpdateStateOnDownloadStart, | |
| 11 | reduceDesktopUpdateStateOnInstallFailure, | |
| 12 | reduceDesktopUpdateStateOnNoUpdate, | |
| 13 | reduceDesktopUpdateStateOnUpdateAvailable, | |
| 14 | } from "./updateMachine.ts"; | |
| 15 | ||
| 16 | const runtimeInfo = { | |
| 17 | hostArch: "x64", | |
| 18 | appArch: "x64", | |
| 19 | runningUnderArm64Translation: false, | |
| 20 | } as const; | |
| 21 | ||
| 22 | describe("updateMachine", () => { | |
| 23 | it("clears transient errors when a check starts", () => { | |
| 24 | const state = reduceDesktopUpdateStateOnCheckStart( | |
| 25 | { | |
| 26 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 27 | enabled: true, | |
| 28 | status: "error", | |
| 29 | message: "network", | |
| 30 | errorContext: "check", | |
| 31 | canRetry: true, | |
| 32 | }, | |
| 33 | "2026-03-04T00:00:00.000Z", | |
| 34 | ); | |
| 35 | ||
| 36 | expect(state.status).toBe("checking"); | |
| 37 | expect(state.message).toBeNull(); | |
| 38 | expect(state.errorContext).toBeNull(); | |
| 39 | expect(state.canRetry).toBe(false); | |
| 40 | }); | |
| 41 | ||
| 42 | it("records a check failure without exposing an action", () => { | |
| 43 | const state = reduceDesktopUpdateStateOnCheckFailure( | |
| 44 | { | |
| 45 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 46 | enabled: true, | |
| 47 | status: "checking", | |
| 48 | }, | |
| 49 | "network unavailable", | |
| 50 | "2026-03-04T00:00:00.000Z", | |
| 51 | ); | |
| 52 | ||
| 53 | expect(state.status).toBe("error"); | |
| 54 | expect(state.errorContext).toBe("check"); | |
| 55 | expect(state.canRetry).toBe(true); | |
| 56 | }); | |
| 57 | ||
| 58 | it("preserves available version on download failure for retry", () => { | |
| 59 | const state = reduceDesktopUpdateStateOnDownloadFailure( | |
| 60 | { | |
| 61 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 62 | enabled: true, | |
| 63 | status: "downloading", | |
| 64 | availableVersion: "1.1.0", | |
| 65 | downloadPercent: 43, | |
| 66 | }, | |
| 67 | "checksum mismatch", | |
| 68 | ); | |
| 69 | ||
| 70 | expect(state.status).toBe("available"); | |
| 71 | expect(state.availableVersion).toBe("1.1.0"); | |
| 72 | expect(state.errorContext).toBe("download"); | |
| 73 | expect(state.canRetry).toBe(true); | |
| 74 | }); | |
| 75 | ||
| 76 | it("transitions to downloaded and then preserves install retry state", () => { | |
| 77 | const downloaded = reduceDesktopUpdateStateOnDownloadComplete( | |
| 78 | { | |
| 79 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 80 | enabled: true, | |
| 81 | status: "downloading", | |
| 82 | availableVersion: "1.1.0", | |
| 83 | }, | |
| 84 | "1.1.0", | |
| 85 | ); | |
| 86 | const failedInstall = reduceDesktopUpdateStateOnInstallFailure( | |
| 87 | downloaded, | |
| 88 | "backend shutdown timed out", | |
| 89 | ); | |
| 90 | ||
| 91 | expect(downloaded.status).toBe("downloaded"); | |
| 92 | expect(downloaded.downloadedVersion).toBe("1.1.0"); | |
| 93 | expect(failedInstall.status).toBe("downloaded"); | |
| 94 | expect(failedInstall.errorContext).toBe("install"); | |
| 95 | expect(failedInstall.canRetry).toBe(true); | |
| 96 | }); | |
| 97 | ||
| 98 | it("clears stale download state when no update is available", () => { | |
| 99 | const state = reduceDesktopUpdateStateOnNoUpdate( | |
| 100 | { | |
| 101 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 102 | enabled: true, | |
| 103 | status: "error", | |
| 104 | availableVersion: "1.1.0", | |
| 105 | downloadedVersion: "1.1.0", | |
| 106 | message: "old failure", | |
| 107 | errorContext: "download", | |
| 108 | canRetry: true, | |
| 109 | }, | |
| 110 | "2026-03-04T00:00:00.000Z", | |
| 111 | ); | |
| 112 | ||
| 113 | expect(state.status).toBe("up-to-date"); | |
| 114 | expect(state.availableVersion).toBeNull(); | |
| 115 | expect(state.downloadedVersion).toBeNull(); | |
| 116 | expect(state.message).toBeNull(); | |
| 117 | expect(state.errorContext).toBeNull(); | |
| 118 | }); | |
| 119 | ||
| 120 | it("tracks available, download start, and progress cleanly", () => { | |
| 121 | const available = reduceDesktopUpdateStateOnUpdateAvailable( | |
| 122 | { | |
| 123 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 124 | enabled: true, | |
| 125 | status: "checking", | |
| 126 | }, | |
| 127 | "1.1.0", | |
| 128 | "2026-03-04T00:00:00.000Z", | |
| 129 | ); | |
| 130 | const downloading = reduceDesktopUpdateStateOnDownloadStart(available); | |
| 131 | const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); | |
| 132 | ||
| 133 | expect(available.status).toBe("available"); | |
| 134 | expect(available.channel).toBe("latest"); | |
| 135 | expect(downloading.status).toBe("downloading"); | |
| 136 | expect(downloading.downloadPercent).toBe(0); | |
| 137 | expect(progress.downloadPercent).toBe(55.5); | |
| 138 | expect(progress.errorContext).toBeNull(); | |
| 139 | }); | |
| 140 | }); |
apps/desktop/src/updates/updateMachine.ts created+174| ... | ... | @@ -0,0 +1,174 @@ |
| 1 | import type { | |
| 2 | DesktopRuntimeInfo, | |
| 3 | DesktopUpdateChannel, | |
| 4 | DesktopUpdateState, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | ||
| 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 | } | |
| 16 | ||
| 17 | export function createInitialDesktopUpdateState( | |
| 18 | currentVersion: string, | |
| 19 | runtimeInfo: DesktopRuntimeInfo, | |
| 20 | channel: DesktopUpdateChannel, | |
| 21 | ): DesktopUpdateState { | |
| 22 | return { | |
| 23 | enabled: false, | |
| 24 | status: "disabled", | |
| 25 | channel, | |
| 26 | currentVersion, | |
| 27 | hostArch: runtimeInfo.hostArch, | |
| 28 | appArch: runtimeInfo.appArch, | |
| 29 | runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation, | |
| 30 | availableVersion: null, | |
| 31 | downloadedVersion: null, | |
| 32 | downloadPercent: null, | |
| 33 | checkedAt: null, | |
| 34 | message: null, | |
| 35 | errorContext: null, | |
| 36 | canRetry: false, | |
| 37 | }; | |
| 38 | } | |
| 39 | ||
| 40 | export function reduceDesktopUpdateStateOnCheckStart( | |
| 41 | state: DesktopUpdateState, | |
| 42 | checkedAt: string, | |
| 43 | ): DesktopUpdateState { | |
| 44 | return { | |
| 45 | ...state, | |
| 46 | status: "checking", | |
| 47 | checkedAt, | |
| 48 | message: null, | |
| 49 | downloadPercent: null, | |
| 50 | errorContext: null, | |
| 51 | canRetry: false, | |
| 52 | }; | |
| 53 | } | |
| 54 | ||
| 55 | export function reduceDesktopUpdateStateOnCheckFailure( | |
| 56 | state: DesktopUpdateState, | |
| 57 | message: string, | |
| 58 | checkedAt: string, | |
| 59 | ): DesktopUpdateState { | |
| 60 | return { | |
| 61 | ...state, | |
| 62 | status: "error", | |
| 63 | message, | |
| 64 | checkedAt, | |
| 65 | downloadPercent: null, | |
| 66 | errorContext: "check", | |
| 67 | canRetry: true, | |
| 68 | }; | |
| 69 | } | |
| 70 | ||
| 71 | export function reduceDesktopUpdateStateOnUpdateAvailable( | |
| 72 | state: DesktopUpdateState, | |
| 73 | version: string, | |
| 74 | checkedAt: string, | |
| 75 | ): DesktopUpdateState { | |
| 76 | return { | |
| 77 | ...state, | |
| 78 | status: "available", | |
| 79 | availableVersion: version, | |
| 80 | downloadedVersion: null, | |
| 81 | downloadPercent: null, | |
| 82 | checkedAt, | |
| 83 | message: null, | |
| 84 | errorContext: null, | |
| 85 | canRetry: false, | |
| 86 | }; | |
| 87 | } | |
| 88 | ||
| 89 | export function reduceDesktopUpdateStateOnNoUpdate( | |
| 90 | state: DesktopUpdateState, | |
| 91 | checkedAt: string, | |
| 92 | ): DesktopUpdateState { | |
| 93 | return { | |
| 94 | ...state, | |
| 95 | status: "up-to-date", | |
| 96 | availableVersion: null, | |
| 97 | downloadedVersion: null, | |
| 98 | downloadPercent: null, | |
| 99 | checkedAt, | |
| 100 | message: null, | |
| 101 | errorContext: null, | |
| 102 | canRetry: false, | |
| 103 | }; | |
| 104 | } | |
| 105 | ||
| 106 | export function reduceDesktopUpdateStateOnDownloadStart( | |
| 107 | state: DesktopUpdateState, | |
| 108 | ): DesktopUpdateState { | |
| 109 | return { | |
| 110 | ...state, | |
| 111 | status: "downloading", | |
| 112 | downloadPercent: 0, | |
| 113 | message: null, | |
| 114 | errorContext: null, | |
| 115 | canRetry: false, | |
| 116 | }; | |
| 117 | } | |
| 118 | ||
| 119 | export function reduceDesktopUpdateStateOnDownloadFailure( | |
| 120 | state: DesktopUpdateState, | |
| 121 | message: string, | |
| 122 | ): DesktopUpdateState { | |
| 123 | return { | |
| 124 | ...state, | |
| 125 | status: nextStatusAfterDownloadFailure(state), | |
| 126 | message, | |
| 127 | downloadPercent: null, | |
| 128 | errorContext: "download", | |
| 129 | canRetry: getCanRetryAfterDownloadFailure(state), | |
| 130 | }; | |
| 131 | } | |
| 132 | ||
| 133 | export function reduceDesktopUpdateStateOnDownloadProgress( | |
| 134 | state: DesktopUpdateState, | |
| 135 | percent: number, | |
| 136 | ): DesktopUpdateState { | |
| 137 | return { | |
| 138 | ...state, | |
| 139 | status: "downloading", | |
| 140 | downloadPercent: percent, | |
| 141 | message: null, | |
| 142 | errorContext: null, | |
| 143 | canRetry: false, | |
| 144 | }; | |
| 145 | } | |
| 146 | ||
| 147 | export function reduceDesktopUpdateStateOnDownloadComplete( | |
| 148 | state: DesktopUpdateState, | |
| 149 | version: string, | |
| 150 | ): DesktopUpdateState { | |
| 151 | return { | |
| 152 | ...state, | |
| 153 | status: "downloaded", | |
| 154 | availableVersion: version, | |
| 155 | downloadedVersion: version, | |
| 156 | downloadPercent: 100, | |
| 157 | message: null, | |
| 158 | errorContext: null, | |
| 159 | canRetry: true, | |
| 160 | }; | |
| 161 | } | |
| 162 | ||
| 163 | export function reduceDesktopUpdateStateOnInstallFailure( | |
| 164 | state: DesktopUpdateState, | |
| 165 | message: string, | |
| 166 | ): DesktopUpdateState { | |
| 167 | return { | |
| 168 | ...state, | |
| 169 | status: "downloaded", | |
| 170 | message, | |
| 171 | errorContext: "install", | |
| 172 | canRetry: true, | |
| 173 | }; | |
| 174 | } |
apps/desktop/src/window/DesktopApplicationMenu.test.ts created+132| ... | ... | @@ -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 | }); |
apps/desktop/src/window/DesktopApplicationMenu.ts created+212| ... | ... | @@ -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); |
apps/desktop/src/window/DesktopWindow.test.ts created+180| ... | ... | @@ -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 "../serverExposure/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 | }); |
apps/desktop/src/window/DesktopWindow.ts created+368| ... | ... | @@ -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 "../serverExposure/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); |
apps/desktop/src/windowReveal.test.ts deleted-74| ... | ... | @@ -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 | }); |
apps/desktop/src/windowReveal.ts deleted-28| ... | ... | @@ -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 | } |
apps/desktop/tsconfig.json+13-1| ... | ... | @@ -3,7 +3,19 @@ |
| 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 | } |
apps/server/src/cli/config.test.ts+63-44| ... | ... | @@ -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) => { |
| 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) => { |
| 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) => { |
| 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) => { |
| 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) => { |
| 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) => { |
| 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) => { |
| 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, |
apps/server/src/cli/config.ts+3-26| ... | ... | @@ -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 { |
| 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 = ( |
| 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 = ( |
| 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 = ( |
| 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 = ( |
| 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 | ); |
apps/server/src/http.ts+1-1| ... | ... | @@ -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 { |
| 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"; |
apps/server/src/observability/Attributes.test.ts+1-36| ... | ... | @@ -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"); |
apps/server/src/observability/Attributes.ts-80| ... | ... | @@ -2,88 +2,8 @@ import { Cause, Exit } from "effect"; |
| 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 { |
apps/server/src/observability/Layers/Observability.ts+1-2| ... | ... | @@ -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 |
apps/server/src/observability/LocalFileTracer.test.ts deleted-117| ... | ... | @@ -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 | }); |
apps/server/src/observability/LocalFileTracer.ts deleted-112| ... | ... | @@ -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 | }); |
apps/server/src/observability/Services/BrowserTraceCollector.ts+1-2| ... | ... | @@ -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 | } |
apps/server/src/observability/TraceRecord.ts deleted-287| ... | ... | @@ -1,287 +0,0 @@ |
| 1 | import { Cause, Exit, Option, Tracer } from "effect"; | |
| 2 | ||
| 3 | import { compactTraceAttributes } from "./Attributes.ts"; | |
| 4 | import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; | |
| 5 | ||
| 6 | interface TraceRecordEvent { | |
| 7 | readonly name: string; | |
| 8 | readonly timeUnixNano: string; | |
| 9 | readonly attributes: Readonly<Record<string, unknown>>; | |
| 10 | } | |
| 11 | ||
| 12 | interface TraceRecordLink { | |
| 13 | readonly traceId: string; | |
| 14 | readonly spanId: string; | |
| 15 | readonly attributes: Readonly<Record<string, unknown>>; | |
| 16 | } | |
| 17 | ||
| 18 | interface BaseTraceRecord { | |
| 19 | readonly name: string; | |
| 20 | readonly kind: string; | |
| 21 | readonly traceId: string; | |
| 22 | readonly spanId: string; | |
| 23 | readonly parentSpanId?: string; | |
| 24 | readonly sampled: boolean; | |
| 25 | readonly startTimeUnixNano: string; | |
| 26 | readonly endTimeUnixNano: string; | |
| 27 | readonly durationMs: number; | |
| 28 | readonly attributes: Readonly<Record<string, unknown>>; | |
| 29 | readonly events: ReadonlyArray<TraceRecordEvent>; | |
| 30 | readonly links: ReadonlyArray<TraceRecordLink>; | |
| 31 | } | |
| 32 | ||
| 33 | export interface EffectTraceRecord extends BaseTraceRecord { | |
| 34 | readonly type: "effect-span"; | |
| 35 | readonly exit: | |
| 36 | | { | |
| 37 | readonly _tag: "Success"; | |
| 38 | } | |
| 39 | | { | |
| 40 | readonly _tag: "Interrupted"; | |
| 41 | readonly cause: string; | |
| 42 | } | |
| 43 | | { | |
| 44 | readonly _tag: "Failure"; | |
| 45 | readonly cause: string; | |
| 46 | }; | |
| 47 | } | |
| 48 | ||
| 49 | interface OtlpTraceRecord extends BaseTraceRecord { | |
| 50 | readonly type: "otlp-span"; | |
| 51 | readonly resourceAttributes: Readonly<Record<string, unknown>>; | |
| 52 | readonly scope: Readonly<{ | |
| 53 | readonly name?: string; | |
| 54 | readonly version?: string; | |
| 55 | readonly attributes: Readonly<Record<string, unknown>>; | |
| 56 | }>; | |
| 57 | readonly status?: | |
| 58 | | { | |
| 59 | readonly code?: string; | |
| 60 | readonly message?: string; | |
| 61 | } | |
| 62 | | undefined; | |
| 63 | } | |
| 64 | ||
| 65 | export type TraceRecord = EffectTraceRecord | OtlpTraceRecord; | |
| 66 | ||
| 67 | type OtlpSpan = OtlpTracer.ScopeSpan["spans"][number]; | |
| 68 | type OtlpSpanEvent = OtlpSpan["events"][number]; | |
| 69 | type OtlpSpanLink = OtlpSpan["links"][number]; | |
| 70 | type OtlpSpanStatus = OtlpSpan["status"]; | |
| 71 | ||
| 72 | interface SerializableSpan { | |
| 73 | readonly name: string; | |
| 74 | readonly traceId: string; | |
| 75 | readonly spanId: string; | |
| 76 | readonly parent: Option.Option<Tracer.AnySpan>; | |
| 77 | readonly status: Tracer.SpanStatus; | |
| 78 | readonly sampled: boolean; | |
| 79 | readonly kind: Tracer.SpanKind; | |
| 80 | readonly attributes: ReadonlyMap<string, unknown>; | |
| 81 | readonly links: ReadonlyArray<Tracer.SpanLink>; | |
| 82 | readonly events: ReadonlyArray< | |
| 83 | readonly [name: string, startTime: bigint, attributes: Record<string, unknown>] | |
| 84 | >; | |
| 85 | } | |
| 86 | ||
| 87 | function formatTraceExit(exit: Exit.Exit<unknown, unknown>): EffectTraceRecord["exit"] { | |
| 88 | if (Exit.isSuccess(exit)) { | |
| 89 | return { _tag: "Success" }; | |
| 90 | } | |
| 91 | if (Cause.hasInterruptsOnly(exit.cause)) { | |
| 92 | return { | |
| 93 | _tag: "Interrupted", | |
| 94 | cause: Cause.pretty(exit.cause), | |
| 95 | }; | |
| 96 | } | |
| 97 | return { | |
| 98 | _tag: "Failure", | |
| 99 | cause: Cause.pretty(exit.cause), | |
| 100 | }; | |
| 101 | } | |
| 102 | ||
| 103 | export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { | |
| 104 | const status = span.status as Extract<Tracer.SpanStatus, { _tag: "Ended" }>; | |
| 105 | const parentSpanId = Option.getOrUndefined(span.parent)?.spanId; | |
| 106 | ||
| 107 | return { | |
| 108 | type: "effect-span", | |
| 109 | name: span.name, | |
| 110 | traceId: span.traceId, | |
| 111 | spanId: span.spanId, | |
| 112 | ...(parentSpanId ? { parentSpanId } : {}), | |
| 113 | sampled: span.sampled, | |
| 114 | kind: span.kind, | |
| 115 | startTimeUnixNano: String(status.startTime), | |
| 116 | endTimeUnixNano: String(status.endTime), | |
| 117 | durationMs: Number(status.endTime - status.startTime) / 1_000_000, | |
| 118 | attributes: compactTraceAttributes(Object.fromEntries(span.attributes)), | |
| 119 | events: span.events.map(([name, startTime, attributes]) => ({ | |
| 120 | name, | |
| 121 | timeUnixNano: String(startTime), | |
| 122 | attributes: compactTraceAttributes(attributes), | |
| 123 | })), | |
| 124 | links: span.links.map((link) => ({ | |
| 125 | traceId: link.span.traceId, | |
| 126 | spanId: link.span.spanId, | |
| 127 | attributes: compactTraceAttributes(link.attributes), | |
| 128 | })), | |
| 129 | exit: formatTraceExit(status.exit), | |
| 130 | }; | |
| 131 | } | |
| 132 | ||
| 133 | const SPAN_KIND_MAP: Record<number, OtlpTraceRecord["kind"]> = { | |
| 134 | 1: "internal", | |
| 135 | 2: "server", | |
| 136 | 3: "client", | |
| 137 | 4: "producer", | |
| 138 | 5: "consumer", | |
| 139 | }; | |
| 140 | ||
| 141 | export function decodeOtlpTraceRecords( | |
| 142 | payload: OtlpTracer.TraceData, | |
| 143 | ): ReadonlyArray<OtlpTraceRecord> { | |
| 144 | const records: Array<OtlpTraceRecord> = []; | |
| 145 | ||
| 146 | for (const resourceSpan of payload.resourceSpans) { | |
| 147 | const resourceAttributes = decodeAttributes(resourceSpan.resource?.attributes ?? []); | |
| 148 | ||
| 149 | for (const scopeSpan of resourceSpan.scopeSpans) { | |
| 150 | for (const span of scopeSpan.spans) { | |
| 151 | records.push( | |
| 152 | otlpSpanToTraceRecord({ | |
| 153 | resourceAttributes, | |
| 154 | scopeAttributes: decodeAttributes( | |
| 155 | "attributes" in scopeSpan.scope && Array.isArray(scopeSpan.scope.attributes) | |
| 156 | ? scopeSpan.scope.attributes | |
| 157 | : [], | |
| 158 | ), | |
| 159 | scopeName: scopeSpan.scope.name, | |
| 160 | scopeVersion: | |
| 161 | "version" in scopeSpan.scope && typeof scopeSpan.scope.version === "string" | |
| 162 | ? scopeSpan.scope.version | |
| 163 | : undefined, | |
| 164 | span, | |
| 165 | }), | |
| 166 | ); | |
| 167 | } | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | return records; | |
| 172 | } | |
| 173 | ||
| 174 | function otlpSpanToTraceRecord(input: { | |
| 175 | readonly resourceAttributes: Readonly<Record<string, unknown>>; | |
| 176 | readonly scopeAttributes: Readonly<Record<string, unknown>>; | |
| 177 | readonly scopeName: string | undefined; | |
| 178 | readonly scopeVersion: string | undefined; | |
| 179 | readonly span: OtlpSpan; | |
| 180 | }): OtlpTraceRecord { | |
| 181 | return { | |
| 182 | type: "otlp-span", | |
| 183 | name: input.span.name, | |
| 184 | traceId: input.span.traceId, | |
| 185 | spanId: input.span.spanId, | |
| 186 | ...(input.span.parentSpanId ? { parentSpanId: input.span.parentSpanId } : {}), | |
| 187 | sampled: true, | |
| 188 | kind: normalizeSpanKind(input.span.kind), | |
| 189 | startTimeUnixNano: input.span.startTimeUnixNano, | |
| 190 | endTimeUnixNano: input.span.endTimeUnixNano, | |
| 191 | durationMs: | |
| 192 | Number(parseBigInt(input.span.endTimeUnixNano) - parseBigInt(input.span.startTimeUnixNano)) / | |
| 193 | 1_000_000, | |
| 194 | attributes: decodeAttributes(input.span.attributes), | |
| 195 | resourceAttributes: input.resourceAttributes, | |
| 196 | scope: { | |
| 197 | ...(input.scopeName ? { name: input.scopeName } : {}), | |
| 198 | ...(input.scopeVersion ? { version: input.scopeVersion } : {}), | |
| 199 | attributes: input.scopeAttributes, | |
| 200 | }, | |
| 201 | events: decodeEvents(input.span.events), | |
| 202 | links: decodeLinks(input.span.links), | |
| 203 | status: decodeStatus(input.span.status), | |
| 204 | }; | |
| 205 | } | |
| 206 | ||
| 207 | function decodeStatus(input: OtlpSpanStatus): OtlpTraceRecord["status"] { | |
| 208 | const code = String(input.code); | |
| 209 | const message = input.message; | |
| 210 | ||
| 211 | return { | |
| 212 | code, | |
| 213 | ...(message ? { message } : {}), | |
| 214 | }; | |
| 215 | } | |
| 216 | ||
| 217 | function decodeEvents(input: ReadonlyArray<OtlpSpanEvent>): ReadonlyArray<TraceRecordEvent> { | |
| 218 | return input.map((current) => ({ | |
| 219 | name: current.name, | |
| 220 | timeUnixNano: current.timeUnixNano, | |
| 221 | attributes: decodeAttributes(current.attributes), | |
| 222 | })); | |
| 223 | } | |
| 224 | ||
| 225 | function decodeLinks(input: ReadonlyArray<OtlpSpanLink>): ReadonlyArray<TraceRecordLink> { | |
| 226 | return input.flatMap((current) => { | |
| 227 | const traceId = current.traceId; | |
| 228 | const spanId = current.spanId; | |
| 229 | return { | |
| 230 | traceId, | |
| 231 | spanId, | |
| 232 | attributes: decodeAttributes(current.attributes), | |
| 233 | }; | |
| 234 | }); | |
| 235 | } | |
| 236 | ||
| 237 | function decodeAttributes( | |
| 238 | input: ReadonlyArray<OtlpResource.KeyValue>, | |
| 239 | ): Readonly<Record<string, unknown>> { | |
| 240 | const entries: Record<string, unknown> = {}; | |
| 241 | ||
| 242 | for (const attribute of input) { | |
| 243 | entries[attribute.key] = decodeValue(attribute.value); | |
| 244 | } | |
| 245 | ||
| 246 | return compactTraceAttributes(entries); | |
| 247 | } | |
| 248 | ||
| 249 | function decodeValue(input: OtlpResource.AnyValue | null | undefined): unknown { | |
| 250 | if (input == null) { | |
| 251 | return null; | |
| 252 | } | |
| 253 | if ("stringValue" in input) { | |
| 254 | return input.stringValue; | |
| 255 | } | |
| 256 | if ("boolValue" in input) { | |
| 257 | return input.boolValue; | |
| 258 | } | |
| 259 | if ("intValue" in input) { | |
| 260 | return input.intValue; | |
| 261 | } | |
| 262 | if ("doubleValue" in input) { | |
| 263 | return input.doubleValue; | |
| 264 | } | |
| 265 | if ("bytesValue" in input) { | |
| 266 | return input.bytesValue; | |
| 267 | } | |
| 268 | if (input.arrayValue) { | |
| 269 | return input.arrayValue.values.map((entry) => decodeValue(entry)); | |
| 270 | } | |
| 271 | if (input.kvlistValue) { | |
| 272 | return decodeAttributes(input.kvlistValue.values); | |
| 273 | } | |
| 274 | return null; | |
| 275 | } | |
| 276 | ||
| 277 | function normalizeSpanKind(input: number): OtlpTraceRecord["kind"] { | |
| 278 | return SPAN_KIND_MAP[input] || "internal"; | |
| 279 | } | |
| 280 | ||
| 281 | function parseBigInt(input: string): bigint { | |
| 282 | try { | |
| 283 | return BigInt(input); | |
| 284 | } catch { | |
| 285 | return 0n; | |
| 286 | } | |
| 287 | } |
apps/server/src/observability/TraceSink.test.ts deleted-152| ... | ... | @@ -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 | }); |
apps/server/src/observability/TraceSink.ts deleted-68| ... | ... | @@ -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 | }); |
apps/server/src/provider/Layers/CursorAdapter.test.ts+1| ... | ... | @@ -113,6 +113,7 @@ async function waitForFileContent(filePath: string, attempts = 40) { |
| 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, |
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+9-25| ... | ... | @@ -193,9 +193,7 @@ const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({ |
| 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) => { |
| 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) => { |
| 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) => { |
| 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) => { |
| 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)), |
apps/server/src/sourceControl/SourceControlRepositoryService.ts+15-21| ... | ... | @@ -153,13 +153,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () |
| 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* () |
| 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* () |
| 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({ |
apps/server/src/vcs/GitVcsDriverCore.ts+2-2| ... | ... | @@ -20,7 +20,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 20 | 20 | |
| 21 | 21 | import { GitCommandError, type VcsRef } from "@t3tools/contracts"; |
| 22 | 22 | import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; |
| 23 | import { compactTraceAttributes } from "../observability/Attributes.ts"; | |
| 23 | import { compactTraceAttributes } from "@t3tools/shared/observability"; | |
| 24 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 24 | 25 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; |
| 25 | 26 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 26 | 27 | import { |
| ... | ... | @@ -29,7 +30,6 @@ import { |
| 29 | 30 | parseRemoteRefWithRemoteNames, |
| 30 | 31 | } from "../git/remoteRefs.ts"; |
| 31 | 32 | import { ServerConfig } from "../config.ts"; |
| 32 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 33 | 33 | |
| 34 | 34 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 35 | 35 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; |
apps/web/tsconfig.json+1-1| ... | ... | @@ -18,7 +18,7 @@ |
| 18 | 18 | "namespaceImportPackages": ["@effect/platform-node"], |
| 19 | 19 | "diagnosticSeverity": { |
| 20 | 20 | "importFromBarrel": "error", |
| 21 | "anyUnknownInErrorContext": "warning", | |
| 21 | "anyUnknownInErrorContext": "error", | |
| 22 | 22 | "instanceOfSchema": "warning", |
| 23 | 23 | "deterministicKeys": "warning" |
| 24 | 24 | } |
bun.lock+4-2| ... | ... | @@ -20,10 +20,12 @@ |
| 20 | 20 | "dependencies": { |
| 21 | 21 | "@effect/platform-node": "catalog:", |
| 22 | 22 | "effect": "catalog:", |
| 23 | "electron": "40.9.3", | |
| 23 | "electron": "41.5.0", | |
| 24 | 24 | "electron-updater": "^6.6.2", |
| 25 | 25 | }, |
| 26 | 26 | "devDependencies": { |
| 27 | "@effect/language-service": "catalog:", | |
| 28 | "@effect/vitest": "catalog:", | |
| 27 | 29 | "@t3tools/client-runtime": "workspace:*", |
| 28 | 30 | "@t3tools/contracts": "workspace:*", |
| 29 | 31 | "@t3tools/shared": "workspace:*", |
| ... | ... | @@ -1150,7 +1152,7 @@ |
| 1150 | 1152 | |
| 1151 | 1153 | "effect-codex-app-server": ["effect-codex-app-server@workspace:packages/effect-codex-app-server"], |
| 1152 | 1154 | |
| 1153 | "electron": ["electron@40.9.3", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-rDcJOT6BBE689Ada+4jD3rVr05pMv9MZOgT0x/rIMVDF9c4ttx4RTb6lVARTyxZC7uqpirttCtcli1eg1DX5qg=="], | |
| 1155 | "electron": ["electron@41.5.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-x9j9//PubUA4EjDtQbZhtk3prolandqCKgit0uCIqc1jb8FTskPbnJtxcDFB1aejczJcuERgjPixBUaMwoWyJg=="], | |
| 1154 | 1156 | |
| 1155 | 1157 | "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="], |
| 1156 | 1158 |
packages/client-runtime/tsconfig.json+14| ... | ... | @@ -1,4 +1,18 @@ |
| 1 | 1 | { |
| 2 | 2 | "extends": "../../tsconfig.base.json", |
| 3 | "compilerOptions": { | |
| 4 | "plugins": [ | |
| 5 | { | |
| 6 | "name": "@effect/language-service", | |
| 7 | "namespaceImportPackages": ["@effect/platform-node", "effect"], | |
| 8 | "diagnosticSeverity": { | |
| 9 | "importFromBarrel": "error", | |
| 10 | "anyUnknownInErrorContext": "error", | |
| 11 | "instanceOfSchema": "warning", | |
| 12 | "deterministicKeys": "warning" | |
| 13 | } | |
| 14 | } | |
| 15 | ] | |
| 16 | }, | |
| 3 | 17 | "include": ["src"] |
| 4 | 18 | } |
packages/contracts/src/auth.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | 2 | |
| 3 | 3 | import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | 4 |
packages/contracts/src/baseSchemas.ts+2-1| ... | ... | @@ -1,10 +1,11 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | 2 | |
| 3 | 3 | export const TrimmedString = Schema.Trim; |
| 4 | 4 | export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()); |
| 5 | 5 | |
| 6 | 6 | export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); |
| 7 | 7 | export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); |
| 8 | export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); | |
| 8 | 9 | |
| 9 | 10 | export const IsoDateTime = Schema.String; |
| 10 | 11 | export type IsoDateTime = typeof IsoDateTime.Type; |
packages/contracts/src/desktopBootstrap.ts created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | ||
| 3 | import { PortSchema } from "./baseSchemas.ts"; | |
| 4 | ||
| 5 | export const DesktopBackendBootstrap = Schema.Struct({ | |
| 6 | mode: Schema.Literal("desktop"), | |
| 7 | noBrowser: Schema.Boolean, | |
| 8 | port: PortSchema, | |
| 9 | t3Home: Schema.String, | |
| 10 | host: Schema.String, | |
| 11 | desktopBootstrapToken: Schema.String, | |
| 12 | tailscaleServeEnabled: Schema.Boolean, | |
| 13 | tailscaleServePort: PortSchema, | |
| 14 | otlpTracesUrl: Schema.optional(Schema.String), | |
| 15 | otlpMetricsUrl: Schema.optional(Schema.String), | |
| 16 | }); | |
| 17 | ||
| 18 | export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type; |
packages/contracts/src/editor.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | 3 | |
| 4 | 4 | export const EditorLaunchStyle = Schema.Literals(["direct-path", "goto", "line-column"]); |
packages/contracts/src/environment.ts+2-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | import { Effect, Schema } from "effect"; | |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 2 | 3 | |
| 3 | 4 | import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | 5 |
packages/contracts/src/filesystem.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | 3 | |
| 4 | 4 | const FILESYSTEM_PATH_MAX_LENGTH = 512; |
packages/contracts/src/git.test.ts+1-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { Schema } from "effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | VcsCreateWorktreeInput, |
packages/contracts/src/git.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | 2 | import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | 3 | import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; |
| 4 | 4 | import { VcsDriverKind } from "./vcs.ts"; |
packages/contracts/src/index.ts+1| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | export * from "./baseSchemas.ts"; |
| 2 | 2 | export * from "./auth.ts"; |
| 3 | 3 | export * from "./environment.ts"; |
| 4 | export * from "./desktopBootstrap.ts"; | |
| 4 | 5 | export * from "./remoteAccess.ts"; |
| 5 | 6 | export * from "./ipc.ts"; |
| 6 | 7 | export * from "./terminal.ts"; |
packages/contracts/src/ipc.ts+187-23| ... | ... | @@ -48,6 +48,7 @@ import type { |
| 48 | 48 | TerminalWriteInput, |
| 49 | 49 | } from "./terminal.ts"; |
| 50 | 50 | import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts"; |
| 51 | import * as Schema from "effect/Schema"; | |
| 51 | 52 | import type { |
| 52 | 53 | ClientOrchestrationCommand, |
| 53 | 54 | OrchestrationGetFullThreadDiffInput, |
| ... | ... | @@ -58,15 +59,11 @@ import type { |
| 58 | 59 | OrchestrationSubscribeThreadInput, |
| 59 | 60 | OrchestrationThreadStreamItem, |
| 60 | 61 | } from "./orchestration.ts"; |
| 61 | import type { EnvironmentId } from "./baseSchemas.ts"; | |
| 62 | import type { | |
| 63 | AuthBearerBootstrapResult, | |
| 64 | AuthSessionState, | |
| 65 | AuthWebSocketTokenResult, | |
| 66 | } from "./auth.ts"; | |
| 67 | import type { AdvertisedEndpoint } from "./remoteAccess.ts"; | |
| 62 | import { EnvironmentId } from "./baseSchemas.ts"; | |
| 63 | import { AuthBearerBootstrapResult, AuthSessionState, AuthWebSocketTokenResult } from "./auth.ts"; | |
| 64 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; | |
| 68 | 65 | import { EditorId } from "./editor.ts"; |
| 69 | import type { ExecutionEnvironmentDescriptor } from "./environment.ts"; | |
| 66 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | |
| 70 | 67 | import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts"; |
| 71 | 68 | import type { |
| 72 | 69 | SourceControlCloneRepositoryInput, |
| ... | ... | @@ -86,6 +83,26 @@ export interface ContextMenuItem<T extends string = string> { |
| 86 | 83 | children?: readonly ContextMenuItem<T>[]; |
| 87 | 84 | } |
| 88 | 85 | |
| 86 | export interface ContextMenuItemSchemaType { | |
| 87 | readonly id: string; | |
| 88 | readonly label: string; | |
| 89 | readonly destructive?: boolean; | |
| 90 | readonly disabled?: boolean; | |
| 91 | readonly children?: readonly ContextMenuItemSchemaType[]; | |
| 92 | } | |
| 93 | ||
| 94 | export const ContextMenuItemSchema: Schema.Codec<ContextMenuItemSchemaType> = Schema.Struct({ | |
| 95 | id: Schema.String, | |
| 96 | label: Schema.String, | |
| 97 | destructive: Schema.optionalKey(Schema.Boolean), | |
| 98 | disabled: Schema.optionalKey(Schema.Boolean), | |
| 99 | children: Schema.optionalKey( | |
| 100 | Schema.Array( | |
| 101 | Schema.suspend((): Schema.Codec<ContextMenuItemSchemaType> => ContextMenuItemSchema), | |
| 102 | ), | |
| 103 | ), | |
| 104 | }); | |
| 105 | ||
| 89 | 106 | export type DesktopUpdateStatus = |
| 90 | 107 | | "disabled" |
| 91 | 108 | | "idle" |
| ... | ... | @@ -101,18 +118,45 @@ export type DesktopTheme = "light" | "dark" | "system"; |
| 101 | 118 | export type DesktopUpdateChannel = "latest" | "nightly"; |
| 102 | 119 | export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; |
| 103 | 120 | |
| 121 | export const DesktopUpdateStatusSchema = Schema.Literals([ | |
| 122 | "disabled", | |
| 123 | "idle", | |
| 124 | "checking", | |
| 125 | "up-to-date", | |
| 126 | "available", | |
| 127 | "downloading", | |
| 128 | "downloaded", | |
| 129 | "error", | |
| 130 | ]); | |
| 131 | export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other"]); | |
| 132 | export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); | |
| 133 | export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); | |
| 134 | export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); | |
| 135 | ||
| 104 | 136 | export interface DesktopAppBranding { |
| 105 | 137 | baseName: string; |
| 106 | 138 | stageLabel: DesktopAppStageLabel; |
| 107 | 139 | displayName: string; |
| 108 | 140 | } |
| 109 | 141 | |
| 142 | export const DesktopAppBrandingSchema = Schema.Struct({ | |
| 143 | baseName: Schema.String, | |
| 144 | stageLabel: DesktopAppStageLabelSchema, | |
| 145 | displayName: Schema.String, | |
| 146 | }); | |
| 147 | ||
| 110 | 148 | export interface DesktopRuntimeInfo { |
| 111 | 149 | hostArch: DesktopRuntimeArch; |
| 112 | 150 | appArch: DesktopRuntimeArch; |
| 113 | 151 | runningUnderArm64Translation: boolean; |
| 114 | 152 | } |
| 115 | 153 | |
| 154 | export const DesktopRuntimeInfoSchema = Schema.Struct({ | |
| 155 | hostArch: DesktopRuntimeArchSchema, | |
| 156 | appArch: DesktopRuntimeArchSchema, | |
| 157 | runningUnderArm64Translation: Schema.Boolean, | |
| 158 | }); | |
| 159 | ||
| 116 | 160 | export interface DesktopUpdateState { |
| 117 | 161 | enabled: boolean; |
| 118 | 162 | status: DesktopUpdateStatus; |
| ... | ... | @@ -130,17 +174,45 @@ export interface DesktopUpdateState { |
| 130 | 174 | canRetry: boolean; |
| 131 | 175 | } |
| 132 | 176 | |
| 177 | export const DesktopUpdateStateSchema = Schema.Struct({ | |
| 178 | enabled: Schema.Boolean, | |
| 179 | status: DesktopUpdateStatusSchema, | |
| 180 | channel: DesktopUpdateChannelSchema, | |
| 181 | currentVersion: Schema.String, | |
| 182 | hostArch: DesktopRuntimeArchSchema, | |
| 183 | appArch: DesktopRuntimeArchSchema, | |
| 184 | runningUnderArm64Translation: Schema.Boolean, | |
| 185 | availableVersion: Schema.NullOr(Schema.String), | |
| 186 | downloadedVersion: Schema.NullOr(Schema.String), | |
| 187 | downloadPercent: Schema.NullOr(Schema.Number), | |
| 188 | checkedAt: Schema.NullOr(Schema.String), | |
| 189 | message: Schema.NullOr(Schema.String), | |
| 190 | errorContext: Schema.NullOr(Schema.Literals(["check", "download", "install"])), | |
| 191 | canRetry: Schema.Boolean, | |
| 192 | }); | |
| 193 | ||
| 133 | 194 | export interface DesktopUpdateActionResult { |
| 134 | 195 | accepted: boolean; |
| 135 | 196 | completed: boolean; |
| 136 | 197 | state: DesktopUpdateState; |
| 137 | 198 | } |
| 138 | 199 | |
| 200 | export const DesktopUpdateActionResultSchema = Schema.Struct({ | |
| 201 | accepted: Schema.Boolean, | |
| 202 | completed: Schema.Boolean, | |
| 203 | state: DesktopUpdateStateSchema, | |
| 204 | }); | |
| 205 | ||
| 139 | 206 | export interface DesktopUpdateCheckResult { |
| 140 | 207 | checked: boolean; |
| 141 | 208 | state: DesktopUpdateState; |
| 142 | 209 | } |
| 143 | 210 | |
| 211 | export const DesktopUpdateCheckResultSchema = Schema.Struct({ | |
| 212 | checked: Schema.Boolean, | |
| 213 | state: DesktopUpdateStateSchema, | |
| 214 | }); | |
| 215 | ||
| 144 | 216 | export interface DesktopEnvironmentBootstrap { |
| 145 | 217 | label: string; |
| 146 | 218 | httpBaseUrl: string | null; |
| ... | ... | @@ -148,19 +220,36 @@ export interface DesktopEnvironmentBootstrap { |
| 148 | 220 | bootstrapToken?: string; |
| 149 | 221 | } |
| 150 | 222 | |
| 151 | export interface DesktopSshEnvironmentTarget { | |
| 152 | alias: string; | |
| 153 | hostname: string; | |
| 154 | username: string | null; | |
| 155 | port: number | null; | |
| 156 | } | |
| 223 | export const DesktopEnvironmentBootstrapSchema = Schema.Struct({ | |
| 224 | label: Schema.String, | |
| 225 | httpBaseUrl: Schema.NullOr(Schema.String), | |
| 226 | wsBaseUrl: Schema.NullOr(Schema.String), | |
| 227 | bootstrapToken: Schema.optionalKey(Schema.String), | |
| 228 | }); | |
| 229 | ||
| 230 | export const DesktopSshEnvironmentTargetSchema = Schema.Struct({ | |
| 231 | alias: Schema.String, | |
| 232 | hostname: Schema.String, | |
| 233 | username: Schema.NullOr(Schema.String), | |
| 234 | port: Schema.NullOr(Schema.Number), | |
| 235 | }); | |
| 236 | export type DesktopSshEnvironmentTarget = typeof DesktopSshEnvironmentTargetSchema.Type; | |
| 157 | 237 | |
| 158 | 238 | export type DesktopSshHostSource = "ssh-config" | "known-hosts"; |
| 239 | export const DesktopSshHostSourceSchema = Schema.Literals(["ssh-config", "known-hosts"]); | |
| 159 | 240 | |
| 160 | 241 | export interface DesktopDiscoveredSshHost extends DesktopSshEnvironmentTarget { |
| 161 | 242 | source: DesktopSshHostSource; |
| 162 | 243 | } |
| 163 | 244 | |
| 245 | export const DesktopDiscoveredSshHostSchema = Schema.Struct({ | |
| 246 | alias: Schema.String, | |
| 247 | hostname: Schema.String, | |
| 248 | username: Schema.NullOr(Schema.String), | |
| 249 | port: Schema.NullOr(Schema.Number), | |
| 250 | source: DesktopSshHostSourceSchema, | |
| 251 | }); | |
| 252 | ||
| 164 | 253 | export interface DesktopSshEnvironmentBootstrap { |
| 165 | 254 | target: DesktopSshEnvironmentTarget; |
| 166 | 255 | httpBaseUrl: string; |
| ... | ... | @@ -170,6 +259,15 @@ export interface DesktopSshEnvironmentBootstrap { |
| 170 | 259 | remoteServerKind?: "external" | "managed"; |
| 171 | 260 | } |
| 172 | 261 | |
| 262 | export const DesktopSshEnvironmentBootstrapSchema = Schema.Struct({ | |
| 263 | target: DesktopSshEnvironmentTargetSchema, | |
| 264 | httpBaseUrl: Schema.String, | |
| 265 | wsBaseUrl: Schema.String, | |
| 266 | pairingToken: Schema.NullOr(Schema.String), | |
| 267 | remotePort: Schema.optionalKey(Schema.Number), | |
| 268 | remoteServerKind: Schema.optionalKey(Schema.Literals(["external", "managed"])), | |
| 269 | }); | |
| 270 | ||
| 173 | 271 | export interface DesktopSshPasswordPromptRequest { |
| 174 | 272 | requestId: string; |
| 175 | 273 | destination: string; |
| ... | ... | @@ -178,18 +276,72 @@ export interface DesktopSshPasswordPromptRequest { |
| 178 | 276 | expiresAt: string; |
| 179 | 277 | } |
| 180 | 278 | |
| 181 | export interface PersistedSavedEnvironmentRecord { | |
| 182 | environmentId: EnvironmentId; | |
| 183 | label: string; | |
| 184 | wsBaseUrl: string; | |
| 185 | httpBaseUrl: string; | |
| 186 | createdAt: string; | |
| 187 | lastConnectedAt: string | null; | |
| 188 | desktopSsh?: DesktopSshEnvironmentTarget; | |
| 189 | } | |
| 279 | export const DesktopSshPasswordPromptRequestSchema = Schema.Struct({ | |
| 280 | requestId: Schema.String, | |
| 281 | destination: Schema.String, | |
| 282 | username: Schema.NullOr(Schema.String), | |
| 283 | prompt: Schema.String, | |
| 284 | expiresAt: Schema.String, | |
| 285 | }); | |
| 286 | ||
| 287 | export const DesktopSshPasswordPromptCancelledType = "ssh-password-prompt-cancelled" as const; | |
| 288 | ||
| 289 | export const DesktopSshPasswordPromptCancelledResultSchema = Schema.Struct({ | |
| 290 | type: Schema.Literal(DesktopSshPasswordPromptCancelledType), | |
| 291 | message: Schema.String, | |
| 292 | }); | |
| 293 | ||
| 294 | export const DesktopSshEnvironmentEnsureOptionsSchema = Schema.Struct({ | |
| 295 | issuePairingToken: Schema.optionalKey(Schema.Boolean), | |
| 296 | }); | |
| 297 | ||
| 298 | export const DesktopSshEnvironmentEnsureInputSchema = Schema.Struct({ | |
| 299 | target: DesktopSshEnvironmentTargetSchema, | |
| 300 | options: Schema.optionalKey(DesktopSshEnvironmentEnsureOptionsSchema), | |
| 301 | }); | |
| 302 | ||
| 303 | export const DesktopSshEnvironmentEnsureResultSchema = Schema.Union([ | |
| 304 | DesktopSshEnvironmentBootstrapSchema, | |
| 305 | DesktopSshPasswordPromptCancelledResultSchema, | |
| 306 | ]); | |
| 307 | ||
| 308 | export const DesktopSshHttpBaseUrlInputSchema = Schema.Struct({ | |
| 309 | httpBaseUrl: Schema.String, | |
| 310 | }); | |
| 311 | ||
| 312 | export const DesktopSshBearerRequestInputSchema = Schema.Struct({ | |
| 313 | httpBaseUrl: Schema.String, | |
| 314 | bearerToken: Schema.String, | |
| 315 | }); | |
| 316 | ||
| 317 | export const DesktopSshBearerBootstrapInputSchema = Schema.Struct({ | |
| 318 | httpBaseUrl: Schema.String, | |
| 319 | credential: Schema.String, | |
| 320 | }); | |
| 321 | ||
| 322 | export const DesktopSshPasswordPromptResolutionInputSchema = Schema.Struct({ | |
| 323 | requestId: Schema.String, | |
| 324 | password: Schema.NullOr(Schema.String), | |
| 325 | }); | |
| 326 | ||
| 327 | export const PersistedSavedEnvironmentRecordSchema = Schema.Struct({ | |
| 328 | environmentId: EnvironmentId, | |
| 329 | label: Schema.String, | |
| 330 | wsBaseUrl: Schema.String, | |
| 331 | httpBaseUrl: Schema.String, | |
| 332 | createdAt: Schema.String, | |
| 333 | lastConnectedAt: Schema.NullOr(Schema.String), | |
| 334 | desktopSsh: Schema.optionalKey(DesktopSshEnvironmentTargetSchema), | |
| 335 | }); | |
| 336 | export type PersistedSavedEnvironmentRecord = typeof PersistedSavedEnvironmentRecordSchema.Type; | |
| 190 | 337 | |
| 191 | 338 | export type DesktopServerExposureMode = "local-only" | "network-accessible"; |
| 192 | 339 | |
| 340 | export const DesktopServerExposureModeSchema = Schema.Literals([ | |
| 341 | "local-only", | |
| 342 | "network-accessible", | |
| 343 | ]); | |
| 344 | ||
| 193 | 345 | export interface DesktopServerExposureState { |
| 194 | 346 | mode: DesktopServerExposureMode; |
| 195 | 347 | endpointUrl: string | null; |
| ... | ... | @@ -198,10 +350,22 @@ export interface DesktopServerExposureState { |
| 198 | 350 | tailscaleServePort: number; |
| 199 | 351 | } |
| 200 | 352 | |
| 353 | export const DesktopServerExposureStateSchema = Schema.Struct({ | |
| 354 | mode: DesktopServerExposureModeSchema, | |
| 355 | endpointUrl: Schema.NullOr(Schema.String), | |
| 356 | advertisedHost: Schema.NullOr(Schema.String), | |
| 357 | tailscaleServeEnabled: Schema.Boolean, | |
| 358 | tailscaleServePort: Schema.Number, | |
| 359 | }); | |
| 360 | ||
| 201 | 361 | export interface PickFolderOptions { |
| 202 | 362 | initialPath?: string | null; |
| 203 | 363 | } |
| 204 | 364 | |
| 365 | export const PickFolderOptionsSchema = Schema.Struct({ | |
| 366 | initialPath: Schema.optionalKey(Schema.NullOr(Schema.String)), | |
| 367 | }); | |
| 368 | ||
| 205 | 369 | export interface DesktopBridge { |
| 206 | 370 | getAppBranding: () => DesktopAppBranding | null; |
| 207 | 371 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; |
packages/contracts/src/keybindings.test.ts+2-2| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | import { Schema } from "effect"; | |
| 2 | 1 | import { assert, it } from "@effect/vitest"; |
| 3 | import { Effect } from "effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | 6 | KeybindingsConfig, |
packages/contracts/src/keybindings.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | 2 | import { TrimmedString } from "./baseSchemas.ts"; |
| 3 | 3 | |
| 4 | 4 | export const MAX_KEYBINDING_VALUE_LENGTH = 64; |
packages/contracts/src/model.ts+3-1| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | import { Effect, Schema, SchemaTransformation } from "effect"; | |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | import * as SchemaTransformation from "effect/SchemaTransformation"; | |
| 2 | 4 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | 5 | import { ProviderDriverKind } from "./providerInstance.ts"; |
| 4 | 6 |
packages/contracts/src/orchestration.test.ts+2-1| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import assert from "node:assert/strict"; |
| 2 | 2 | import { it } from "@effect/vitest"; |
| 3 | import { Effect, Schema } from "effect"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 4 | 5 | |
| 5 | 6 | import { |
| 6 | 7 | DEFAULT_PROVIDER_INTERACTION_MODE, |
packages/contracts/src/orchestration.ts+6-1| ... | ... | @@ -1,4 +1,9 @@ |
| 1 | import { Effect, Option, Schema, SchemaIssue, SchemaTransformation, Struct } from "effect"; | |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | import * as Schema from "effect/Schema"; | |
| 4 | import * as SchemaIssue from "effect/SchemaIssue"; | |
| 5 | import * as SchemaTransformation from "effect/SchemaTransformation"; | |
| 6 | import * as Struct from "effect/Struct"; | |
| 2 | 7 | import { ProviderOptionSelections } from "./model.ts"; |
| 3 | 8 | import { RepositoryIdentity } from "./environment.ts"; |
| 4 | 9 | import { |
packages/contracts/src/project.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | 2 | import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | 3 | |
| 4 | 4 | const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; |
packages/contracts/src/provider.test.ts+1-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { Schema } from "effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | ProviderEvent, |
packages/contracts/src/provider.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | 3 | import { |
| 4 | 4 | ApprovalRequestId, |
packages/contracts/src/providerInstance.test.ts+1-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { Schema } from "effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | ProviderDriverKind, |
packages/contracts/src/providerInstance.ts+2-1| ... | ... | @@ -33,7 +33,8 @@ |
| 33 | 33 | * |
| 34 | 34 | * @module providerInstance |
| 35 | 35 | */ |
| 36 | import { Effect, Schema } from "effect"; | |
| 36 | import * as Effect from "effect/Effect"; | |
| 37 | import * as Schema from "effect/Schema"; | |
| 37 | 38 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 38 | 39 | |
| 39 | 40 | const PROVIDER_SLUG_MAX_CHARS = 64; |
packages/contracts/src/providerRuntime.test.ts+1-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { Schema } from "effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | 3 | |
| 4 | 4 | import { ProviderRuntimeEvent } from "./providerRuntime.ts"; |
| 5 | 5 |
packages/contracts/src/providerRuntime.ts+2-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | import { Effect, Schema } from "effect"; | |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 2 | 3 | import { |
| 3 | 4 | EventId, |
| 4 | 5 | IsoDateTime, |
packages/contracts/src/remoteAccess.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | 2 | |
| 3 | 3 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | 4 |
packages/contracts/src/rpc.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | 2 | import * as Rpc from "effect/unstable/rpc/Rpc"; |
| 3 | 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; |
| 4 | 4 |
packages/contracts/src/server.test.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | 4 | import { ServerProvider } from "./server.ts"; |
packages/contracts/src/server.ts+2-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | import { Effect, Schema } from "effect"; | |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 2 | 3 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| 3 | 4 | import { ServerAuthDescriptor } from "./auth.ts"; |
| 4 | 5 | import { |
packages/contracts/src/settings.test.ts+1-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { Schema } from "effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | 3 | |
| 4 | 4 | import { ProviderInstanceId } from "./providerInstance.ts"; |
| 5 | 5 | import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts"; |
packages/contracts/src/settings.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Effect } from "effect"; | |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 4 | 4 | import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; |
packages/contracts/src/sourceControl.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | 2 | import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | 3 | import { VcsDriverKind } from "./vcs.ts"; |
| 4 | 4 |
packages/contracts/src/terminal.test.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | 4 | import { |
packages/contracts/src/terminal.ts+2-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | import { Effect, Schema } from "effect"; | |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 2 | 3 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | 4 | |
| 4 | 5 | export const DEFAULT_TERMINAL_ID = "default"; |
packages/contracts/src/vcs.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | 3 | |
| 4 | 4 | export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]); |
packages/contracts/tsconfig.json+2-2| ... | ... | @@ -4,10 +4,10 @@ |
| 4 | 4 | "plugins": [ |
| 5 | 5 | { |
| 6 | 6 | "name": "@effect/language-service", |
| 7 | "namespaceImportPackages": ["@effect/platform-node"], | |
| 7 | "namespaceImportPackages": ["@effect/platform-node", "effect"], | |
| 8 | 8 | "diagnosticSeverity": { |
| 9 | 9 | "importFromBarrel": "error", |
| 10 | "anyUnknownInErrorContext": "warning", | |
| 10 | "anyUnknownInErrorContext": "error", | |
| 11 | 11 | "instanceOfSchema": "warning", |
| 12 | 12 | "deterministicKeys": "warning" |
| 13 | 13 | } |
packages/effect-acp/scripts/generate.ts+6-1| ... | ... | @@ -3,7 +3,12 @@ |
| 3 | 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 4 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | 5 | import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator"; |
| 6 | import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; | |
| 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 Path from "effect/Path"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 7 | 12 | import { Command, Flag } from "effect/unstable/cli"; |
| 8 | 13 | import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 9 | 14 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
packages/effect-acp/tsconfig.json+2-2| ... | ... | @@ -4,10 +4,10 @@ |
| 4 | 4 | "plugins": [ |
| 5 | 5 | { |
| 6 | 6 | "name": "@effect/language-service", |
| 7 | "namespaceImportPackages": ["@effect/platform-node"], | |
| 7 | "namespaceImportPackages": ["@effect/platform-node", "effect"], | |
| 8 | 8 | "diagnosticSeverity": { |
| 9 | 9 | "importFromBarrel": "error", |
| 10 | "anyUnknownInErrorContext": "warning", | |
| 10 | "anyUnknownInErrorContext": "error", | |
| 11 | 11 | "instanceOfSchema": "warning", |
| 12 | 12 | "deterministicKeys": "warning" |
| 13 | 13 | } |
packages/effect-codex-app-server/scripts/generate.ts+6-1| ... | ... | @@ -3,7 +3,12 @@ |
| 3 | 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 4 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | 5 | import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator"; |
| 6 | import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; | |
| 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 Path from "effect/Path"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 7 | 12 | import { |
| 8 | 13 | FetchHttpClient, |
| 9 | 14 | HttpClient, |
packages/effect-codex-app-server/tsconfig.json+2-2| ... | ... | @@ -4,10 +4,10 @@ |
| 4 | 4 | "plugins": [ |
| 5 | 5 | { |
| 6 | 6 | "name": "@effect/language-service", |
| 7 | "namespaceImportPackages": ["@effect/platform-node"], | |
| 7 | "namespaceImportPackages": ["@effect/platform-node", "effect"], | |
| 8 | 8 | "diagnosticSeverity": { |
| 9 | 9 | "importFromBarrel": "error", |
| 10 | "anyUnknownInErrorContext": "warning", | |
| 10 | "anyUnknownInErrorContext": "error", | |
| 11 | 11 | "instanceOfSchema": "warning", |
| 12 | 12 | "deterministicKeys": "warning" |
| 13 | 13 | } |
packages/shared/package.json+4| ... | ... | @@ -20,6 +20,10 @@ |
| 20 | 20 | "types": "./src/logging.ts", |
| 21 | 21 | "import": "./src/logging.ts" |
| 22 | 22 | }, |
| 23 | "./observability": { | |
| 24 | "types": "./src/observability.ts", | |
| 25 | "import": "./src/observability.ts" | |
| 26 | }, | |
| 23 | 27 | "./shell": { |
| 24 | 28 | "types": "./src/shell.ts", |
| 25 | 29 | "import": "./src/shell.ts" |
packages/shared/src/DrainableWorker.test.ts+2-1| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { it } from "@effect/vitest"; |
| 2 | 2 | import { describe, expect } from "vitest"; |
| 3 | import { Deferred, Effect } from "effect"; | |
| 3 | import * as Deferred from "effect/Deferred"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 4 | 5 | |
| 5 | 6 | import { makeDrainableWorker } from "./DrainableWorker.ts"; |
| 6 | 7 |
packages/shared/src/DrainableWorker.ts+4-2| ... | ... | @@ -8,8 +8,10 @@ |
| 8 | 8 | * |
| 9 | 9 | * @module DrainableWorker |
| 10 | 10 | */ |
| 11 | import type { Scope } from "effect"; | |
| 12 | import { Effect, TxQueue, TxRef } from "effect"; | |
| 11 | import * as Scope from "effect/Scope"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as TxQueue from "effect/TxQueue"; | |
| 14 | import * as TxRef from "effect/TxRef"; | |
| 13 | 15 | |
| 14 | 16 | export interface DrainableWorker<A> { |
| 15 | 17 | /** |
packages/shared/src/KeyedCoalescingWorker.test.ts+3-2| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { it } from "@effect/vitest"; |
| 2 | 2 | import { describe, expect } from "vitest"; |
| 3 | import { Deferred, Effect } from "effect"; | |
| 3 | import * as Deferred from "effect/Deferred"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 4 | 5 | |
| 5 | 6 | import { makeKeyedCoalescingWorker } from "./KeyedCoalescingWorker.ts"; |
| 6 | 7 | |
| ... | ... | @@ -73,7 +74,7 @@ describe("makeKeyedCoalescingWorker", () => { |
| 73 | 74 | if (value === "first") { |
| 74 | 75 | yield* Deferred.succeed(firstStarted, undefined).pipe(Effect.orDie); |
| 75 | 76 | yield* Deferred.await(releaseFailure); |
| 76 | yield* Effect.fail("boom"); | |
| 77 | return yield* Effect.fail("boom"); | |
| 77 | 78 | } |
| 78 | 79 | |
| 79 | 80 | if (value === "second") { |
packages/shared/src/KeyedCoalescingWorker.ts+4-2| ... | ... | @@ -7,8 +7,10 @@ |
| 7 | 7 | * |
| 8 | 8 | * @module KeyedCoalescingWorker |
| 9 | 9 | */ |
| 10 | import type { Scope } from "effect"; | |
| 11 | import { Effect, TxQueue, TxRef } from "effect"; | |
| 10 | import * as Scope from "effect/Scope"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as TxQueue from "effect/TxQueue"; | |
| 13 | import * as TxRef from "effect/TxRef"; | |
| 12 | 14 | |
| 13 | 15 | export interface KeyedCoalescingWorker<K, V> { |
| 14 | 16 | readonly enqueue: (key: K, value: V) => Effect.Effect<void>; |
packages/shared/src/Net.test.ts+15-15| ... | ... | @@ -1,11 +1,11 @@ |
| 1 | import * as Net from "node:net"; | |
| 1 | import * as NodeNet from "node:net"; | |
| 2 | 2 | |
| 3 | 3 | import { assert, describe, it } from "@effect/vitest"; |
| 4 | import { Effect } from "effect"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | 5 | |
| 6 | import { NetError, NetService } from "./Net.ts"; | |
| 6 | import * as Net from "./Net.ts"; | |
| 7 | 7 | |
| 8 | const closeServer = (server: Net.Server) => | |
| 8 | const closeServer = (server: NodeNet.Server) => | |
| 9 | 9 | Effect.sync(() => { |
| 10 | 10 | try { |
| 11 | 11 | server.close(); |
| ... | ... | @@ -14,24 +14,24 @@ const closeServer = (server: Net.Server) => |
| 14 | 14 | } |
| 15 | 15 | }); |
| 16 | 16 | |
| 17 | const getPort = (server: Net.Server): number => { | |
| 17 | const getPort = (server: NodeNet.Server): number => { | |
| 18 | 18 | const address = server.address(); |
| 19 | 19 | return typeof address === "object" && address !== null ? address.port : 0; |
| 20 | 20 | }; |
| 21 | 21 | |
| 22 | const openServer = (host?: string): Effect.Effect<Net.Server, NetError> => | |
| 23 | Effect.callback<Net.Server, NetError>((resume) => { | |
| 24 | const server = Net.createServer(); | |
| 22 | const openServer = (host?: string): Effect.Effect<NodeNet.Server, Net.NetError> => | |
| 23 | Effect.callback<NodeNet.Server, Net.NetError>((resume) => { | |
| 24 | const server = NodeNet.createServer(); | |
| 25 | 25 | let settled = false; |
| 26 | 26 | |
| 27 | const settle = (effect: Effect.Effect<Net.Server, NetError>) => { | |
| 27 | const settle = (effect: Effect.Effect<NodeNet.Server, Net.NetError>) => { | |
| 28 | 28 | if (settled) return; |
| 29 | 29 | settled = true; |
| 30 | 30 | resume(effect); |
| 31 | 31 | }; |
| 32 | 32 | |
| 33 | 33 | server.once("error", (cause) => { |
| 34 | settle(Effect.fail(new NetError({ message: "Failed to open test server", cause }))); | |
| 34 | settle(Effect.fail(new Net.NetError({ message: "Failed to open test server", cause }))); | |
| 35 | 35 | }); |
| 36 | 36 | |
| 37 | 37 | if (host) { |
| ... | ... | @@ -43,11 +43,11 @@ const openServer = (host?: string): Effect.Effect<Net.Server, NetError> => |
| 43 | 43 | return closeServer(server); |
| 44 | 44 | }); |
| 45 | 45 | |
| 46 | it.layer(NetService.layer)("NetService", (it) => { | |
| 46 | it.layer(Net.NetService.layer)("NetService", (it) => { | |
| 47 | 47 | describe("Net helpers", () => { |
| 48 | 48 | it.effect("reserveLoopbackPort returns a positive loopback port", () => |
| 49 | 49 | Effect.gen(function* () { |
| 50 | const net = yield* NetService; | |
| 50 | const net = yield* Net.NetService; | |
| 51 | 51 | const port = yield* net.reserveLoopbackPort(); |
| 52 | 52 | |
| 53 | 53 | assert.ok(port > 0); |
| ... | ... | @@ -59,7 +59,7 @@ it.layer(NetService.layer)("NetService", (it) => { |
| 59 | 59 | openServer("127.0.0.1"), |
| 60 | 60 | (server) => |
| 61 | 61 | Effect.gen(function* () { |
| 62 | const net = yield* NetService; | |
| 62 | const net = yield* Net.NetService; | |
| 63 | 63 | const port = getPort(server); |
| 64 | 64 | |
| 65 | 65 | const available = yield* net.isPortAvailableOnLoopback(port); |
| ... | ... | @@ -71,7 +71,7 @@ it.layer(NetService.layer)("NetService", (it) => { |
| 71 | 71 | |
| 72 | 72 | it.effect("findAvailablePort returns preferred when it is free", () => |
| 73 | 73 | Effect.gen(function* () { |
| 74 | const net = yield* NetService; | |
| 74 | const net = yield* Net.NetService; | |
| 75 | 75 | const preferred = yield* net.reserveLoopbackPort(); |
| 76 | 76 | |
| 77 | 77 | const resolved = yield* net.findAvailablePort(preferred); |
| ... | ... | @@ -84,7 +84,7 @@ it.layer(NetService.layer)("NetService", (it) => { |
| 84 | 84 | openServer(), |
| 85 | 85 | (server) => |
| 86 | 86 | Effect.gen(function* () { |
| 87 | const net = yield* NetService; | |
| 87 | const net = yield* Net.NetService; | |
| 88 | 88 | const preferred = getPort(server); |
| 89 | 89 | |
| 90 | 90 | const resolved = yield* net.findAvailablePort(preferred); |
packages/shared/src/Net.ts+95-88| ... | ... | @@ -1,6 +1,9 @@ |
| 1 | import * as Net from "node:net"; | |
| 1 | import * as NodeNet from "node:net"; | |
| 2 | 2 | |
| 3 | import { Data, Effect, Layer, Context } from "effect"; | |
| 3 | import * as Data from "effect/Data"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Context from "effect/Context"; | |
| 4 | 7 | |
| 5 | 8 | export class NetError extends Data.TaggedError("NetError")<{ |
| 6 | 9 | readonly message: string; |
| ... | ... | @@ -18,7 +21,7 @@ function isErrnoExceptionWithCode(cause: unknown): cause is { |
| 18 | 21 | ); |
| 19 | 22 | } |
| 20 | 23 | |
| 21 | const closeServer = (server: Net.Server) => { | |
| 24 | const closeServer = (server: NodeNet.Server) => { | |
| 22 | 25 | try { |
| 23 | 26 | server.close(); |
| 24 | 27 | } catch { |
| ... | ... | @@ -28,7 +31,7 @@ const closeServer = (server: Net.Server) => { |
| 28 | 31 | |
| 29 | 32 | const tryReservePort = (port: number): Effect.Effect<number, NetError> => |
| 30 | 33 | Effect.callback<number, NetError>((resume) => { |
| 31 | const server = Net.createServer(); | |
| 34 | const server = NodeNet.createServer(); | |
| 32 | 35 | let settled = false; |
| 33 | 36 | |
| 34 | 37 | const settle = (effect: Effect.Effect<number, NetError>) => { |
| ... | ... | @@ -82,99 +85,103 @@ export interface NetServiceShape { |
| 82 | 85 | readonly findAvailablePort: (preferred: number) => Effect.Effect<number, NetError>; |
| 83 | 86 | } |
| 84 | 87 | |
| 85 | /** | |
| 86 | * NetService - Service tag for startup networking helpers. | |
| 87 | */ | |
| 88 | export class NetService extends Context.Service<NetService, NetServiceShape>()( | |
| 89 | "@t3tools/shared/Net/NetService", | |
| 90 | ) { | |
| 91 | static readonly layer = Layer.sync(NetService, () => { | |
| 92 | /** | |
| 93 | * Returns true when a TCP server can bind to {host, port}. | |
| 94 | * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail | |
| 95 | * loopback availability checks. | |
| 96 | */ | |
| 97 | const canListenOnHost = (port: number, host: string): Effect.Effect<boolean> => | |
| 98 | Effect.callback<boolean>((resume) => { | |
| 99 | const server = Net.createServer(); | |
| 100 | let settled = false; | |
| 101 | ||
| 102 | const settle = (value: boolean) => { | |
| 103 | if (settled) return; | |
| 104 | settled = true; | |
| 105 | resume(Effect.succeed(value)); | |
| 106 | }; | |
| 107 | ||
| 108 | server.unref(); | |
| 109 | ||
| 110 | server.once("error", (cause) => { | |
| 111 | if (isErrnoExceptionWithCode(cause) && cause.code === "EADDRNOTAVAIL") { | |
| 112 | settle(true); | |
| 113 | return; | |
| 114 | } | |
| 115 | settle(false); | |
| 116 | }); | |
| 88 | export const make = () => { | |
| 89 | /** | |
| 90 | * Returns true when a TCP server can bind to {host, port}. | |
| 91 | * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail | |
| 92 | * loopback availability checks. | |
| 93 | */ | |
| 94 | const canListenOnHost = (port: number, host: string): Effect.Effect<boolean> => | |
| 95 | Effect.callback<boolean>((resume) => { | |
| 96 | const server = NodeNet.createServer(); | |
| 97 | let settled = false; | |
| 98 | ||
| 99 | const settle = (value: boolean) => { | |
| 100 | if (settled) return; | |
| 101 | settled = true; | |
| 102 | resume(Effect.succeed(value)); | |
| 103 | }; | |
| 104 | ||
| 105 | server.unref(); | |
| 106 | ||
| 107 | server.once("error", (cause) => { | |
| 108 | if (isErrnoExceptionWithCode(cause) && cause.code === "EADDRNOTAVAIL") { | |
| 109 | settle(true); | |
| 110 | return; | |
| 111 | } | |
| 112 | settle(false); | |
| 113 | }); | |
| 117 | 114 | |
| 118 | server.once("listening", () => { | |
| 119 | server.close(() => { | |
| 120 | settle(true); | |
| 121 | }); | |
| 115 | server.once("listening", () => { | |
| 116 | server.close(() => { | |
| 117 | settle(true); | |
| 122 | 118 | }); |
| 119 | }); | |
| 123 | 120 | |
| 124 | server.listen({ host, port }); | |
| 121 | server.listen({ host, port }); | |
| 125 | 122 | |
| 126 | return Effect.sync(() => { | |
| 127 | closeServer(server); | |
| 128 | }); | |
| 123 | return Effect.sync(() => { | |
| 124 | closeServer(server); | |
| 129 | 125 | }); |
| 126 | }); | |
| 130 | 127 | |
| 131 | /** | |
| 132 | * Reserve an ephemeral loopback port and release it immediately. | |
| 133 | * Returns the reserved port number. | |
| 134 | */ | |
| 135 | const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect<number, NetError> => | |
| 136 | Effect.callback<number, NetError>((resume) => { | |
| 137 | const probe = Net.createServer(); | |
| 138 | let settled = false; | |
| 139 | ||
| 140 | const settle = (effect: Effect.Effect<number, NetError>) => { | |
| 141 | if (settled) return; | |
| 142 | settled = true; | |
| 143 | resume(effect); | |
| 144 | }; | |
| 145 | ||
| 146 | probe.once("error", (cause) => { | |
| 147 | settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port", cause }))); | |
| 148 | }); | |
| 128 | /** | |
| 129 | * Reserve an ephemeral loopback port and release it immediately. | |
| 130 | * Returns the reserved port number. | |
| 131 | */ | |
| 132 | const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect<number, NetError> => | |
| 133 | Effect.callback<number, NetError>((resume) => { | |
| 134 | const probe = NodeNet.createServer(); | |
| 135 | let settled = false; | |
| 136 | ||
| 137 | const settle = (effect: Effect.Effect<number, NetError>) => { | |
| 138 | if (settled) return; | |
| 139 | settled = true; | |
| 140 | resume(effect); | |
| 141 | }; | |
| 142 | ||
| 143 | probe.once("error", (cause) => { | |
| 144 | settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port", cause }))); | |
| 145 | }); | |
| 149 | 146 | |
| 150 | probe.listen(0, host, () => { | |
| 151 | const address = probe.address(); | |
| 152 | const port = typeof address === "object" && address !== null ? address.port : 0; | |
| 153 | probe.close(() => { | |
| 154 | if (port > 0) { | |
| 155 | settle(Effect.succeed(port)); | |
| 156 | return; | |
| 157 | } | |
| 158 | settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port" }))); | |
| 159 | }); | |
| 147 | probe.listen(0, host, () => { | |
| 148 | const address = probe.address(); | |
| 149 | const port = typeof address === "object" && address !== null ? address.port : 0; | |
| 150 | probe.close(() => { | |
| 151 | if (port > 0) { | |
| 152 | settle(Effect.succeed(port)); | |
| 153 | return; | |
| 154 | } | |
| 155 | settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port" }))); | |
| 160 | 156 | }); |
| 157 | }); | |
| 161 | 158 | |
| 162 | return Effect.sync(() => { | |
| 163 | closeServer(probe); | |
| 164 | }); | |
| 159 | return Effect.sync(() => { | |
| 160 | closeServer(probe); | |
| 165 | 161 | }); |
| 162 | }); | |
| 166 | 163 | |
| 167 | return { | |
| 168 | canListenOnHost, | |
| 169 | isPortAvailableOnLoopback: (port) => | |
| 170 | Effect.zipWith( | |
| 171 | canListenOnHost(port, "127.0.0.1"), | |
| 172 | canListenOnHost(port, "::1"), | |
| 173 | (ipv4, ipv6) => ipv4 && ipv6, | |
| 174 | ), | |
| 175 | reserveLoopbackPort, | |
| 176 | findAvailablePort: (preferred) => | |
| 177 | Effect.catch(tryReservePort(preferred), () => tryReservePort(0)), | |
| 178 | } satisfies NetServiceShape; | |
| 179 | }); | |
| 164 | return { | |
| 165 | canListenOnHost, | |
| 166 | isPortAvailableOnLoopback: (port) => | |
| 167 | Effect.zipWith( | |
| 168 | canListenOnHost(port, "127.0.0.1"), | |
| 169 | canListenOnHost(port, "::1"), | |
| 170 | (ipv4, ipv6) => ipv4 && ipv6, | |
| 171 | ), | |
| 172 | reserveLoopbackPort, | |
| 173 | findAvailablePort: (preferred) => | |
| 174 | Effect.catch(tryReservePort(preferred), () => tryReservePort(0)), | |
| 175 | } satisfies NetServiceShape; | |
| 176 | }; | |
| 177 | ||
| 178 | /** | |
| 179 | * NetService - Service tag for startup networking helpers. | |
| 180 | */ | |
| 181 | export class NetService extends Context.Service<NetService, NetServiceShape>()( | |
| 182 | "@t3tools/shared/Net/NetService", | |
| 183 | ) { | |
| 184 | static readonly layer = Layer.sync(NetService, make); | |
| 180 | 185 | } |
| 186 | ||
| 187 | export const layer = NetService.layer; |
packages/shared/src/observability.test.ts created+314| ... | ... | @@ -0,0 +1,314 @@ |
| 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 * as Effect from "effect/Effect"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Logger from "effect/Logger"; | |
| 9 | import * as References from "effect/References"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | import * as Tracer from "effect/Tracer"; | |
| 12 | ||
| 13 | import { | |
| 14 | compactTraceAttributes, | |
| 15 | makeLocalFileTracer, | |
| 16 | makeTraceSink, | |
| 17 | type TraceRecord, | |
| 18 | } from "./observability.ts"; | |
| 19 | ||
| 20 | const TraceRecordLine = Schema.Struct({ | |
| 21 | name: Schema.String, | |
| 22 | spanId: Schema.String, | |
| 23 | parentSpanId: Schema.optional(Schema.String), | |
| 24 | attributes: Schema.Record(Schema.String, Schema.Unknown), | |
| 25 | events: Schema.Array( | |
| 26 | Schema.Struct({ | |
| 27 | name: Schema.String, | |
| 28 | attributes: Schema.Record(Schema.String, Schema.Unknown), | |
| 29 | }), | |
| 30 | ), | |
| 31 | exit: Schema.optional( | |
| 32 | Schema.Struct({ | |
| 33 | _tag: Schema.String, | |
| 34 | }), | |
| 35 | ), | |
| 36 | }); | |
| 37 | ||
| 38 | const decodeTraceRecordLine = Schema.decodeUnknownSync(Schema.fromJsonString(TraceRecordLine)); | |
| 39 | ||
| 40 | const makeRecord = (name: string, suffix = ""): TraceRecord => ({ | |
| 41 | type: "effect-span", | |
| 42 | name, | |
| 43 | traceId: `trace-${name}-${suffix}`, | |
| 44 | spanId: `span-${name}-${suffix}`, | |
| 45 | sampled: true, | |
| 46 | kind: "internal", | |
| 47 | startTimeUnixNano: "1", | |
| 48 | endTimeUnixNano: "2", | |
| 49 | durationMs: 1, | |
| 50 | attributes: { | |
| 51 | payload: suffix, | |
| 52 | }, | |
| 53 | events: [], | |
| 54 | links: [], | |
| 55 | exit: { | |
| 56 | _tag: "Success", | |
| 57 | }, | |
| 58 | }); | |
| 59 | ||
| 60 | const readTraceRecords = (tracePath: string) => | |
| 61 | fs | |
| 62 | .readFileSync(tracePath, "utf8") | |
| 63 | .trim() | |
| 64 | .split("\n") | |
| 65 | .filter((line) => line.length > 0) | |
| 66 | .map((line) => decodeTraceRecordLine(line)); | |
| 67 | ||
| 68 | const makeTestLayer = (tracePath: string) => | |
| 69 | Layer.mergeAll( | |
| 70 | Layer.effect( | |
| 71 | Tracer.Tracer, | |
| 72 | makeLocalFileTracer({ | |
| 73 | filePath: tracePath, | |
| 74 | maxBytes: 1024 * 1024, | |
| 75 | maxFiles: 2, | |
| 76 | batchWindowMs: 10_000, | |
| 77 | }), | |
| 78 | ), | |
| 79 | Logger.layer([Logger.tracerLogger], { mergeWithExisting: false }), | |
| 80 | Layer.succeed(References.MinimumLogLevel, "Info"), | |
| 81 | ); | |
| 82 | ||
| 83 | describe("observability", () => { | |
| 84 | it("normalizes circular arrays, maps, and sets without recursing forever", () => { | |
| 85 | const array: Array<unknown> = ["alpha"]; | |
| 86 | array.push(array); | |
| 87 | ||
| 88 | const map = new Map<string, unknown>(); | |
| 89 | map.set("self", map); | |
| 90 | ||
| 91 | const set = new Set<unknown>(); | |
| 92 | set.add(set); | |
| 93 | ||
| 94 | assert.deepStrictEqual( | |
| 95 | compactTraceAttributes({ | |
| 96 | array, | |
| 97 | map, | |
| 98 | set, | |
| 99 | }), | |
| 100 | { | |
| 101 | array: ["alpha", "[Circular]"], | |
| 102 | map: { self: "[Circular]" }, | |
| 103 | set: ["[Circular]"], | |
| 104 | }, | |
| 105 | ); | |
| 106 | }); | |
| 107 | ||
| 108 | it("normalizes invalid dates without throwing", () => { | |
| 109 | assert.deepStrictEqual( | |
| 110 | compactTraceAttributes({ | |
| 111 | invalidDate: new Date("not-a-real-date"), | |
| 112 | }), | |
| 113 | { | |
| 114 | invalidDate: "Invalid Date", | |
| 115 | }, | |
| 116 | ); | |
| 117 | }); | |
| 118 | ||
| 119 | it.effect("flushes buffered trace records on close", () => | |
| 120 | Effect.scoped( | |
| 121 | Effect.gen(function* () { | |
| 122 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); | |
| 123 | const tracePath = path.join(tempDir, "shared.trace.ndjson"); | |
| 124 | ||
| 125 | try { | |
| 126 | const sink = yield* makeTraceSink({ | |
| 127 | filePath: tracePath, | |
| 128 | maxBytes: 1024, | |
| 129 | maxFiles: 2, | |
| 130 | batchWindowMs: 10_000, | |
| 131 | }); | |
| 132 | ||
| 133 | sink.push(makeRecord("alpha")); | |
| 134 | sink.push(makeRecord("beta")); | |
| 135 | yield* sink.close(); | |
| 136 | ||
| 137 | const lines = readTraceRecords(tracePath); | |
| 138 | ||
| 139 | assert.equal(lines.length, 2); | |
| 140 | assert.equal(lines[0]?.name, "alpha"); | |
| 141 | assert.equal(lines[1]?.name, "beta"); | |
| 142 | } finally { | |
| 143 | fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 144 | } | |
| 145 | }), | |
| 146 | ), | |
| 147 | ); | |
| 148 | ||
| 149 | it.effect("rotates the trace file when the configured max size is exceeded", () => | |
| 150 | Effect.scoped( | |
| 151 | Effect.gen(function* () { | |
| 152 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); | |
| 153 | const tracePath = path.join(tempDir, "shared.trace.ndjson"); | |
| 154 | ||
| 155 | try { | |
| 156 | const sink = yield* makeTraceSink({ | |
| 157 | filePath: tracePath, | |
| 158 | maxBytes: 180, | |
| 159 | maxFiles: 2, | |
| 160 | batchWindowMs: 10_000, | |
| 161 | }); | |
| 162 | ||
| 163 | for (let index = 0; index < 8; index += 1) { | |
| 164 | sink.push(makeRecord("rotate", `${index}-${"x".repeat(48)}`)); | |
| 165 | yield* sink.flush; | |
| 166 | } | |
| 167 | yield* sink.close(); | |
| 168 | ||
| 169 | const matchingFiles = fs | |
| 170 | .readdirSync(tempDir) | |
| 171 | .filter( | |
| 172 | (entry) => | |
| 173 | entry === "shared.trace.ndjson" || entry.startsWith("shared.trace.ndjson."), | |
| 174 | ) | |
| 175 | .toSorted(); | |
| 176 | ||
| 177 | assert.equal( | |
| 178 | matchingFiles.some((entry) => entry === "shared.trace.ndjson.1"), | |
| 179 | true, | |
| 180 | ); | |
| 181 | assert.equal( | |
| 182 | matchingFiles.some((entry) => entry === "shared.trace.ndjson.3"), | |
| 183 | false, | |
| 184 | ); | |
| 185 | } finally { | |
| 186 | fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 187 | } | |
| 188 | }), | |
| 189 | ), | |
| 190 | ); | |
| 191 | ||
| 192 | it.effect("drops only the invalid trace record when serialization fails", () => | |
| 193 | Effect.scoped( | |
| 194 | Effect.gen(function* () { | |
| 195 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); | |
| 196 | const tracePath = path.join(tempDir, "shared.trace.ndjson"); | |
| 197 | ||
| 198 | try { | |
| 199 | const sink = yield* makeTraceSink({ | |
| 200 | filePath: tracePath, | |
| 201 | maxBytes: 1024, | |
| 202 | maxFiles: 2, | |
| 203 | batchWindowMs: 10_000, | |
| 204 | }); | |
| 205 | ||
| 206 | const circular: Array<unknown> = []; | |
| 207 | circular.push(circular); | |
| 208 | ||
| 209 | sink.push(makeRecord("alpha")); | |
| 210 | sink.push({ | |
| 211 | ...makeRecord("invalid"), | |
| 212 | attributes: { | |
| 213 | circular, | |
| 214 | }, | |
| 215 | } as TraceRecord); | |
| 216 | sink.push(makeRecord("beta")); | |
| 217 | yield* sink.close(); | |
| 218 | ||
| 219 | const lines = readTraceRecords(tracePath); | |
| 220 | ||
| 221 | assert.deepStrictEqual( | |
| 222 | lines.map((line) => line.name), | |
| 223 | ["alpha", "beta"], | |
| 224 | ); | |
| 225 | } finally { | |
| 226 | fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 227 | } | |
| 228 | }), | |
| 229 | ), | |
| 230 | ); | |
| 231 | ||
| 232 | it.effect("writes nested spans to disk and captures log messages as span events", () => | |
| 233 | Effect.gen(function* () { | |
| 234 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); | |
| 235 | const tracePath = path.join(tempDir, "shared.trace.ndjson"); | |
| 236 | ||
| 237 | try { | |
| 238 | yield* Effect.scoped( | |
| 239 | Effect.gen(function* () { | |
| 240 | const program = Effect.gen(function* () { | |
| 241 | yield* Effect.annotateCurrentSpan({ | |
| 242 | "demo.parent": true, | |
| 243 | }); | |
| 244 | yield* Effect.logInfo("parent event"); | |
| 245 | yield* Effect.gen(function* () { | |
| 246 | yield* Effect.annotateCurrentSpan({ | |
| 247 | "demo.child": true, | |
| 248 | }); | |
| 249 | yield* Effect.logInfo("child event"); | |
| 250 | }).pipe(Effect.withSpan("child-span")); | |
| 251 | }).pipe(Effect.withSpan("parent-span")); | |
| 252 | ||
| 253 | yield* program.pipe(Effect.provide(makeTestLayer(tracePath))); | |
| 254 | }), | |
| 255 | ); | |
| 256 | ||
| 257 | const records = readTraceRecords(tracePath); | |
| 258 | assert.equal(records.length, 2); | |
| 259 | ||
| 260 | const parent = records.find((record) => record.name === "parent-span"); | |
| 261 | const child = records.find((record) => record.name === "child-span"); | |
| 262 | ||
| 263 | assert.notEqual(parent, undefined); | |
| 264 | assert.notEqual(child, undefined); | |
| 265 | if (!parent || !child) { | |
| 266 | return; | |
| 267 | } | |
| 268 | ||
| 269 | assert.equal(child.parentSpanId, parent.spanId); | |
| 270 | assert.equal(parent.attributes["demo.parent"], true); | |
| 271 | assert.equal(child.attributes["demo.child"], true); | |
| 272 | assert.equal( | |
| 273 | parent.events.some((event) => event.name === "parent event"), | |
| 274 | true, | |
| 275 | ); | |
| 276 | assert.equal( | |
| 277 | child.events.some((event) => event.name === "child event"), | |
| 278 | true, | |
| 279 | ); | |
| 280 | assert.equal( | |
| 281 | child.events.some((event) => event.attributes["effect.logLevel"] === "INFO"), | |
| 282 | true, | |
| 283 | ); | |
| 284 | } finally { | |
| 285 | fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 286 | } | |
| 287 | }), | |
| 288 | ); | |
| 289 | ||
| 290 | it.effect("serializes interrupted spans with an interrupted exit status", () => | |
| 291 | Effect.gen(function* () { | |
| 292 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); | |
| 293 | const tracePath = path.join(tempDir, "shared.trace.ndjson"); | |
| 294 | ||
| 295 | try { | |
| 296 | yield* Effect.scoped( | |
| 297 | Effect.exit( | |
| 298 | Effect.interrupt.pipe( | |
| 299 | Effect.withSpan("interrupt-span"), | |
| 300 | Effect.provide(makeTestLayer(tracePath)), | |
| 301 | ), | |
| 302 | ), | |
| 303 | ); | |
| 304 | ||
| 305 | const records = readTraceRecords(tracePath); | |
| 306 | assert.equal(records.length, 1); | |
| 307 | assert.equal(records[0]?.name, "interrupt-span"); | |
| 308 | assert.equal(records[0]?.exit?._tag, "Interrupted"); | |
| 309 | } finally { | |
| 310 | fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 311 | } | |
| 312 | }), | |
| 313 | ); | |
| 314 | }); |
packages/shared/src/observability.ts created+539| ... | ... | @@ -0,0 +1,539 @@ |
| 1 | import * as Cause from "effect/Cause"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import type * as Exit from "effect/Exit"; | |
| 4 | import * as ExitRuntime from "effect/Exit"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as Tracer from "effect/Tracer"; | |
| 7 | import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; | |
| 8 | ||
| 9 | import { RotatingFileSink } from "./logging.ts"; | |
| 10 | ||
| 11 | const FLUSH_BUFFER_THRESHOLD = 32; | |
| 12 | ||
| 13 | export type TraceAttributes = Readonly<Record<string, unknown>>; | |
| 14 | ||
| 15 | export interface TraceRecordEvent { | |
| 16 | readonly name: string; | |
| 17 | readonly timeUnixNano: string; | |
| 18 | readonly attributes: Readonly<Record<string, unknown>>; | |
| 19 | } | |
| 20 | ||
| 21 | export interface TraceRecordLink { | |
| 22 | readonly traceId: string; | |
| 23 | readonly spanId: string; | |
| 24 | readonly attributes: Readonly<Record<string, unknown>>; | |
| 25 | } | |
| 26 | ||
| 27 | interface BaseTraceRecord { | |
| 28 | readonly name: string; | |
| 29 | readonly kind: string; | |
| 30 | readonly traceId: string; | |
| 31 | readonly spanId: string; | |
| 32 | readonly parentSpanId?: string; | |
| 33 | readonly sampled: boolean; | |
| 34 | readonly startTimeUnixNano: string; | |
| 35 | readonly endTimeUnixNano: string; | |
| 36 | readonly durationMs: number; | |
| 37 | readonly attributes: Readonly<Record<string, unknown>>; | |
| 38 | readonly events: ReadonlyArray<TraceRecordEvent>; | |
| 39 | readonly links: ReadonlyArray<TraceRecordLink>; | |
| 40 | } | |
| 41 | ||
| 42 | export interface EffectTraceRecord extends BaseTraceRecord { | |
| 43 | readonly type: "effect-span"; | |
| 44 | readonly exit: | |
| 45 | | { | |
| 46 | readonly _tag: "Success"; | |
| 47 | } | |
| 48 | | { | |
| 49 | readonly _tag: "Interrupted"; | |
| 50 | readonly cause: string; | |
| 51 | } | |
| 52 | | { | |
| 53 | readonly _tag: "Failure"; | |
| 54 | readonly cause: string; | |
| 55 | }; | |
| 56 | } | |
| 57 | ||
| 58 | export interface OtlpTraceRecord extends BaseTraceRecord { | |
| 59 | readonly type: "otlp-span"; | |
| 60 | readonly resourceAttributes: Readonly<Record<string, unknown>>; | |
| 61 | readonly scope: Readonly<{ | |
| 62 | readonly name?: string; | |
| 63 | readonly version?: string; | |
| 64 | readonly attributes: Readonly<Record<string, unknown>>; | |
| 65 | }>; | |
| 66 | readonly status?: | |
| 67 | | { | |
| 68 | readonly code?: string; | |
| 69 | readonly message?: string; | |
| 70 | } | |
| 71 | | undefined; | |
| 72 | } | |
| 73 | ||
| 74 | export type TraceRecord = EffectTraceRecord | OtlpTraceRecord; | |
| 75 | ||
| 76 | export interface TraceSinkOptions { | |
| 77 | readonly filePath: string; | |
| 78 | readonly maxBytes: number; | |
| 79 | readonly maxFiles: number; | |
| 80 | readonly batchWindowMs: number; | |
| 81 | } | |
| 82 | ||
| 83 | export interface TraceSink { | |
| 84 | readonly filePath: string; | |
| 85 | push: (record: TraceRecord) => void; | |
| 86 | flush: Effect.Effect<void>; | |
| 87 | close: () => Effect.Effect<void>; | |
| 88 | } | |
| 89 | ||
| 90 | export interface LocalFileTracerOptions extends TraceSinkOptions { | |
| 91 | readonly delegate?: Tracer.Tracer; | |
| 92 | readonly sink?: TraceSink; | |
| 93 | } | |
| 94 | ||
| 95 | type OtlpSpan = OtlpTracer.ScopeSpan["spans"][number]; | |
| 96 | type OtlpSpanEvent = OtlpSpan["events"][number]; | |
| 97 | type OtlpSpanLink = OtlpSpan["links"][number]; | |
| 98 | type OtlpSpanStatus = OtlpSpan["status"]; | |
| 99 | ||
| 100 | interface SerializableSpan { | |
| 101 | readonly name: string; | |
| 102 | readonly traceId: string; | |
| 103 | readonly spanId: string; | |
| 104 | readonly parent: Option.Option<Tracer.AnySpan>; | |
| 105 | readonly status: Tracer.SpanStatus; | |
| 106 | readonly sampled: boolean; | |
| 107 | readonly kind: Tracer.SpanKind; | |
| 108 | readonly attributes: ReadonlyMap<string, unknown>; | |
| 109 | readonly links: ReadonlyArray<Tracer.SpanLink>; | |
| 110 | readonly events: ReadonlyArray< | |
| 111 | readonly [name: string, startTime: bigint, attributes: Record<string, unknown>] | |
| 112 | >; | |
| 113 | } | |
| 114 | ||
| 115 | function isPlainObject(value: unknown): value is Record<string, unknown> { | |
| 116 | return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 117 | } | |
| 118 | ||
| 119 | function markSeen(value: object, seen: WeakSet<object>): boolean { | |
| 120 | if (seen.has(value)) { | |
| 121 | return true; | |
| 122 | } | |
| 123 | seen.add(value); | |
| 124 | return false; | |
| 125 | } | |
| 126 | ||
| 127 | function normalizeJsonValue(value: unknown, seen: WeakSet<object> = new WeakSet()): unknown { | |
| 128 | if ( | |
| 129 | value === null || | |
| 130 | value === undefined || | |
| 131 | typeof value === "string" || | |
| 132 | typeof value === "number" || | |
| 133 | typeof value === "boolean" | |
| 134 | ) { | |
| 135 | return value ?? null; | |
| 136 | } | |
| 137 | if (typeof value === "bigint") { | |
| 138 | return value.toString(); | |
| 139 | } | |
| 140 | if (value instanceof Date) { | |
| 141 | return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString(); | |
| 142 | } | |
| 143 | if (value instanceof Error) { | |
| 144 | return { | |
| 145 | name: value.name, | |
| 146 | message: value.message, | |
| 147 | ...(value.stack ? { stack: value.stack } : {}), | |
| 148 | }; | |
| 149 | } | |
| 150 | if (Array.isArray(value)) { | |
| 151 | if (markSeen(value, seen)) { | |
| 152 | return "[Circular]"; | |
| 153 | } | |
| 154 | return value.map((entry) => normalizeJsonValue(entry, seen)); | |
| 155 | } | |
| 156 | if (value instanceof Map) { | |
| 157 | if (markSeen(value, seen)) { | |
| 158 | return "[Circular]"; | |
| 159 | } | |
| 160 | return Object.fromEntries( | |
| 161 | Array.from(value.entries(), ([key, entryValue]) => [ | |
| 162 | String(key), | |
| 163 | normalizeJsonValue(entryValue, seen), | |
| 164 | ]), | |
| 165 | ); | |
| 166 | } | |
| 167 | if (value instanceof Set) { | |
| 168 | if (markSeen(value, seen)) { | |
| 169 | return "[Circular]"; | |
| 170 | } | |
| 171 | return Array.from(value.values(), (entry) => normalizeJsonValue(entry, seen)); | |
| 172 | } | |
| 173 | if (!isPlainObject(value)) { | |
| 174 | return String(value); | |
| 175 | } | |
| 176 | if (markSeen(value, seen)) { | |
| 177 | return "[Circular]"; | |
| 178 | } | |
| 179 | return Object.fromEntries( | |
| 180 | Object.entries(value).map(([key, entryValue]) => [key, normalizeJsonValue(entryValue, seen)]), | |
| 181 | ); | |
| 182 | } | |
| 183 | ||
| 184 | export function compactTraceAttributes( | |
| 185 | attributes: Readonly<Record<string, unknown>>, | |
| 186 | ): TraceAttributes { | |
| 187 | return Object.fromEntries( | |
| 188 | Object.entries(attributes) | |
| 189 | .filter(([, value]) => value !== undefined) | |
| 190 | .map(([key, value]) => [key, normalizeJsonValue(value)]), | |
| 191 | ); | |
| 192 | } | |
| 193 | ||
| 194 | function formatTraceExit(exit: Exit.Exit<unknown, unknown>): EffectTraceRecord["exit"] { | |
| 195 | if (ExitRuntime.isSuccess(exit)) { | |
| 196 | return { _tag: "Success" }; | |
| 197 | } | |
| 198 | if (Cause.hasInterruptsOnly(exit.cause)) { | |
| 199 | return { | |
| 200 | _tag: "Interrupted", | |
| 201 | cause: Cause.pretty(exit.cause), | |
| 202 | }; | |
| 203 | } | |
| 204 | return { | |
| 205 | _tag: "Failure", | |
| 206 | cause: Cause.pretty(exit.cause), | |
| 207 | }; | |
| 208 | } | |
| 209 | ||
| 210 | export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { | |
| 211 | const status = span.status as Extract<Tracer.SpanStatus, { _tag: "Ended" }>; | |
| 212 | const parentSpanId = Option.getOrUndefined(span.parent)?.spanId; | |
| 213 | ||
| 214 | return { | |
| 215 | type: "effect-span", | |
| 216 | name: span.name, | |
| 217 | traceId: span.traceId, | |
| 218 | spanId: span.spanId, | |
| 219 | ...(parentSpanId ? { parentSpanId } : {}), | |
| 220 | sampled: span.sampled, | |
| 221 | kind: span.kind, | |
| 222 | startTimeUnixNano: String(status.startTime), | |
| 223 | endTimeUnixNano: String(status.endTime), | |
| 224 | durationMs: Number(status.endTime - status.startTime) / 1_000_000, | |
| 225 | attributes: compactTraceAttributes(Object.fromEntries(span.attributes)), | |
| 226 | events: span.events.map(([name, startTime, attributes]) => ({ | |
| 227 | name, | |
| 228 | timeUnixNano: String(startTime), | |
| 229 | attributes: compactTraceAttributes(attributes), | |
| 230 | })), | |
| 231 | links: span.links.map((link) => ({ | |
| 232 | traceId: link.span.traceId, | |
| 233 | spanId: link.span.spanId, | |
| 234 | attributes: compactTraceAttributes(link.attributes), | |
| 235 | })), | |
| 236 | exit: formatTraceExit(status.exit), | |
| 237 | }; | |
| 238 | } | |
| 239 | ||
| 240 | export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: TraceSinkOptions) { | |
| 241 | const sink = new RotatingFileSink({ | |
| 242 | filePath: options.filePath, | |
| 243 | maxBytes: options.maxBytes, | |
| 244 | maxFiles: options.maxFiles, | |
| 245 | }); | |
| 246 | ||
| 247 | let buffer: Array<string> = []; | |
| 248 | ||
| 249 | const flushUnsafe = () => { | |
| 250 | if (buffer.length === 0) { | |
| 251 | return; | |
| 252 | } | |
| 253 | ||
| 254 | const chunk = buffer.join(""); | |
| 255 | buffer = []; | |
| 256 | ||
| 257 | try { | |
| 258 | sink.write(chunk); | |
| 259 | } catch { | |
| 260 | buffer.unshift(chunk); | |
| 261 | } | |
| 262 | }; | |
| 263 | ||
| 264 | const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false)); | |
| 265 | ||
| 266 | yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore)); | |
| 267 | yield* Effect.forkScoped( | |
| 268 | Effect.sleep(`${options.batchWindowMs} millis`).pipe(Effect.andThen(flush), Effect.forever), | |
| 269 | ); | |
| 270 | ||
| 271 | return { | |
| 272 | filePath: options.filePath, | |
| 273 | push(record) { | |
| 274 | try { | |
| 275 | buffer.push(`${JSON.stringify(record)}\n`); | |
| 276 | if (buffer.length >= FLUSH_BUFFER_THRESHOLD) { | |
| 277 | flushUnsafe(); | |
| 278 | } | |
| 279 | } catch { | |
| 280 | return; | |
| 281 | } | |
| 282 | }, | |
| 283 | flush, | |
| 284 | close: () => flush, | |
| 285 | } satisfies TraceSink; | |
| 286 | }); | |
| 287 | ||
| 288 | class LocalFileSpan implements Tracer.Span { | |
| 289 | readonly _tag = "Span"; | |
| 290 | readonly name: string; | |
| 291 | readonly spanId: string; | |
| 292 | readonly traceId: string; | |
| 293 | readonly parent: Option.Option<Tracer.AnySpan>; | |
| 294 | readonly annotations: Tracer.Span["annotations"]; | |
| 295 | readonly links: Array<Tracer.SpanLink>; | |
| 296 | readonly sampled: boolean; | |
| 297 | readonly kind: Tracer.SpanKind; | |
| 298 | ||
| 299 | status: Tracer.SpanStatus; | |
| 300 | attributes: Map<string, unknown>; | |
| 301 | events: Array<[name: string, startTime: bigint, attributes: Record<string, unknown>]>; | |
| 302 | private readonly delegate: Tracer.Span; | |
| 303 | private readonly push: (record: EffectTraceRecord) => void; | |
| 304 | ||
| 305 | constructor( | |
| 306 | options: Parameters<Tracer.Tracer["span"]>[0], | |
| 307 | delegate: Tracer.Span, | |
| 308 | push: (record: EffectTraceRecord) => void, | |
| 309 | ) { | |
| 310 | this.delegate = delegate; | |
| 311 | this.push = push; | |
| 312 | this.name = delegate.name; | |
| 313 | this.spanId = delegate.spanId; | |
| 314 | this.traceId = delegate.traceId; | |
| 315 | this.parent = options.parent; | |
| 316 | this.annotations = options.annotations; | |
| 317 | this.links = [...options.links]; | |
| 318 | this.sampled = delegate.sampled; | |
| 319 | this.kind = delegate.kind; | |
| 320 | this.status = { | |
| 321 | _tag: "Started", | |
| 322 | startTime: options.startTime, | |
| 323 | }; | |
| 324 | this.attributes = new Map(); | |
| 325 | this.events = []; | |
| 326 | } | |
| 327 | ||
| 328 | end(endTime: bigint, exit: Exit.Exit<unknown, unknown>): void { | |
| 329 | this.status = { | |
| 330 | _tag: "Ended", | |
| 331 | startTime: this.status.startTime, | |
| 332 | endTime, | |
| 333 | exit, | |
| 334 | }; | |
| 335 | this.delegate.end(endTime, exit); | |
| 336 | ||
| 337 | if (this.sampled) { | |
| 338 | this.push(spanToTraceRecord(this)); | |
| 339 | } | |
| 340 | } | |
| 341 | ||
| 342 | attribute(key: string, value: unknown): void { | |
| 343 | this.attributes.set(key, value); | |
| 344 | this.delegate.attribute(key, value); | |
| 345 | } | |
| 346 | ||
| 347 | event(name: string, startTime: bigint, attributes?: Record<string, unknown>): void { | |
| 348 | const nextAttributes = attributes ?? {}; | |
| 349 | this.events.push([name, startTime, nextAttributes]); | |
| 350 | this.delegate.event(name, startTime, nextAttributes); | |
| 351 | } | |
| 352 | ||
| 353 | addLinks(links: ReadonlyArray<Tracer.SpanLink>): void { | |
| 354 | this.links.push(...links); | |
| 355 | this.delegate.addLinks(links); | |
| 356 | } | |
| 357 | } | |
| 358 | ||
| 359 | export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* ( | |
| 360 | options: LocalFileTracerOptions, | |
| 361 | ) { | |
| 362 | const sink = | |
| 363 | options.sink ?? | |
| 364 | (yield* makeTraceSink({ | |
| 365 | filePath: options.filePath, | |
| 366 | maxBytes: options.maxBytes, | |
| 367 | maxFiles: options.maxFiles, | |
| 368 | batchWindowMs: options.batchWindowMs, | |
| 369 | })); | |
| 370 | ||
| 371 | const delegate = | |
| 372 | options.delegate ?? | |
| 373 | Tracer.make({ | |
| 374 | span: (spanOptions) => new Tracer.NativeSpan(spanOptions), | |
| 375 | }); | |
| 376 | ||
| 377 | return Tracer.make({ | |
| 378 | span(spanOptions) { | |
| 379 | return new LocalFileSpan(spanOptions, delegate.span(spanOptions), sink.push); | |
| 380 | }, | |
| 381 | ...(delegate.context ? { context: delegate.context } : {}), | |
| 382 | }); | |
| 383 | }); | |
| 384 | ||
| 385 | const SPAN_KIND_MAP: Record<number, OtlpTraceRecord["kind"]> = { | |
| 386 | 1: "internal", | |
| 387 | 2: "server", | |
| 388 | 3: "client", | |
| 389 | 4: "producer", | |
| 390 | 5: "consumer", | |
| 391 | }; | |
| 392 | ||
| 393 | export function decodeOtlpTraceRecords( | |
| 394 | payload: OtlpTracer.TraceData, | |
| 395 | ): ReadonlyArray<OtlpTraceRecord> { | |
| 396 | const records: Array<OtlpTraceRecord> = []; | |
| 397 | ||
| 398 | for (const resourceSpan of payload.resourceSpans) { | |
| 399 | const resourceAttributes = decodeAttributes(resourceSpan.resource?.attributes ?? []); | |
| 400 | ||
| 401 | for (const scopeSpan of resourceSpan.scopeSpans) { | |
| 402 | for (const span of scopeSpan.spans) { | |
| 403 | records.push( | |
| 404 | otlpSpanToTraceRecord({ | |
| 405 | resourceAttributes, | |
| 406 | scopeAttributes: decodeAttributes( | |
| 407 | "attributes" in scopeSpan.scope && Array.isArray(scopeSpan.scope.attributes) | |
| 408 | ? scopeSpan.scope.attributes | |
| 409 | : [], | |
| 410 | ), | |
| 411 | scopeName: scopeSpan.scope.name, | |
| 412 | scopeVersion: | |
| 413 | "version" in scopeSpan.scope && typeof scopeSpan.scope.version === "string" | |
| 414 | ? scopeSpan.scope.version | |
| 415 | : undefined, | |
| 416 | span, | |
| 417 | }), | |
| 418 | ); | |
| 419 | } | |
| 420 | } | |
| 421 | } | |
| 422 | ||
| 423 | return records; | |
| 424 | } | |
| 425 | ||
| 426 | function otlpSpanToTraceRecord(input: { | |
| 427 | readonly resourceAttributes: Readonly<Record<string, unknown>>; | |
| 428 | readonly scopeAttributes: Readonly<Record<string, unknown>>; | |
| 429 | readonly scopeName: string | undefined; | |
| 430 | readonly scopeVersion: string | undefined; | |
| 431 | readonly span: OtlpSpan; | |
| 432 | }): OtlpTraceRecord { | |
| 433 | return { | |
| 434 | type: "otlp-span", | |
| 435 | name: input.span.name, | |
| 436 | traceId: input.span.traceId, | |
| 437 | spanId: input.span.spanId, | |
| 438 | ...(input.span.parentSpanId ? { parentSpanId: input.span.parentSpanId } : {}), | |
| 439 | sampled: true, | |
| 440 | kind: normalizeSpanKind(input.span.kind), | |
| 441 | startTimeUnixNano: input.span.startTimeUnixNano, | |
| 442 | endTimeUnixNano: input.span.endTimeUnixNano, | |
| 443 | durationMs: | |
| 444 | Number(parseBigInt(input.span.endTimeUnixNano) - parseBigInt(input.span.startTimeUnixNano)) / | |
| 445 | 1_000_000, | |
| 446 | attributes: decodeAttributes(input.span.attributes), | |
| 447 | resourceAttributes: input.resourceAttributes, | |
| 448 | scope: { | |
| 449 | ...(input.scopeName ? { name: input.scopeName } : {}), | |
| 450 | ...(input.scopeVersion ? { version: input.scopeVersion } : {}), | |
| 451 | attributes: input.scopeAttributes, | |
| 452 | }, | |
| 453 | events: decodeEvents(input.span.events), | |
| 454 | links: decodeLinks(input.span.links), | |
| 455 | status: decodeStatus(input.span.status), | |
| 456 | }; | |
| 457 | } | |
| 458 | ||
| 459 | function decodeStatus(input: OtlpSpanStatus): OtlpTraceRecord["status"] { | |
| 460 | const code = String(input.code); | |
| 461 | const message = input.message; | |
| 462 | ||
| 463 | return { | |
| 464 | code, | |
| 465 | ...(message ? { message } : {}), | |
| 466 | }; | |
| 467 | } | |
| 468 | ||
| 469 | function decodeEvents(input: ReadonlyArray<OtlpSpanEvent>): ReadonlyArray<TraceRecordEvent> { | |
| 470 | return input.map((current) => ({ | |
| 471 | name: current.name, | |
| 472 | timeUnixNano: current.timeUnixNano, | |
| 473 | attributes: decodeAttributes(current.attributes), | |
| 474 | })); | |
| 475 | } | |
| 476 | ||
| 477 | function decodeLinks(input: ReadonlyArray<OtlpSpanLink>): ReadonlyArray<TraceRecordLink> { | |
| 478 | return input.flatMap((current) => { | |
| 479 | const traceId = current.traceId; | |
| 480 | const spanId = current.spanId; | |
| 481 | return { | |
| 482 | traceId, | |
| 483 | spanId, | |
| 484 | attributes: decodeAttributes(current.attributes), | |
| 485 | }; | |
| 486 | }); | |
| 487 | } | |
| 488 | ||
| 489 | function decodeAttributes( | |
| 490 | input: ReadonlyArray<OtlpResource.KeyValue>, | |
| 491 | ): Readonly<Record<string, unknown>> { | |
| 492 | const entries: Record<string, unknown> = {}; | |
| 493 | ||
| 494 | for (const attribute of input) { | |
| 495 | entries[attribute.key] = decodeValue(attribute.value); | |
| 496 | } | |
| 497 | ||
| 498 | return compactTraceAttributes(entries); | |
| 499 | } | |
| 500 | ||
| 501 | function decodeValue(input: OtlpResource.AnyValue | null | undefined): unknown { | |
| 502 | if (input == null) { | |
| 503 | return null; | |
| 504 | } | |
| 505 | if ("stringValue" in input) { | |
| 506 | return input.stringValue; | |
| 507 | } | |
| 508 | if ("boolValue" in input) { | |
| 509 | return input.boolValue; | |
| 510 | } | |
| 511 | if ("intValue" in input) { | |
| 512 | return input.intValue; | |
| 513 | } | |
| 514 | if ("doubleValue" in input) { | |
| 515 | return input.doubleValue; | |
| 516 | } | |
| 517 | if ("bytesValue" in input) { | |
| 518 | return input.bytesValue; | |
| 519 | } | |
| 520 | if (input.arrayValue) { | |
| 521 | return input.arrayValue.values.map((entry) => decodeValue(entry)); | |
| 522 | } | |
| 523 | if (input.kvlistValue) { | |
| 524 | return decodeAttributes(input.kvlistValue.values); | |
| 525 | } | |
| 526 | return null; | |
| 527 | } | |
| 528 | ||
| 529 | function normalizeSpanKind(input: number): OtlpTraceRecord["kind"] { | |
| 530 | return SPAN_KIND_MAP[input] || "internal"; | |
| 531 | } | |
| 532 | ||
| 533 | function parseBigInt(input: string): bigint { | |
| 534 | try { | |
| 535 | return BigInt(input); | |
| 536 | } catch { | |
| 537 | return 0n; | |
| 538 | } | |
| 539 | } |
packages/shared/src/schemaJson.ts+9-11| ... | ... | @@ -1,14 +1,12 @@ |
| 1 | import { | |
| 2 | Cause, | |
| 3 | Effect, | |
| 4 | Exit, | |
| 5 | Option, | |
| 6 | Result, | |
| 7 | Schema, | |
| 8 | SchemaGetter, | |
| 9 | SchemaIssue, | |
| 10 | SchemaTransformation, | |
| 11 | } from "effect"; | |
| 1 | import * as Cause from "effect/Cause"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Exit from "effect/Exit"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import * as Result from "effect/Result"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import * as SchemaGetter from "effect/SchemaGetter"; | |
| 8 | import * as SchemaIssue from "effect/SchemaIssue"; | |
| 9 | import * as SchemaTransformation from "effect/SchemaTransformation"; | |
| 12 | 10 | |
| 13 | 11 | export const decodeJsonResult = <S extends Schema.Codec<unknown, unknown, never, never>>( |
| 14 | 12 | schema: S, |
packages/shared/src/serverSettings.ts+1-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; |
| 2 | import { Schema } from "effect"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | 3 | import { deepMerge } from "./Struct.ts"; |
| 4 | 4 | import { fromLenientJson } from "./schemaJson.ts"; |
| 5 | 5 | import { createModelSelection } from "./model.ts"; |
packages/shared/src/shell.ts+2-2| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import * as OS from "node:os"; | |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | 2 | import { execFileSync } from "node:child_process"; |
| 3 | 3 | import { accessSync, constants, statSync } from "node:fs"; |
| 4 | 4 | import { extname, join } from "node:path"; |
| ... | ... | @@ -32,7 +32,7 @@ function trimNonEmpty(value: string | null | undefined): string | undefined { |
| 32 | 32 | |
| 33 | 33 | function readUserLoginShell(): string | undefined { |
| 34 | 34 | try { |
| 35 | return trimNonEmpty(OS.userInfo().shell); | |
| 35 | return trimNonEmpty(NodeOS.userInfo().shell); | |
| 36 | 36 | } catch { |
| 37 | 37 | return undefined; |
| 38 | 38 | } |
packages/shared/tsconfig.json+14| ... | ... | @@ -1,4 +1,18 @@ |
| 1 | 1 | { |
| 2 | 2 | "extends": "../../tsconfig.base.json", |
| 3 | "compilerOptions": { | |
| 4 | "plugins": [ | |
| 5 | { | |
| 6 | "name": "@effect/language-service", | |
| 7 | "namespaceImportPackages": ["@effect/platform-node", "effect"], | |
| 8 | "diagnosticSeverity": { | |
| 9 | "importFromBarrel": "error", | |
| 10 | "anyUnknownInErrorContext": "error", | |
| 11 | "instanceOfSchema": "warning", | |
| 12 | "deterministicKeys": "warning" | |
| 13 | } | |
| 14 | } | |
| 15 | ] | |
| 16 | }, | |
| 3 | 17 | "include": ["src"] |
| 4 | 18 | } |
packages/ssh/src/auth.test.ts+4-1| ... | ... | @@ -1,6 +1,9 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | import { Effect, FileSystem, Path } from "effect"; | |
| 3 | ||
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Path from "effect/Path"; | |
| 4 | 7 | |
| 5 | 8 | import { |
| 6 | 9 | buildSshAskpassHelperDescriptor, |
packages/ssh/src/auth.ts+6-1| ... | ... | @@ -1,4 +1,9 @@ |
| 1 | import { Context, Effect, FileSystem, Layer, Path, PlatformError } from "effect"; | |
| 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 Path from "effect/Path"; | |
| 6 | import * as PlatformError from "effect/PlatformError"; | |
| 2 | 7 | |
| 3 | 8 | import { SshPasswordPromptError } from "./errors.ts"; |
| 4 | 9 |
packages/ssh/src/command.test.ts+7-1| ... | ... | @@ -1,6 +1,12 @@ |
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { Duration, Effect, Fiber, Layer, Result, Sink, Stream } from "effect"; | |
| 3 | import * as Duration from "effect/Duration"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Result from "effect/Result"; | |
| 8 | import * as Sink from "effect/Sink"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 4 | 10 | import { TestClock } from "effect/testing"; |
| 5 | 11 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 6 | 12 |
packages/ssh/src/command.ts+7-1| ... | ... | @@ -1,7 +1,13 @@ |
| 1 | 1 | import * as Crypto from "node:crypto"; |
| 2 | 2 | |
| 3 | 3 | import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts"; |
| 4 | import { Duration, Effect, FileSystem, Option, Path, Scope, Stream } from "effect"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Path from "effect/Path"; | |
| 9 | import * as Scope from "effect/Scope"; | |
| 10 | import * as Stream from "effect/Stream"; | |
| 5 | 11 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 6 | 12 | |
| 7 | 13 | import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts"; |
packages/ssh/src/config.test.ts+3-1| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | import { Effect, FileSystem, Path } from "effect"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Path from "effect/Path"; | |
| 4 | 6 | |
| 5 | 7 | import { |
| 6 | 8 | discoverSshHosts, |
packages/ssh/src/config.ts+4-1| ... | ... | @@ -1,6 +1,9 @@ |
| 1 | 1 | import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | import { Effect, FileSystem, Path, PlatformError } from "effect"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Path from "effect/Path"; | |
| 6 | import * as PlatformError from "effect/PlatformError"; | |
| 4 | 7 | |
| 5 | 8 | import { SshHostDiscoveryError } from "./errors.ts"; |
| 6 | 9 |
packages/ssh/src/errors.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Data } from "effect"; | |
| 1 | import * as Data from "effect/Data"; | |
| 2 | 2 | |
| 3 | 3 | export class SshHostDiscoveryError extends Data.TaggedError("SshHostDiscoveryError")<{ |
| 4 | 4 | readonly message: string; |
packages/ssh/src/tunnel.test.ts+7-1| ... | ... | @@ -1,7 +1,13 @@ |
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 3 | import { NetService } from "@t3tools/shared/Net"; |
| 4 | import { Duration, Effect, Fiber, Layer, Result, Sink, Stream } from "effect"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Fiber from "effect/Fiber"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Result from "effect/Result"; | |
| 9 | import * as Sink from "effect/Sink"; | |
| 10 | import * as Stream from "effect/Stream"; | |
| 5 | 11 | import { TestClock } from "effect/testing"; |
| 6 | 12 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 7 | 13 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
packages/ssh/src/tunnel.ts+20-18| ... | ... | @@ -4,22 +4,20 @@ import type { |
| 4 | 4 | } from "@t3tools/contracts"; |
| 5 | 5 | import { type NetError, NetService } from "@t3tools/shared/Net"; |
| 6 | 6 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 7 | import { | |
| 8 | Deferred, | |
| 9 | Context, | |
| 10 | Duration, | |
| 11 | Effect, | |
| 12 | Exit, | |
| 13 | FileSystem, | |
| 14 | Layer, | |
| 15 | Option, | |
| 16 | Path, | |
| 17 | Ref, | |
| 18 | Schema, | |
| 19 | Scope, | |
| 20 | Schedule, | |
| 21 | Stream, | |
| 22 | } from "effect"; | |
| 7 | import * as Context from "effect/Context"; | |
| 8 | import * as Deferred from "effect/Deferred"; | |
| 9 | import * as Duration from "effect/Duration"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Exit from "effect/Exit"; | |
| 12 | import * as FileSystem from "effect/FileSystem"; | |
| 13 | import * as Layer from "effect/Layer"; | |
| 14 | import * as Option from "effect/Option"; | |
| 15 | import * as Path from "effect/Path"; | |
| 16 | import * as Ref from "effect/Ref"; | |
| 17 | import * as Schedule from "effect/Schedule"; | |
| 18 | import * as Schema from "effect/Schema"; | |
| 19 | import * as Scope from "effect/Scope"; | |
| 20 | import * as Stream from "effect/Stream"; | |
| 23 | 21 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 24 | 22 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 25 | 23 | |
| ... | ... | @@ -64,7 +62,7 @@ export interface RemoteT3RunnerOptions { |
| 64 | 62 | |
| 65 | 63 | export interface SshEnvironmentManagerOptions { |
| 66 | 64 | readonly resolveCliPackageSpec?: () => string; |
| 67 | readonly resolveCliRunner?: () => RemoteT3RunnerOptions; | |
| 65 | readonly resolveCliRunner?: Effect.Effect<RemoteT3RunnerOptions>; | |
| 68 | 66 | } |
| 69 | 67 | |
| 70 | 68 | interface SshTunnelEntry { |
| ... | ... | @@ -1502,7 +1500,11 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma |
| 1502 | 1500 | }); |
| 1503 | 1501 | const packageSpec = options.resolveCliPackageSpec?.(); |
| 1504 | 1502 | const runner = |
| 1505 | options.resolveCliRunner?.() ?? (packageSpec === undefined ? undefined : { packageSpec }); | |
| 1503 | options.resolveCliRunner === undefined | |
| 1504 | ? packageSpec === undefined | |
| 1505 | ? undefined | |
| 1506 | : { packageSpec } | |
| 1507 | : yield* options.resolveCliRunner; | |
| 1506 | 1508 | yield* Effect.logDebug("ssh.environment.runner.resolved", { |
| 1507 | 1509 | ...sshTargetLogFields(resolvedTarget), |
| 1508 | 1510 | ...sshRunnerLogFields(runner), |
packages/ssh/tsconfig.json+2-1| ... | ... | @@ -4,9 +4,10 @@ |
| 4 | 4 | "plugins": [ |
| 5 | 5 | { |
| 6 | 6 | "name": "@effect/language-service", |
| 7 | "namespaceImportPackages": ["@effect/platform-node", "effect"], | |
| 7 | 8 | "diagnosticSeverity": { |
| 8 | 9 | "importFromBarrel": "error", |
| 9 | "anyUnknownInErrorContext": "warning", | |
| 10 | "anyUnknownInErrorContext": "error", | |
| 10 | 11 | "instanceOfSchema": "warning", |
| 11 | 12 | "deterministicKeys": "warning" |
| 12 | 13 | } |
packages/tailscale/src/tailscale.test.ts+4-1| ... | ... | @@ -1,5 +1,8 @@ |
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import { Effect, Layer, Sink, Stream } from "effect"; | |
| 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"; | |
| 3 | 6 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 4 | 7 | |
| 5 | 8 | import { |
packages/tailscale/src/tailscale.ts+5-1| ... | ... | @@ -1,4 +1,8 @@ |
| 1 | import { Data, Effect, Option, Schema, Stream } from "effect"; | |
| 1 | import * as Data from "effect/Data"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Option from "effect/Option"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | import * as Stream from "effect/Stream"; | |
| 2 | 6 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 3 | 7 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 4 | 8 |