| author | |
| committer | |
| log | a9b1190a11276927caf573d92c33c5346fc4c076 |
| tree | cfd50d9e832643984aa6a5ccc646d5fe5d134688 |
| parent | 44fb34ad5383badfb02c9f7daa1d8486277eeb01 |
| signature | Signed by PGP key B5690EEEBB952194 |
83 files changed, 11424 insertions(+), 2071 deletions(-)
.github/workflows/release.yml+67-2| ... | ... | @@ -252,10 +252,64 @@ jobs: |
| 252 | 252 | echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" |
| 253 | 253 | echo "relay_url=https://$relay_domain" >> "$GITHUB_OUTPUT" |
| 254 | 254 | |
| 255 | # node-pty publishes no Linux prebuilt and the WSL backend runs under the | |
| 256 | # distro's own (Linux) Node, which can't load the Windows/Electron binary. We | |
| 257 | # build the Linux pty.node here, on Linux, and hand it to the Windows packaging | |
| 258 | # job — the Windows artifact then ships a ready WSL backend binary with no | |
| 259 | # cross-compiling and no first-launch compiler/node-gyp/network on the user's | |
| 260 | # machine. node-pty is N-API, so one binary works across all WSL Node versions. | |
| 261 | build_wsl_node_pty: | |
| 262 | name: Build WSL node-pty (linux-x64) | |
| 263 | needs: [preflight] | |
| 264 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} | |
| 265 | runs-on: blacksmith-8vcpu-ubuntu-2404 | |
| 266 | timeout-minutes: 15 | |
| 267 | steps: | |
| 268 | - name: Checkout | |
| 269 | uses: actions/checkout@v6 | |
| 270 | with: | |
| 271 | ref: ${{ needs.preflight.outputs.ref }} | |
| 272 | fetch-depth: 0 | |
| 273 | ||
| 274 | - name: Setup Vite+ | |
| 275 | uses: voidzero-dev/setup-vp@v1 | |
| 276 | with: | |
| 277 | node-version-file: package.json | |
| 278 | cache: true | |
| 279 | run-install: true | |
| 280 | ||
| 281 | - name: Build node-pty linux-x64 prebuild | |
| 282 | shell: bash | |
| 283 | run: | | |
| 284 | set -euo pipefail | |
| 285 | # Resolve node-pty from apps/server (where it's a dependency) and build | |
| 286 | # its native binary from source for Linux. node-addon-api resolves from | |
| 287 | # node-pty's own dependency tree, so node-gyp has everything it needs. | |
| 288 | pty_pkg="$(node -e "console.log(require.resolve('node-pty/package.json', { paths: ['$GITHUB_WORKSPACE/apps/server'] }))")" | |
| 289 | pty_dir="$(dirname "$pty_pkg")" | |
| 290 | ( cd "$pty_dir" && npx --yes node-gyp rebuild ) | |
| 291 | mkdir -p wsl-prebuild | |
| 292 | cp "$pty_dir/build/Release/pty.node" wsl-prebuild/pty.node | |
| 293 | file wsl-prebuild/pty.node | |
| 294 | ||
| 295 | - name: Upload node-pty linux-x64 prebuild | |
| 296 | uses: actions/upload-artifact@v7 | |
| 297 | with: | |
| 298 | name: wsl-node-pty-x64 | |
| 299 | path: wsl-prebuild/pty.node | |
| 300 | if-no-files-found: error | |
| 301 | ||
| 255 | 302 | build: |
| 256 | 303 | name: Build ${{ matrix.label }} |
| 257 | needs: [preflight, relay_public_config] | |
| 258 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} | |
| 304 | # build_wsl_node_pty stays in `needs` so it runs first and its artifact is | |
| 305 | # available to download, but only the Windows matrix entry consumes it. We | |
| 306 | # therefore gate the job on preflight + relay (must succeed) WITHOUT requiring | |
| 307 | # build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux | |
| 308 | # builds. `!cancelled()` (not `!failure()`) lets the job run even when | |
| 309 | # build_wsl_node_pty failed; the Windows-only download step below then fails | |
| 310 | # that single platform if the prebuild is missing. | |
| 311 | needs: [preflight, relay_public_config, build_wsl_node_pty] | |
| 312 | if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} | |
| 259 | 313 | runs-on: ${{ matrix.runner }} |
| 260 | 314 | timeout-minutes: 30 |
| 261 | 315 | env: |
| ... | ... | @@ -323,6 +377,13 @@ jobs: |
| 323 | 377 | - name: Align package versions to release version |
| 324 | 378 | run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" |
| 325 | 379 | |
| 380 | - name: Download WSL node-pty prebuild | |
| 381 | if: matrix.platform == 'win' | |
| 382 | uses: actions/download-artifact@v7 | |
| 383 | with: | |
| 384 | name: wsl-node-pty-x64 | |
| 385 | path: wsl-prebuild | |
| 386 | ||
| 326 | 387 | - name: Install Spectre-mitigated MSVC libs |
| 327 | 388 | if: matrix.platform == 'win' |
| 328 | 389 | shell: pwsh |
| ... | ... | @@ -476,6 +537,10 @@ jobs: |
| 476 | 537 | echo "macOS signing disabled (missing one or more Apple signing secrets)." |
| 477 | 538 | fi |
| 478 | 539 | elif [[ "${{ matrix.platform }}" == "win" ]]; then |
| 540 | # Bundle the Linux node-pty binary built by the build_wsl_node_pty job | |
| 541 | # so the packaged WSL backend ships a ready binary (no first-launch | |
| 542 | # compile). Required for a working WSL backend on Windows. | |
| 543 | args+=(--wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node") | |
| 479 | 544 | if has_all \ |
| 480 | 545 | "$AZURE_TENANT_ID" \ |
| 481 | 546 | "$AZURE_CLIENT_ID" \ |
apps/desktop/src/app/DesktopApp.ts+32-5| ... | ... | @@ -13,7 +13,8 @@ import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; |
| 13 | 13 | import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; |
| 14 | 14 | import * as DesktopClerk from "./DesktopClerk.ts"; |
| 15 | 15 | import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; |
| 16 | import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; | |
| 16 | import * as DesktopWindow from "../window/DesktopWindow.ts"; | |
| 17 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; | |
| 17 | 18 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 18 | 19 | import * as DesktopLifecycle from "./DesktopLifecycle.ts"; |
| 19 | 20 | import * as DesktopObservability from "./DesktopObservability.ts"; |
| ... | ... | @@ -23,6 +24,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 23 | 24 | import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; |
| 24 | 25 | import * as DesktopState from "./DesktopState.ts"; |
| 25 | 26 | import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; |
| 27 | import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; | |
| 26 | 28 | |
| 27 | 29 | const DEFAULT_DESKTOP_BACKEND_PORT = 3773; |
| 28 | 30 | const MAX_TCP_PORT = 65_535; |
| ... | ... | @@ -135,11 +137,14 @@ const fatalStartupCause = <E>(stage: string, cause: Cause.Cause<E>) => |
| 135 | 137 | handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); |
| 136 | 138 | |
| 137 | 139 | const bootstrap = Effect.gen(function* () { |
| 138 | const backendManager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 140 | const pool = yield* DesktopBackendPool.DesktopBackendPool; | |
| 141 | const primaryBackend = yield* pool.primary; | |
| 139 | 142 | const state = yield* DesktopState.DesktopState; |
| 140 | 143 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 141 | 144 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| 142 | 145 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; |
| 146 | const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; | |
| 147 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | |
| 143 | 148 | yield* logBootstrapInfo("bootstrap start"); |
| 144 | 149 | |
| 145 | 150 | if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { |
| ... | ... | @@ -193,8 +198,20 @@ const bootstrap = Effect.gen(function* () { |
| 193 | 198 | yield* logBootstrapInfo("bootstrap ipc handlers registered"); |
| 194 | 199 | |
| 195 | 200 | if (!(yield* Ref.get(state.quitting))) { |
| 196 | yield* backendManager.start; | |
| 201 | // In wsl-only mode the renderer is served by the WSL backend, which can be | |
| 202 | // slow to cold-boot — show a "Connecting to WSL" splash immediately so the | |
| 203 | // app feels responsive instead of presenting no window until WSL is ready. | |
| 204 | // (Dual mode opens fast off the Windows primary, so no splash there.) | |
| 205 | if (settings.wslOnly === true && settings.wslBackendEnabled === true) { | |
| 206 | yield* desktopWindow.showConnectingSplash; | |
| 207 | } | |
| 208 | yield* primaryBackend.start; | |
| 197 | 209 | yield* logBootstrapInfo("bootstrap backend start requested"); |
| 210 | // Bring up the WSL backend if the user previously enabled it. The | |
| 211 | // primary is already starting; reconcile fires off the WSL register | |
| 212 | // in parallel rather than blocking primary readiness on a possibly | |
| 213 | // slow first wsl.exe spawn. | |
| 214 | yield* Effect.forkScoped(wslBackend.reconcile); | |
| 198 | 215 | } |
| 199 | 216 | }).pipe(Effect.withSpan("desktop.bootstrap")); |
| 200 | 217 | |
| ... | ... | @@ -241,10 +258,20 @@ const scopedProgram = Effect.scoped( |
| 241 | 258 | yield* Effect.annotateCurrentSpan({ scope: "desktop", runId }); |
| 242 | 259 | |
| 243 | 260 | const shutdown = yield* DesktopShutdown.DesktopShutdown; |
| 244 | const backendManager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 245 | 261 | |
| 246 | 262 | yield* Effect.addFinalizer(() => |
| 247 | backendManager.stop().pipe(Effect.ensuring(shutdown.markComplete)), | |
| 263 | Effect.gen(function* () { | |
| 264 | const pool = yield* DesktopBackendPool.DesktopBackendPool; | |
| 265 | // Stop every backend in the pool, not just the primary. The | |
| 266 | // electronApp.quit() path can race ahead of the layer-scope | |
| 267 | // cascade, so leaving the WSL instance for its parent scope | |
| 268 | // finalizer means it gets hard-killed by the OS instead of | |
| 269 | // receiving SIGTERM + grace. Stops run concurrently. | |
| 270 | const instances = yield* pool.list; | |
| 271 | yield* Effect.forEach(instances, (instance) => instance.stop(), { | |
| 272 | concurrency: "unbounded", | |
| 273 | }); | |
| 274 | }).pipe(Effect.ensuring(shutdown.markComplete)), | |
| 248 | 275 | ); |
| 249 | 276 | |
| 250 | 277 | yield* startup; |
apps/desktop/src/app/DesktopBackendOutputLog.test.ts deleted-122| ... | ... | @@ -1,122 +0,0 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as FileSystem from "effect/FileSystem"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Logger from "effect/Logger"; | |
| 6 | import * as Path from "effect/Path"; | |
| 7 | import * as PlatformError from "effect/PlatformError"; | |
| 8 | ||
| 9 | import * as DesktopBackendOutputLog from "./DesktopBackendOutputLog.ts"; | |
| 10 | import * as DesktopConfig from "./DesktopConfig.ts"; | |
| 11 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | |
| 12 | ||
| 13 | const LOG_FILE_PATH = "/Users/alice/.t3/userdata/logs/server-child.log"; | |
| 14 | ||
| 15 | const environmentLayer = DesktopEnvironment.layer({ | |
| 16 | dirname: "/repo/apps/desktop/dist-electron", | |
| 17 | homeDirectory: "/Users/alice", | |
| 18 | platform: "darwin", | |
| 19 | processArch: "arm64", | |
| 20 | appVersion: "1.2.3", | |
| 21 | appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", | |
| 22 | isPackaged: true, | |
| 23 | resourcesPath: "/Applications/T3 Code.app/Contents/Resources", | |
| 24 | runningUnderArm64Translation: false, | |
| 25 | }).pipe(Layer.provide(Layer.merge(Path.layer, DesktopConfig.layerTest({})))); | |
| 26 | ||
| 27 | const withOutputLog = <A, E, R>( | |
| 28 | effect: Effect.Effect<A, E, R | DesktopBackendOutputLog.DesktopBackendOutputLog>, | |
| 29 | fileSystemLayer: Layer.Layer<FileSystem.FileSystem>, | |
| 30 | messages: Array<ReadonlyArray<unknown>>, | |
| 31 | ) => { | |
| 32 | const logger = Logger.make(({ message }) => { | |
| 33 | messages.push(Array.isArray(message) ? message : [message]); | |
| 34 | }); | |
| 35 | const outputLogLayer = DesktopBackendOutputLog.layer.pipe( | |
| 36 | Layer.provide(Layer.mergeAll(fileSystemLayer, Path.layer, environmentLayer)), | |
| 37 | Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false })), | |
| 38 | ); | |
| 39 | return effect.pipe(Effect.provide(outputLogLayer)); | |
| 40 | }; | |
| 41 | ||
| 42 | const loggedError = (messages: ReadonlyArray<ReadonlyArray<unknown>>): unknown => | |
| 43 | messages.flat().find((value) => typeof value === "object" && value !== null && "error" in value) | |
| 44 | ?.error; | |
| 45 | ||
| 46 | describe("DesktopBackendOutputLog", () => { | |
| 47 | it.effect("logs setup failures with the log path and exact cause", () => { | |
| 48 | const messages: Array<ReadonlyArray<unknown>> = []; | |
| 49 | const cause = PlatformError.systemError({ | |
| 50 | _tag: "PermissionDenied", | |
| 51 | module: "FileSystem", | |
| 52 | method: "makeDirectory", | |
| 53 | pathOrDescriptor: "/Users/alice/.t3/userdata/logs", | |
| 54 | description: "private setup diagnostic", | |
| 55 | }); | |
| 56 | const fileSystemLayer = FileSystem.layerNoop({ | |
| 57 | makeDirectory: () => Effect.fail(cause), | |
| 58 | }); | |
| 59 | ||
| 60 | return withOutputLog( | |
| 61 | Effect.gen(function* () { | |
| 62 | const outputLog = yield* DesktopBackendOutputLog.DesktopBackendOutputLog; | |
| 63 | yield* outputLog.writeSessionBoundary({ phase: "START", details: "test" }); | |
| 64 | ||
| 65 | const error = loggedError(messages); | |
| 66 | assert.instanceOf(error, DesktopBackendOutputLog.DesktopBackendOutputLogSetupError); | |
| 67 | assert.equal(error.logFilePath, LOG_FILE_PATH); | |
| 68 | assert.strictEqual(error.cause, cause); | |
| 69 | assert.equal( | |
| 70 | error.message, | |
| 71 | `Failed to initialize the desktop backend output log at ${LOG_FILE_PATH}.`, | |
| 72 | ); | |
| 73 | assert.notInclude(error.message, "private setup diagnostic"); | |
| 74 | }), | |
| 75 | fileSystemLayer, | |
| 76 | messages, | |
| 77 | ); | |
| 78 | }); | |
| 79 | ||
| 80 | it.effect("logs record write failures with the operation and exact cause", () => { | |
| 81 | const messages: Array<ReadonlyArray<unknown>> = []; | |
| 82 | const missingCause = PlatformError.systemError({ | |
| 83 | _tag: "NotFound", | |
| 84 | module: "FileSystem", | |
| 85 | method: "stat", | |
| 86 | pathOrDescriptor: LOG_FILE_PATH, | |
| 87 | }); | |
| 88 | const writeCause = PlatformError.systemError({ | |
| 89 | _tag: "PermissionDenied", | |
| 90 | module: "FileSystem", | |
| 91 | method: "writeFile", | |
| 92 | pathOrDescriptor: LOG_FILE_PATH, | |
| 93 | description: "private write diagnostic", | |
| 94 | }); | |
| 95 | const fileSystemLayer = FileSystem.layerNoop({ | |
| 96 | makeDirectory: () => Effect.void, | |
| 97 | stat: () => Effect.fail(missingCause), | |
| 98 | readDirectory: () => Effect.succeed([]), | |
| 99 | writeFile: () => Effect.fail(writeCause), | |
| 100 | }); | |
| 101 | ||
| 102 | return withOutputLog( | |
| 103 | Effect.gen(function* () { | |
| 104 | const outputLog = yield* DesktopBackendOutputLog.DesktopBackendOutputLog; | |
| 105 | yield* outputLog.writeSessionBoundary({ phase: "START", details: "test" }); | |
| 106 | ||
| 107 | const error = loggedError(messages); | |
| 108 | assert.instanceOf(error, DesktopBackendOutputLog.DesktopBackendOutputLogWriteError); | |
| 109 | assert.equal(error.operation, "write-record"); | |
| 110 | assert.equal(error.logFilePath, LOG_FILE_PATH); | |
| 111 | assert.strictEqual(error.cause, writeCause); | |
| 112 | assert.equal( | |
| 113 | error.message, | |
| 114 | `Desktop backend output log operation "write-record" failed at ${LOG_FILE_PATH}.`, | |
| 115 | ); | |
| 116 | assert.notInclude(error.message, "private write diagnostic"); | |
| 117 | }), | |
| 118 | fileSystemLayer, | |
| 119 | messages, | |
| 120 | ); | |
| 121 | }); | |
| 122 | }); |
apps/desktop/src/app/DesktopBackendOutputLog.ts deleted-385| ... | ... | @@ -1,385 +0,0 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as DateTime from "effect/DateTime"; | |
| 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 Path from "effect/Path"; | |
| 8 | import * as PlatformError from "effect/PlatformError"; | |
| 9 | import * as References from "effect/References"; | |
| 10 | import * as Ref from "effect/Ref"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | import * as Semaphore from "effect/Semaphore"; | |
| 13 | ||
| 14 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | |
| 15 | ||
| 16 | export const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; | |
| 17 | export const DESKTOP_LOG_FILE_MAX_FILES = 10; | |
| 18 | ||
| 19 | const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; | |
| 20 | ||
| 21 | interface RotatingLogFileWriter { | |
| 22 | readonly filePath: string; | |
| 23 | readonly writeBytes: ( | |
| 24 | chunk: Uint8Array, | |
| 25 | ) => Effect.Effect<void, PlatformError.PlatformError | DesktopLogFileWriterRecoveryError>; | |
| 26 | readonly writeText: ( | |
| 27 | chunk: string, | |
| 28 | ) => Effect.Effect<void, PlatformError.PlatformError | DesktopLogFileWriterRecoveryError>; | |
| 29 | } | |
| 30 | ||
| 31 | class DesktopLogFileWriterConfigurationError extends Schema.TaggedErrorClass<DesktopLogFileWriterConfigurationError>()( | |
| 32 | "DesktopLogFileWriterConfigurationError", | |
| 33 | { | |
| 34 | option: Schema.Literals(["maxBytes", "maxFiles"]), | |
| 35 | value: Schema.Number, | |
| 36 | }, | |
| 37 | ) { | |
| 38 | override get message(): string { | |
| 39 | return `${this.option} must be >= 1 (received ${this.value})`; | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | class DesktopLogFileWriterRecoveryError extends Schema.TaggedErrorClass<DesktopLogFileWriterRecoveryError>()( | |
| 44 | "DesktopLogFileWriterRecoveryError", | |
| 45 | { | |
| 46 | logFilePath: Schema.String, | |
| 47 | cause: Schema.Defect(), | |
| 48 | recoveryCause: Schema.Defect(), | |
| 49 | }, | |
| 50 | ) { | |
| 51 | override get message(): string { | |
| 52 | return `Failed to refresh desktop backend output log size after a write failure at ${this.logFilePath}.`; | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | export class DesktopBackendOutputLogSetupError extends Schema.TaggedErrorClass<DesktopBackendOutputLogSetupError>()( | |
| 57 | "DesktopBackendOutputLogSetupError", | |
| 58 | { | |
| 59 | logFilePath: Schema.String, | |
| 60 | cause: Schema.Defect(), | |
| 61 | }, | |
| 62 | ) { | |
| 63 | override get message(): string { | |
| 64 | return `Failed to initialize the desktop backend output log at ${this.logFilePath}.`; | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | export class DesktopBackendOutputLogWriteError extends Schema.TaggedErrorClass<DesktopBackendOutputLogWriteError>()( | |
| 69 | "DesktopBackendOutputLogWriteError", | |
| 70 | { | |
| 71 | operation: Schema.Literals(["encode-record", "write-record"]), | |
| 72 | logFilePath: Schema.String, | |
| 73 | cause: Schema.Defect(), | |
| 74 | }, | |
| 75 | ) { | |
| 76 | override get message(): string { | |
| 77 | return `Desktop backend output log operation "${this.operation}" failed at ${this.logFilePath}.`; | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | export class DesktopBackendConsoleWriteError extends Schema.TaggedErrorClass<DesktopBackendConsoleWriteError>()( | |
| 82 | "DesktopBackendConsoleWriteError", | |
| 83 | { | |
| 84 | streamName: Schema.Literals(["stdout", "stderr"]), | |
| 85 | cause: Schema.Defect(), | |
| 86 | }, | |
| 87 | ) { | |
| 88 | override get message(): string { | |
| 89 | return `Failed to mirror desktop backend output to ${this.streamName}.`; | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | export class DesktopBackendOutputLog extends Context.Service< | |
| 94 | DesktopBackendOutputLog, | |
| 95 | { | |
| 96 | readonly writeSessionBoundary: (input: { | |
| 97 | readonly phase: "START" | "END"; | |
| 98 | readonly details: string; | |
| 99 | }) => Effect.Effect<void>; | |
| 100 | readonly writeOutputChunk: ( | |
| 101 | streamName: "stdout" | "stderr", | |
| 102 | chunk: Uint8Array, | |
| 103 | ) => Effect.Effect<void>; | |
| 104 | } | |
| 105 | >()("@t3tools/desktop/app/DesktopBackendOutputLog") {} | |
| 106 | ||
| 107 | type DesktopLogFileWriterError = | |
| 108 | | DesktopLogFileWriterConfigurationError | |
| 109 | | PlatformError.PlatformError; | |
| 110 | ||
| 111 | const DesktopBackendChildLogRecord = Schema.Struct({ | |
| 112 | message: Schema.String, | |
| 113 | level: Schema.Literals(["INFO", "ERROR"]), | |
| 114 | timestamp: Schema.String, | |
| 115 | annotations: Schema.Record(Schema.String, Schema.Unknown), | |
| 116 | spans: Schema.Record(Schema.String, Schema.Unknown), | |
| 117 | fiberId: Schema.String, | |
| 118 | }); | |
| 119 | ||
| 120 | const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( | |
| 121 | Schema.fromJsonString(DesktopBackendChildLogRecord), | |
| 122 | ); | |
| 123 | ||
| 124 | const DesktopBackendOutputLogNoop: DesktopBackendOutputLog["Service"] = { | |
| 125 | writeSessionBoundary: () => Effect.void, | |
| 126 | writeOutputChunk: () => Effect.void, | |
| 127 | }; | |
| 128 | ||
| 129 | const textEncoder = new TextEncoder(); | |
| 130 | const textDecoder = new TextDecoder(); | |
| 131 | ||
| 132 | const currentDesktopRunId = Effect.gen(function* () { | |
| 133 | const annotations = yield* References.CurrentLogAnnotations; | |
| 134 | const runId = annotations.runId; | |
| 135 | return typeof runId === "string" && runId.length > 0 ? runId : "unknown"; | |
| 136 | }); | |
| 137 | ||
| 138 | const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); | |
| 139 | ||
| 140 | const refreshFileSize = ( | |
| 141 | fileSystem: FileSystem.FileSystem, | |
| 142 | filePath: string, | |
| 143 | ): Effect.Effect<number, PlatformError.PlatformError> => | |
| 144 | fileSystem.stat(filePath).pipe( | |
| 145 | Effect.map((stat) => Number(stat.size)), | |
| 146 | Effect.catchTags({ | |
| 147 | PlatformError: (error) => | |
| 148 | error.reason._tag === "NotFound" ? Effect.succeed(0) : Effect.fail(error), | |
| 149 | }), | |
| 150 | ); | |
| 151 | ||
| 152 | const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { | |
| 153 | readonly filePath: string; | |
| 154 | readonly maxBytes?: number; | |
| 155 | readonly maxFiles?: number; | |
| 156 | }): Effect.fn.Return< | |
| 157 | RotatingLogFileWriter, | |
| 158 | DesktopLogFileWriterError, | |
| 159 | FileSystem.FileSystem | Path.Path | |
| 160 | > { | |
| 161 | const fileSystem = yield* FileSystem.FileSystem; | |
| 162 | const path = yield* Path.Path; | |
| 163 | const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES; | |
| 164 | const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES; | |
| 165 | const directory = path.dirname(input.filePath); | |
| 166 | const baseName = path.basename(input.filePath); | |
| 167 | ||
| 168 | if (maxBytes < 1) { | |
| 169 | return yield* new DesktopLogFileWriterConfigurationError({ | |
| 170 | option: "maxBytes", | |
| 171 | value: maxBytes, | |
| 172 | }); | |
| 173 | } | |
| 174 | if (maxFiles < 1) { | |
| 175 | return yield* new DesktopLogFileWriterConfigurationError({ | |
| 176 | option: "maxFiles", | |
| 177 | value: maxFiles, | |
| 178 | }); | |
| 179 | } | |
| 180 | ||
| 181 | yield* fileSystem.makeDirectory(directory, { recursive: true }); | |
| 182 | ||
| 183 | const withSuffix = (index: number) => `${input.filePath}.${index}`; | |
| 184 | const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); | |
| 185 | const mutex = yield* Semaphore.make(1); | |
| 186 | ||
| 187 | const recoverCurrentSize = ( | |
| 188 | cause: PlatformError.PlatformError, | |
| 189 | ): Effect.Effect<never, PlatformError.PlatformError | DesktopLogFileWriterRecoveryError> => | |
| 190 | refreshFileSize(fileSystem, input.filePath).pipe( | |
| 191 | Effect.matchEffect({ | |
| 192 | onFailure: (recoveryCause) => | |
| 193 | Effect.fail( | |
| 194 | new DesktopLogFileWriterRecoveryError({ | |
| 195 | logFilePath: input.filePath, | |
| 196 | cause, | |
| 197 | recoveryCause, | |
| 198 | }), | |
| 199 | ), | |
| 200 | onSuccess: (size) => Ref.set(currentSize, size).pipe(Effect.andThen(Effect.fail(cause))), | |
| 201 | }), | |
| 202 | ); | |
| 203 | ||
| 204 | const pruneOverflowBackups = Effect.gen(function* () { | |
| 205 | const entries = yield* fileSystem.readDirectory(directory); | |
| 206 | for (const entry of entries) { | |
| 207 | if (!entry.startsWith(`${baseName}.`)) continue; | |
| 208 | const suffix = Number(entry.slice(baseName.length + 1)); | |
| 209 | if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; | |
| 210 | yield* fileSystem.remove(path.join(directory, entry), { force: true }); | |
| 211 | } | |
| 212 | }); | |
| 213 | ||
| 214 | const rotate = Effect.gen(function* () { | |
| 215 | yield* fileSystem.remove(withSuffix(maxFiles), { force: true }); | |
| 216 | for (let index = maxFiles - 1; index >= 1; index -= 1) { | |
| 217 | const source = withSuffix(index); | |
| 218 | const sourceExists = yield* fileSystem.exists(source); | |
| 219 | if (sourceExists) { | |
| 220 | yield* fileSystem.rename(source, withSuffix(index + 1)); | |
| 221 | } | |
| 222 | } | |
| 223 | const currentExists = yield* fileSystem.exists(input.filePath); | |
| 224 | if (currentExists) { | |
| 225 | yield* fileSystem.rename(input.filePath, withSuffix(1)); | |
| 226 | } | |
| 227 | yield* Ref.set(currentSize, 0); | |
| 228 | }); | |
| 229 | ||
| 230 | const writeBytes = ( | |
| 231 | chunk: Uint8Array, | |
| 232 | ): Effect.Effect<void, PlatformError.PlatformError | DesktopLogFileWriterRecoveryError> => { | |
| 233 | if (chunk.byteLength === 0) return Effect.void; | |
| 234 | ||
| 235 | return mutex.withPermits(1)( | |
| 236 | Effect.gen(function* () { | |
| 237 | const beforeSize = yield* Ref.get(currentSize); | |
| 238 | if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) { | |
| 239 | yield* rotate; | |
| 240 | } | |
| 241 | ||
| 242 | yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" }); | |
| 243 | const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength; | |
| 244 | yield* Ref.set(currentSize, afterSize); | |
| 245 | ||
| 246 | if (afterSize > maxBytes) { | |
| 247 | yield* rotate; | |
| 248 | } | |
| 249 | }).pipe( | |
| 250 | Effect.catchTags({ | |
| 251 | PlatformError: recoverCurrentSize, | |
| 252 | }), | |
| 253 | ), | |
| 254 | ); | |
| 255 | }; | |
| 256 | ||
| 257 | yield* pruneOverflowBackups; | |
| 258 | ||
| 259 | return { | |
| 260 | filePath: input.filePath, | |
| 261 | writeBytes, | |
| 262 | writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), | |
| 263 | } satisfies RotatingLogFileWriter; | |
| 264 | }); | |
| 265 | ||
| 266 | const writeDevelopmentConsoleOutput = ( | |
| 267 | streamName: "stdout" | "stderr", | |
| 268 | chunk: Uint8Array, | |
| 269 | ): Effect.Effect<void> => | |
| 270 | Effect.try({ | |
| 271 | try: () => { | |
| 272 | const output = streamName === "stderr" ? process.stderr : process.stdout; | |
| 273 | output.write(chunk); | |
| 274 | }, | |
| 275 | catch: (cause) => new DesktopBackendConsoleWriteError({ streamName, cause }), | |
| 276 | }).pipe( | |
| 277 | Effect.catchTags({ | |
| 278 | DesktopBackendConsoleWriteError: (error) => Effect.logError(error.message, { error }), | |
| 279 | }), | |
| 280 | ); | |
| 281 | ||
| 282 | const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( | |
| 283 | function* ( | |
| 284 | logFile: RotatingLogFileWriter, | |
| 285 | input: { | |
| 286 | readonly message: string; | |
| 287 | readonly level: "INFO" | "ERROR"; | |
| 288 | readonly annotations: Record<string, unknown>; | |
| 289 | }, | |
| 290 | ): Effect.fn.Return<void> { | |
| 291 | return yield* Effect.gen(function* () { | |
| 292 | const timestamp = DateTime.formatIso(yield* DateTime.now); | |
| 293 | const encoded = yield* encodeDesktopBackendChildLogRecord({ | |
| 294 | message: input.message, | |
| 295 | level: input.level, | |
| 296 | timestamp, | |
| 297 | annotations: input.annotations, | |
| 298 | spans: {}, | |
| 299 | fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, | |
| 300 | }).pipe( | |
| 301 | Effect.mapError( | |
| 302 | (cause) => | |
| 303 | new DesktopBackendOutputLogWriteError({ | |
| 304 | operation: "encode-record", | |
| 305 | logFilePath: logFile.filePath, | |
| 306 | cause, | |
| 307 | }), | |
| 308 | ), | |
| 309 | ); | |
| 310 | yield* logFile.writeText(`${encoded}\n`).pipe( | |
| 311 | Effect.mapError( | |
| 312 | (cause) => | |
| 313 | new DesktopBackendOutputLogWriteError({ | |
| 314 | operation: "write-record", | |
| 315 | logFilePath: logFile.filePath, | |
| 316 | cause, | |
| 317 | }), | |
| 318 | ), | |
| 319 | ); | |
| 320 | }).pipe( | |
| 321 | Effect.catchTags({ | |
| 322 | DesktopBackendOutputLogWriteError: (error) => Effect.logError(error.message, { error }), | |
| 323 | }), | |
| 324 | ); | |
| 325 | }, | |
| 326 | ); | |
| 327 | ||
| 328 | export const make = Effect.gen(function* () { | |
| 329 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 330 | const logFilePath = environment.path.join(environment.logDir, "server-child.log"); | |
| 331 | const writer = yield* makeRotatingLogFileWriter({ | |
| 332 | filePath: logFilePath, | |
| 333 | }).pipe( | |
| 334 | Effect.mapError((cause) => new DesktopBackendOutputLogSetupError({ logFilePath, cause })), | |
| 335 | Effect.map(Option.some), | |
| 336 | Effect.catchTags({ | |
| 337 | DesktopBackendOutputLogSetupError: (error) => | |
| 338 | Effect.logError(error.message, { error }).pipe(Effect.as(Option.none())), | |
| 339 | }), | |
| 340 | ); | |
| 341 | ||
| 342 | const service = Option.match(writer, { | |
| 343 | onNone: () => DesktopBackendOutputLogNoop, | |
| 344 | onSome: (logFile) => | |
| 345 | ({ | |
| 346 | writeSessionBoundary: Effect.fn("desktop.observability.backendOutput.writeSessionBoundary")( | |
| 347 | function* ({ phase, details }) { | |
| 348 | const runId = yield* currentDesktopRunId; | |
| 349 | yield* writeBackendChildLogRecord(logFile, { | |
| 350 | message: `backend child process session ${phase.toLowerCase()}`, | |
| 351 | level: "INFO", | |
| 352 | annotations: { | |
| 353 | component: "desktop-backend-child", | |
| 354 | runId, | |
| 355 | phase, | |
| 356 | details: sanitizeLogValue(details), | |
| 357 | }, | |
| 358 | }); | |
| 359 | }, | |
| 360 | ), | |
| 361 | writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( | |
| 362 | function* (streamName, chunk) { | |
| 363 | if (environment.isDevelopment) { | |
| 364 | yield* writeDevelopmentConsoleOutput(streamName, chunk); | |
| 365 | } | |
| 366 | const runId = yield* currentDesktopRunId; | |
| 367 | yield* writeBackendChildLogRecord(logFile, { | |
| 368 | message: "backend child process output", | |
| 369 | level: streamName === "stderr" ? "ERROR" : "INFO", | |
| 370 | annotations: { | |
| 371 | component: "desktop-backend-child", | |
| 372 | runId, | |
| 373 | stream: streamName, | |
| 374 | text: textDecoder.decode(chunk), | |
| 375 | }, | |
| 376 | }); | |
| 377 | }, | |
| 378 | ), | |
| 379 | }) satisfies DesktopBackendOutputLog["Service"], | |
| 380 | }); | |
| 381 | ||
| 382 | return DesktopBackendOutputLog.of(service); | |
| 383 | }); | |
| 384 | ||
| 385 | export const layer = Layer.effect(DesktopBackendOutputLog, make); |
apps/desktop/src/app/DesktopObservability.test.ts+4-1| ... | ... | @@ -125,7 +125,8 @@ describe("DesktopObservability", () => { |
| 125 | 125 | }).pipe(Effect.provide(environmentLayer)); |
| 126 | 126 | |
| 127 | 127 | yield* Effect.gen(function* () { |
| 128 | const outputLog = yield* DesktopObservability.DesktopBackendOutputLog; | |
| 128 | const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory; | |
| 129 | const outputLog = yield* factory.forInstance("primary"); | |
| 129 | 130 | yield* outputLog.writeSessionBoundary({ |
| 130 | 131 | phase: "START", |
| 131 | 132 | details: "pid=123 port=3773 cwd=/repo", |
| ... | ... | @@ -145,6 +146,7 @@ describe("DesktopObservability", () => { |
| 145 | 146 | assert.equal(boundary.level, "INFO"); |
| 146 | 147 | assert.equal(boundary.annotations.component, "desktop-backend-child"); |
| 147 | 148 | assert.equal(boundary.annotations.runId, "test-run"); |
| 149 | assert.equal(boundary.annotations.instanceId, "primary"); | |
| 148 | 150 | assert.equal(boundary.annotations.phase, "START"); |
| 149 | 151 | assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo"); |
| 150 | 152 | |
| ... | ... | @@ -152,6 +154,7 @@ describe("DesktopObservability", () => { |
| 152 | 154 | assert.equal(output.level, "INFO"); |
| 153 | 155 | assert.equal(output.annotations.component, "desktop-backend-child"); |
| 154 | 156 | assert.equal(output.annotations.runId, "test-run"); |
| 157 | assert.equal(output.annotations.instanceId, "primary"); | |
| 155 | 158 | assert.equal(output.annotations.stream, "stdout"); |
| 156 | 159 | assert.equal(output.annotations.text, "hello server\n"); |
| 157 | 160 | }).pipe( |
apps/desktop/src/app/DesktopObservability.ts+376-8| ... | ... | @@ -1,20 +1,65 @@ |
| 1 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | |
| 1 | 2 | import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; |
| 2 | 3 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 4 | import * as Context from "effect/Context"; | |
| 5 | import * as DateTime from "effect/DateTime"; | |
| 3 | 6 | import * as Effect from "effect/Effect"; |
| 4 | 7 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 8 | import * as Layer from "effect/Layer"; |
| 6 | 9 | import * as Logger from "effect/Logger"; |
| 7 | 10 | import * as Option from "effect/Option"; |
| 11 | import * as Path from "effect/Path"; | |
| 12 | import * as PlatformError from "effect/PlatformError"; | |
| 8 | 13 | import * as References from "effect/References"; |
| 14 | import * as Ref from "effect/Ref"; | |
| 15 | import * as Schema from "effect/Schema"; | |
| 16 | import * as Scope from "effect/Scope"; | |
| 17 | import * as Semaphore from "effect/Semaphore"; | |
| 18 | import * as SynchronizedRef from "effect/SynchronizedRef"; | |
| 9 | 19 | import * as Tracer from "effect/Tracer"; |
| 10 | 20 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; |
| 11 | 21 | |
| 12 | import * as DesktopBackendOutputLogModule from "./DesktopBackendOutputLog.ts"; | |
| 13 | 22 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 14 | 23 | |
| 24 | const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; | |
| 25 | const DESKTOP_LOG_FILE_MAX_FILES = 10; | |
| 26 | const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; | |
| 15 | 27 | const DESKTOP_TRACE_BATCH_WINDOW_MS = 200; |
| 16 | 28 | |
| 17 | export { DesktopBackendOutputLog } from "./DesktopBackendOutputLog.ts"; | |
| 29 | export interface RotatingLogFileWriter { | |
| 30 | readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>; | |
| 31 | readonly writeText: (chunk: string) => Effect.Effect<void>; | |
| 32 | } | |
| 33 | ||
| 34 | export interface DesktopBackendOutputLogShape { | |
| 35 | readonly writeSessionBoundary: (input: { | |
| 36 | readonly phase: "START" | "END"; | |
| 37 | readonly details: string; | |
| 38 | }) => Effect.Effect<void>; | |
| 39 | readonly writeOutputChunk: ( | |
| 40 | streamName: "stdout" | "stderr", | |
| 41 | chunk: Uint8Array, | |
| 42 | ) => Effect.Effect<void>; | |
| 43 | } | |
| 44 | ||
| 45 | // Factory for per-instance backend output logs. `forInstance(id)` returns | |
| 46 | // a writer that targets a distinct rotating log file — the primary | |
| 47 | // instance keeps `server-child.log` so the historical path stays stable | |
| 48 | // for ops; other instances get `server-child-<sanitized-id>.log`. | |
| 49 | // | |
| 50 | // Writers are cached per id within a single factory instance so repeated | |
| 51 | // `forInstance` calls (e.g. during a backend restart that re-resolves | |
| 52 | // services) reuse the same rotating writer rather than racing each other | |
| 53 | // on the same file. | |
| 54 | export class DesktopBackendOutputLogFactory extends Context.Service< | |
| 55 | DesktopBackendOutputLogFactory, | |
| 56 | { | |
| 57 | readonly forInstance: (id: string) => Effect.Effect<DesktopBackendOutputLogShape>; | |
| 58 | } | |
| 59 | >()("@t3tools/desktop/app/DesktopObservability/DesktopBackendOutputLogFactory") {} | |
| 60 | ||
| 61 | const textEncoder = new TextEncoder(); | |
| 62 | const textDecoder = new TextDecoder(); | |
| 18 | 63 | |
| 19 | 64 | export type DesktopLogAnnotations = Record<string, unknown>; |
| 20 | 65 | |
| ... | ... | @@ -50,7 +95,166 @@ export function makeComponentLogger(component: string): DesktopComponentLogger { |
| 50 | 95 | }; |
| 51 | 96 | } |
| 52 | 97 | |
| 53 | const readPersistedOtlpTracesUrl = Effect.gen(function* () { | |
| 98 | class DesktopLogFileWriterConfigurationError extends Schema.TaggedErrorClass<DesktopLogFileWriterConfigurationError>()( | |
| 99 | "DesktopLogFileWriterConfigurationError", | |
| 100 | { | |
| 101 | option: Schema.Literals(["maxBytes", "maxFiles"]), | |
| 102 | value: Schema.Number, | |
| 103 | }, | |
| 104 | ) { | |
| 105 | override get message() { | |
| 106 | return `${this.option} must be >= 1 (received ${this.value})`; | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | type DesktopLogFileWriterError = | |
| 111 | | DesktopLogFileWriterConfigurationError | |
| 112 | | PlatformError.PlatformError; | |
| 113 | ||
| 114 | const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); | |
| 115 | ||
| 116 | const DesktopBackendChildLogRecord = Schema.Struct({ | |
| 117 | message: Schema.String, | |
| 118 | level: Schema.Literals(["INFO", "ERROR"]), | |
| 119 | timestamp: Schema.String, | |
| 120 | annotations: Schema.Record(Schema.String, Schema.Unknown), | |
| 121 | spans: Schema.Record(Schema.String, Schema.Unknown), | |
| 122 | fiberId: Schema.String, | |
| 123 | }); | |
| 124 | ||
| 125 | const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( | |
| 126 | Schema.fromJsonString(DesktopBackendChildLogRecord), | |
| 127 | ); | |
| 128 | ||
| 129 | const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { | |
| 130 | writeSessionBoundary: () => Effect.void, | |
| 131 | writeOutputChunk: () => Effect.void, | |
| 132 | }; | |
| 133 | ||
| 134 | const currentDesktopRunId = Effect.gen(function* () { | |
| 135 | const annotations = yield* References.CurrentLogAnnotations; | |
| 136 | const runId = annotations.runId; | |
| 137 | return typeof runId === "string" && runId.length > 0 ? runId : "unknown"; | |
| 138 | }); | |
| 139 | ||
| 140 | const refreshFileSize = ( | |
| 141 | fileSystem: FileSystem.FileSystem, | |
| 142 | filePath: string, | |
| 143 | ): Effect.Effect<number, never> => | |
| 144 | fileSystem.stat(filePath).pipe( | |
| 145 | Effect.map((stat) => Number(stat.size)), | |
| 146 | Effect.orElseSucceed(() => 0), | |
| 147 | ); | |
| 148 | ||
| 149 | const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { | |
| 150 | readonly filePath: string; | |
| 151 | readonly maxBytes?: number; | |
| 152 | readonly maxFiles?: number; | |
| 153 | }): Effect.fn.Return< | |
| 154 | RotatingLogFileWriter, | |
| 155 | DesktopLogFileWriterError, | |
| 156 | FileSystem.FileSystem | Path.Path | |
| 157 | > { | |
| 158 | const fileSystem = yield* FileSystem.FileSystem; | |
| 159 | const path = yield* Path.Path; | |
| 160 | const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES; | |
| 161 | const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES; | |
| 162 | const directory = path.dirname(input.filePath); | |
| 163 | const baseName = path.basename(input.filePath); | |
| 164 | ||
| 165 | if (maxBytes < 1) { | |
| 166 | return yield* new DesktopLogFileWriterConfigurationError({ | |
| 167 | option: "maxBytes", | |
| 168 | value: maxBytes, | |
| 169 | }); | |
| 170 | } | |
| 171 | if (maxFiles < 1) { | |
| 172 | return yield* new DesktopLogFileWriterConfigurationError({ | |
| 173 | option: "maxFiles", | |
| 174 | value: maxFiles, | |
| 175 | }); | |
| 176 | } | |
| 177 | ||
| 178 | yield* fileSystem.makeDirectory(directory, { recursive: true }); | |
| 179 | ||
| 180 | const withSuffix = (index: number) => `${input.filePath}.${index}`; | |
| 181 | const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); | |
| 182 | const mutex = yield* Semaphore.make(1); | |
| 183 | ||
| 184 | const pruneOverflowBackups = Effect.gen(function* () { | |
| 185 | const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); | |
| 186 | for (const entry of entries) { | |
| 187 | if (!entry.startsWith(`${baseName}.`)) continue; | |
| 188 | const suffix = Number(entry.slice(baseName.length + 1)); | |
| 189 | if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; | |
| 190 | yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); | |
| 191 | } | |
| 192 | }); | |
| 193 | ||
| 194 | const rotate = Effect.gen(function* () { | |
| 195 | yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore); | |
| 196 | for (let index = maxFiles - 1; index >= 1; index -= 1) { | |
| 197 | const source = withSuffix(index); | |
| 198 | const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false)); | |
| 199 | if (sourceExists) { | |
| 200 | yield* fileSystem.rename(source, withSuffix(index + 1)); | |
| 201 | } | |
| 202 | } | |
| 203 | const currentExists = yield* fileSystem | |
| 204 | .exists(input.filePath) | |
| 205 | .pipe(Effect.orElseSucceed(() => false)); | |
| 206 | if (currentExists) { | |
| 207 | yield* fileSystem.rename(input.filePath, withSuffix(1)); | |
| 208 | } | |
| 209 | yield* Ref.set(currentSize, 0); | |
| 210 | }).pipe( | |
| 211 | Effect.catch(() => | |
| 212 | refreshFileSize(fileSystem, input.filePath).pipe( | |
| 213 | Effect.flatMap((size) => Ref.set(currentSize, size)), | |
| 214 | ), | |
| 215 | ), | |
| 216 | ); | |
| 217 | ||
| 218 | const writeBytes = (chunk: Uint8Array): Effect.Effect<void> => { | |
| 219 | if (chunk.byteLength === 0) return Effect.void; | |
| 220 | ||
| 221 | return mutex.withPermits(1)( | |
| 222 | Effect.gen(function* () { | |
| 223 | const beforeSize = yield* Ref.get(currentSize); | |
| 224 | if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) { | |
| 225 | yield* rotate; | |
| 226 | } | |
| 227 | ||
| 228 | yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" }); | |
| 229 | const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength; | |
| 230 | yield* Ref.set(currentSize, afterSize); | |
| 231 | ||
| 232 | if (afterSize > maxBytes) { | |
| 233 | yield* rotate; | |
| 234 | } | |
| 235 | }).pipe( | |
| 236 | Effect.catch(() => | |
| 237 | refreshFileSize(fileSystem, input.filePath).pipe( | |
| 238 | Effect.flatMap((size) => Ref.set(currentSize, size)), | |
| 239 | ), | |
| 240 | ), | |
| 241 | ), | |
| 242 | ); | |
| 243 | }; | |
| 244 | ||
| 245 | yield* pruneOverflowBackups; | |
| 246 | ||
| 247 | return { | |
| 248 | writeBytes, | |
| 249 | writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), | |
| 250 | } satisfies RotatingLogFileWriter; | |
| 251 | }); | |
| 252 | ||
| 253 | const readPersistedOtlpTracesUrl: Effect.Effect< | |
| 254 | Option.Option<string>, | |
| 255 | never, | |
| 256 | FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment | |
| 257 | > = Effect.gen(function* () { | |
| 54 | 258 | const fileSystem = yield* FileSystem.FileSystem; |
| 55 | 259 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 56 | 260 | const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); |
| ... | ... | @@ -70,6 +274,170 @@ const resolveOtlpTracesUrl = Effect.gen(function* () { |
| 70 | 274 | return yield* readPersistedOtlpTracesUrl; |
| 71 | 275 | }); |
| 72 | 276 | |
| 277 | const writeDevelopmentConsoleOutput = ( | |
| 278 | streamName: "stdout" | "stderr", | |
| 279 | chunk: Uint8Array, | |
| 280 | ): Effect.Effect<void> => | |
| 281 | Effect.sync(() => { | |
| 282 | const output = streamName === "stderr" ? process.stderr : process.stdout; | |
| 283 | output.write(chunk); | |
| 284 | }).pipe(Effect.ignore); | |
| 285 | ||
| 286 | const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( | |
| 287 | function* ( | |
| 288 | logFile: RotatingLogFileWriter, | |
| 289 | input: { | |
| 290 | readonly message: string; | |
| 291 | readonly level: "INFO" | "ERROR"; | |
| 292 | readonly annotations: Record<string, unknown>; | |
| 293 | }, | |
| 294 | ): Effect.fn.Return<void> { | |
| 295 | return yield* Effect.gen(function* () { | |
| 296 | const timestamp = DateTime.formatIso(yield* DateTime.now); | |
| 297 | const encoded = yield* encodeDesktopBackendChildLogRecord({ | |
| 298 | message: input.message, | |
| 299 | level: input.level, | |
| 300 | timestamp, | |
| 301 | annotations: input.annotations, | |
| 302 | spans: {}, | |
| 303 | fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, | |
| 304 | }); | |
| 305 | yield* logFile.writeText(`${encoded}\n`); | |
| 306 | }).pipe(Effect.ignore({ log: true })); | |
| 307 | }, | |
| 308 | ); | |
| 309 | ||
| 310 | const PRIMARY_BACKEND_LOG_INSTANCE_ID = PRIMARY_LOCAL_ENVIRONMENT_ID; | |
| 311 | ||
| 312 | const sanitizeInstanceIdForFileName = (id: string): string => id.replace(/[^a-zA-Z0-9._-]+/g, "_"); | |
| 313 | ||
| 314 | const backendLogFilePathForInstance = ( | |
| 315 | environment: DesktopEnvironment.DesktopEnvironment["Service"], | |
| 316 | id: string, | |
| 317 | ): string => { | |
| 318 | // Primary keeps the historical "server-child.log" path so ops scripts | |
| 319 | // and packaged-build log inspection still find it where it always lived. | |
| 320 | if (id === PRIMARY_BACKEND_LOG_INSTANCE_ID) { | |
| 321 | return environment.path.join(environment.logDir, "server-child.log"); | |
| 322 | } | |
| 323 | const sanitized = sanitizeInstanceIdForFileName(id); | |
| 324 | return environment.path.join(environment.logDir, `server-child-${sanitized}.log`); | |
| 325 | }; | |
| 326 | ||
| 327 | // Just the IO sink. Cacheable by resolved file path so two ids that | |
| 328 | // sanitize to the same filename share a single RotatingLogFileWriter | |
| 329 | // (no race on currentSize tracking). Splitting the sink off from the | |
| 330 | // per-call shape lets the shape annotate writes with the *caller's* | |
| 331 | // id rather than whatever id created the cached writer first. | |
| 332 | const makeBackendOutputSinkForInstance = ( | |
| 333 | environment: DesktopEnvironment.DesktopEnvironment["Service"], | |
| 334 | id: string, | |
| 335 | ): Effect.Effect< | |
| 336 | Option.Option<RotatingLogFileWriter>, | |
| 337 | never, | |
| 338 | FileSystem.FileSystem | Path.Path | Scope.Scope | |
| 339 | > => | |
| 340 | makeRotatingLogFileWriter({ | |
| 341 | filePath: backendLogFilePathForInstance(environment, id), | |
| 342 | }).pipe(Effect.option); | |
| 343 | ||
| 344 | const makeBackendOutputLogShape = ( | |
| 345 | environment: DesktopEnvironment.DesktopEnvironment["Service"], | |
| 346 | id: string, | |
| 347 | sink: Option.Option<RotatingLogFileWriter>, | |
| 348 | ): DesktopBackendOutputLogShape => | |
| 349 | Option.match(sink, { | |
| 350 | onNone: () => DesktopBackendOutputLogNoop, | |
| 351 | onSome: (logFile) => | |
| 352 | ({ | |
| 353 | writeSessionBoundary: Effect.fn("desktop.observability.backendOutput.writeSessionBoundary")( | |
| 354 | function* ({ phase, details }) { | |
| 355 | const runId = yield* currentDesktopRunId; | |
| 356 | yield* writeBackendChildLogRecord(logFile, { | |
| 357 | message: `backend child process session ${phase.toLowerCase()}`, | |
| 358 | level: "INFO", | |
| 359 | annotations: { | |
| 360 | component: "desktop-backend-child", | |
| 361 | runId, | |
| 362 | instanceId: id, | |
| 363 | phase, | |
| 364 | details: sanitizeLogValue(details), | |
| 365 | }, | |
| 366 | }); | |
| 367 | }, | |
| 368 | ), | |
| 369 | writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( | |
| 370 | function* (streamName, chunk) { | |
| 371 | if (environment.isDevelopment) { | |
| 372 | yield* writeDevelopmentConsoleOutput(streamName, chunk); | |
| 373 | } | |
| 374 | const runId = yield* currentDesktopRunId; | |
| 375 | yield* writeBackendChildLogRecord(logFile, { | |
| 376 | message: "backend child process output", | |
| 377 | level: streamName === "stderr" ? "ERROR" : "INFO", | |
| 378 | annotations: { | |
| 379 | component: "desktop-backend-child", | |
| 380 | runId, | |
| 381 | instanceId: id, | |
| 382 | stream: streamName, | |
| 383 | text: textDecoder.decode(chunk), | |
| 384 | }, | |
| 385 | }); | |
| 386 | }, | |
| 387 | ), | |
| 388 | }) satisfies DesktopBackendOutputLogShape, | |
| 389 | }); | |
| 390 | ||
| 391 | const backendOutputLogFactoryLayer = Layer.effect( | |
| 392 | DesktopBackendOutputLogFactory, | |
| 393 | Effect.gen(function* () { | |
| 394 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 395 | const fileSystem = yield* FileSystem.FileSystem; | |
| 396 | const path = yield* Path.Path; | |
| 397 | const factoryScope = yield* Scope.Scope; | |
| 398 | // Per-file-path cache of the IO sink only. The per-call shape | |
| 399 | // wraps the sink with the caller's instance id so a cache hit on | |
| 400 | // a path collision (e.g. "wsl:default" and "wsl_default" both | |
| 401 | // resolve to server-child-wsl_default.log) doesn't attribute the | |
| 402 | // second caller's writes to the first caller's id. Each sink pins | |
| 403 | // itself to the factory's scope so all log resources tear down | |
| 404 | // together at app exit. Mutex serializes concurrent first-time | |
| 405 | // lookups for the same file path. | |
| 406 | const cacheRef = yield* SynchronizedRef.make< | |
| 407 | ReadonlyMap<string, Option.Option<RotatingLogFileWriter>> | |
| 408 | >(new Map()); | |
| 409 | ||
| 410 | const makeForId = (id: string): Effect.Effect<DesktopBackendOutputLogShape> => | |
| 411 | SynchronizedRef.modifyEffect(cacheRef, (cache) => { | |
| 412 | const cacheKey = backendLogFilePathForInstance(environment, id); | |
| 413 | const cached = cache.get(cacheKey); | |
| 414 | if (cached !== undefined) { | |
| 415 | return Effect.succeed([ | |
| 416 | makeBackendOutputLogShape(environment, id, cached), | |
| 417 | cache, | |
| 418 | ] as const); | |
| 419 | } | |
| 420 | return makeBackendOutputSinkForInstance(environment, id).pipe( | |
| 421 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 422 | Effect.provideService(Path.Path, path), | |
| 423 | Scope.provide(factoryScope), | |
| 424 | Effect.map((sink) => { | |
| 425 | const next = new Map(cache); | |
| 426 | next.set(cacheKey, sink); | |
| 427 | return [ | |
| 428 | makeBackendOutputLogShape(environment, id, sink), | |
| 429 | next as ReadonlyMap<string, Option.Option<RotatingLogFileWriter>>, | |
| 430 | ] as const; | |
| 431 | }), | |
| 432 | ); | |
| 433 | }); | |
| 434 | ||
| 435 | return DesktopBackendOutputLogFactory.of({ | |
| 436 | forInstance: (id) => makeForId(id), | |
| 437 | }); | |
| 438 | }), | |
| 439 | ); | |
| 440 | ||
| 73 | 441 | const desktopLoggerLayer = Layer.mergeAll( |
| 74 | 442 | Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }), |
| 75 | 443 | Layer.succeed(References.MinimumLogLevel, "Info"), |
| ... | ... | @@ -82,8 +450,8 @@ const tracerLayer = Layer.unwrap( |
| 82 | 450 | const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson"); |
| 83 | 451 | const sink = yield* makeTraceSink({ |
| 84 | 452 | filePath: tracePath, |
| 85 | maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES, | |
| 86 | maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES, | |
| 453 | maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, | |
| 454 | maxFiles: DESKTOP_LOG_FILE_MAX_FILES, | |
| 87 | 455 | batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, |
| 88 | 456 | }); |
| 89 | 457 | const delegate = Option.isNone(otlpTracesUrl) |
| ... | ... | @@ -101,8 +469,8 @@ const tracerLayer = Layer.unwrap( |
| 101 | 469 | }); |
| 102 | 470 | const tracer = yield* makeLocalFileTracer({ |
| 103 | 471 | filePath: tracePath, |
| 104 | maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES, | |
| 105 | maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES, | |
| 472 | maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, | |
| 473 | maxFiles: DESKTOP_LOG_FILE_MAX_FILES, | |
| 106 | 474 | batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, |
| 107 | 475 | sink, |
| 108 | 476 | ...(delegate ? { delegate } : {}), |
| ... | ... | @@ -113,7 +481,7 @@ const tracerLayer = Layer.unwrap( |
| 113 | 481 | ).pipe(Layer.provideMerge(OtlpSerialization.layerJson)); |
| 114 | 482 | |
| 115 | 483 | export const layer = Layer.mergeAll( |
| 116 | DesktopBackendOutputLogModule.layer, | |
| 484 | backendOutputLogFactoryLayer, | |
| 117 | 485 | desktopLoggerLayer, |
| 118 | 486 | tracerLayer, |
| 119 | 487 | Layer.succeed(Tracer.MinimumTraceLevel, "Info"), |
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+556-14| ... | ... | @@ -4,13 +4,19 @@ import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Logger from "effect/Logger"; |
| 7 | import * as ManagedRuntime from "effect/ManagedRuntime"; | |
| 8 | import * as Option from "effect/Option"; | |
| 9 | import * as Path from "effect/Path"; | |
| 7 | 10 | import * as PlatformError from "effect/PlatformError"; |
| 8 | 11 | import * as Schema from "effect/Schema"; |
| 12 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 9 | 13 | |
| 10 | 14 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 11 | 15 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; |
| 12 | 16 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 13 | 17 | import * as DesktopServerExposure from "./DesktopServerExposure.ts"; |
| 18 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | |
| 19 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; | |
| 14 | 20 | |
| 15 | 21 | const PersistedServerObservabilitySettingsDocument = Schema.Struct({ |
| 16 | 22 | observability: Schema.Struct({ |
| ... | ... | @@ -45,19 +51,22 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp |
| 45 | 51 | function makeEnvironmentLayer( |
| 46 | 52 | baseDir: string, |
| 47 | 53 | options?: { |
| 54 | readonly appPath?: string; | |
| 48 | 55 | readonly isPackaged?: boolean; |
| 49 | 56 | readonly devServerUrl?: string; |
| 57 | readonly platform?: NodeJS.Platform; | |
| 58 | readonly resourcesPath?: string; | |
| 50 | 59 | }, |
| 51 | 60 | ) { |
| 52 | 61 | return DesktopEnvironment.layer({ |
| 53 | 62 | dirname: "/repo/apps/desktop/src", |
| 54 | 63 | homeDirectory: baseDir, |
| 55 | platform: "darwin", | |
| 64 | platform: options?.platform ?? "darwin", | |
| 56 | 65 | processArch: "x64", |
| 57 | 66 | appVersion: "1.2.3", |
| 58 | appPath: "/repo", | |
| 67 | appPath: options?.appPath ?? "/repo", | |
| 59 | 68 | isPackaged: options?.isPackaged ?? true, |
| 60 | resourcesPath: "/missing/resources", | |
| 69 | resourcesPath: options?.resourcesPath ?? "/missing/resources", | |
| 61 | 70 | runningUnderArm64Translation: false, |
| 62 | 71 | }).pipe( |
| 63 | 72 | Layer.provide( |
| ... | ... | @@ -75,6 +84,14 @@ function makeEnvironmentLayer( |
| 75 | 84 | ); |
| 76 | 85 | } |
| 77 | 86 | |
| 87 | const restoreEnv = (name: string, value: string | undefined) => { | |
| 88 | if (value === undefined) { | |
| 89 | delete process.env[name]; | |
| 90 | } else { | |
| 91 | process.env[name] = value; | |
| 92 | } | |
| 93 | }; | |
| 94 | ||
| 78 | 95 | const withHarness = <A, E, R>( |
| 79 | 96 | effect: Effect.Effect< |
| 80 | 97 | A, |
| ... | ... | @@ -95,6 +112,8 @@ const withHarness = <A, E, R>( |
| 95 | 112 | Effect.provide( |
| 96 | 113 | DesktopBackendConfiguration.layer.pipe( |
| 97 | 114 | Layer.provideMerge(serverExposureLayer), |
| 115 | Layer.provideMerge(DesktopAppSettings.layerTest()), | |
| 116 | Layer.provideMerge(DesktopWslEnvironment.layerTest()), | |
| 98 | 117 | Layer.provideMerge(makeEnvironmentLayer(baseDir)), |
| 99 | 118 | ), |
| 100 | 119 | ), |
| ... | ... | @@ -102,14 +121,14 @@ const withHarness = <A, E, R>( |
| 102 | 121 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); |
| 103 | 122 | |
| 104 | 123 | describe("DesktopBackendConfiguration", () => { |
| 105 | it.effect("resolves backend start config with a stable scoped bootstrap token", () => | |
| 124 | it.effect("resolvePrimary produces a stable scoped bootstrap token", () => | |
| 106 | 125 | withHarness( |
| 107 | 126 | Effect.gen(function* () { |
| 108 | 127 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 109 | 128 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| 110 | 129 | |
| 111 | const first = yield* configuration.resolve; | |
| 112 | const second = yield* configuration.resolve; | |
| 130 | const first = yield* configuration.resolvePrimary; | |
| 131 | const second = yield* configuration.resolvePrimary; | |
| 113 | 132 | |
| 114 | 133 | assert.equal(first.executablePath, process.execPath); |
| 115 | 134 | assert.equal(first.entryPath, environment.backendEntryPath); |
| ... | ... | @@ -133,7 +152,167 @@ describe("DesktopBackendConfiguration", () => { |
| 133 | 152 | ), |
| 134 | 153 | ); |
| 135 | 154 | |
| 136 | it.effect("includes persisted backend observability endpoints when present", () => | |
| 155 | it.effect("resolveWsl reuses the primary's bootstrap token", () => | |
| 156 | withHarness( | |
| 157 | Effect.gen(function* () { | |
| 158 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 159 | ||
| 160 | const primary = yield* configuration.resolvePrimary; | |
| 161 | const wsl = yield* configuration.resolveWsl({ port: 5000, distro: null }); | |
| 162 | ||
| 163 | assert.equal(wsl.bootstrap.desktopBootstrapToken, primary.bootstrap.desktopBootstrapToken); | |
| 164 | }), | |
| 165 | ), | |
| 166 | ); | |
| 167 | ||
| 168 | it.effect("resolveWsl pins a default-tracking run to the concrete default distro", () => | |
| 169 | Effect.gen(function* () { | |
| 170 | const fileSystem = yield* FileSystem.FileSystem; | |
| 171 | const path = yield* Path.Path; | |
| 172 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 173 | prefix: "t3-desktop-backend-config-test-", | |
| 174 | }); | |
| 175 | const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs"); | |
| 176 | yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true }); | |
| 177 | yield* fileSystem.writeFileString(entryPath, ""); | |
| 178 | ||
| 179 | const observedDistros: Array<string | null> = []; | |
| 180 | const config = yield* Effect.gen(function* () { | |
| 181 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 182 | return yield* configuration.resolveWsl({ port: 5000, distro: null }); | |
| 183 | }).pipe( | |
| 184 | Effect.provide( | |
| 185 | DesktopBackendConfiguration.layer.pipe( | |
| 186 | Layer.provideMerge(serverExposureLayer), | |
| 187 | Layer.provideMerge(DesktopAppSettings.layerTest()), | |
| 188 | Layer.provideMerge( | |
| 189 | DesktopWslEnvironment.layerTest({ | |
| 190 | isAvailable: true, | |
| 191 | distros: [ | |
| 192 | { name: "Debian", isDefault: false, version: 2 }, | |
| 193 | { name: "Ubuntu", isDefault: true, version: 2 }, | |
| 194 | ], | |
| 195 | windowsToWslPath: (distro) => { | |
| 196 | observedDistros.push(distro); | |
| 197 | return Option.some("/repo/apps/server/dist/bin.mjs"); | |
| 198 | }, | |
| 199 | ensureNodePty: (distro) => { | |
| 200 | observedDistros.push(distro); | |
| 201 | return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; | |
| 202 | }, | |
| 203 | getDistroIp: (distro) => { | |
| 204 | observedDistros.push(distro); | |
| 205 | return Option.some("172.27.0.99"); | |
| 206 | }, | |
| 207 | }), | |
| 208 | ), | |
| 209 | Layer.provideMerge( | |
| 210 | makeEnvironmentLayer(baseDir, { | |
| 211 | appPath: baseDir, | |
| 212 | platform: "win32", | |
| 213 | resourcesPath: baseDir, | |
| 214 | }), | |
| 215 | ), | |
| 216 | ), | |
| 217 | ), | |
| 218 | ); | |
| 219 | ||
| 220 | assert.equal(config.runningDistro, "Ubuntu"); | |
| 221 | assert.deepEqual(config.args.slice(0, 2), ["-d", "Ubuntu"]); | |
| 222 | assert.deepEqual(observedDistros, ["Ubuntu", "Ubuntu", "Ubuntu"]); | |
| 223 | assert.isTrue(Option.isNone(config.preflightFailure)); | |
| 224 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 225 | ); | |
| 226 | ||
| 227 | it.effect( | |
| 228 | "resolveWsl preserves inherited PATH with quote-sensitive values as separate args", | |
| 229 | () => | |
| 230 | Effect.gen(function* () { | |
| 231 | const fileSystem = yield* FileSystem.FileSystem; | |
| 232 | const path = yield* Path.Path; | |
| 233 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 234 | prefix: "t3-desktop-backend-config-test-", | |
| 235 | }); | |
| 236 | const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs"); | |
| 237 | yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true }); | |
| 238 | yield* fileSystem.writeFileString(entryPath, ""); | |
| 239 | ||
| 240 | const nodePath = "/home/test user's/.nvm/versions/node/v22.0.0/bin/node"; | |
| 241 | const linuxEntryPath = "/tmp/t3 code's launch/entry file.mjs"; | |
| 242 | const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin"; | |
| 243 | const devServerUrl = "http://127.0.0.1:5733/dev%20assets/?label=hello%20world"; | |
| 244 | const config = yield* Effect.gen(function* () { | |
| 245 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 246 | return yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); | |
| 247 | }).pipe( | |
| 248 | Effect.provide( | |
| 249 | DesktopBackendConfiguration.layer.pipe( | |
| 250 | Layer.provideMerge(serverExposureLayer), | |
| 251 | Layer.provideMerge(DesktopAppSettings.layerTest()), | |
| 252 | Layer.provideMerge( | |
| 253 | DesktopWslEnvironment.layerTest({ | |
| 254 | isAvailable: true, | |
| 255 | distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], | |
| 256 | windowsToWslPath: () => Option.some(linuxEntryPath), | |
| 257 | ensureNodePty: () => ({ ok: true, nodePath, resolvedPath }), | |
| 258 | getDistroIp: () => Option.some("172.27.0.99"), | |
| 259 | }), | |
| 260 | ), | |
| 261 | Layer.provideMerge( | |
| 262 | makeEnvironmentLayer(baseDir, { | |
| 263 | appPath: baseDir, | |
| 264 | devServerUrl, | |
| 265 | isPackaged: true, | |
| 266 | platform: "win32", | |
| 267 | resourcesPath: baseDir, | |
| 268 | }), | |
| 269 | ), | |
| 270 | ), | |
| 271 | ), | |
| 272 | ); | |
| 273 | ||
| 274 | assert.equal(config.bootstrapDelivery, "stdin"); | |
| 275 | assert.deepEqual(config.args, [ | |
| 276 | "-d", | |
| 277 | "Ubuntu", | |
| 278 | "--exec", | |
| 279 | "env", | |
| 280 | "PATH=/home/test user's/.nvm/versions/node/v22.0.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin", | |
| 281 | nodePath, | |
| 282 | linuxEntryPath, | |
| 283 | "--bootstrap-fd", | |
| 284 | "0", | |
| 285 | "--dev-url", | |
| 286 | devServerUrl, | |
| 287 | ]); | |
| 288 | assert.notInclude(config.args, "bash"); | |
| 289 | assert.notInclude(config.args, "/bin/sh"); | |
| 290 | assert.notInclude(config.args, "-c"); | |
| 291 | assert.isTrue(Option.isNone(config.preflightFailure)); | |
| 292 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 293 | ); | |
| 294 | ||
| 295 | it.effect("resolvePrimary and resolveWsl share one token under concurrent resolution", () => | |
| 296 | withHarness( | |
| 297 | Effect.gen(function* () { | |
| 298 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 299 | ||
| 300 | // Resolve both before any token is cached, concurrently, so the | |
| 301 | // generate step (a yield point) can interleave. The atomic | |
| 302 | // get-or-create must still hand both the same token; a non-atomic | |
| 303 | // Ref would let each generate its own and break the shared-token | |
| 304 | // invariant. | |
| 305 | const [primary, wsl] = yield* Effect.all( | |
| 306 | [configuration.resolvePrimary, configuration.resolveWsl({ port: 5000, distro: null })], | |
| 307 | { concurrency: "unbounded" }, | |
| 308 | ); | |
| 309 | ||
| 310 | assert.equal(wsl.bootstrap.desktopBootstrapToken, primary.bootstrap.desktopBootstrapToken); | |
| 311 | }), | |
| 312 | ), | |
| 313 | ); | |
| 314 | ||
| 315 | it.effect("resolvePrimary surfaces persisted backend observability endpoints", () => | |
| 137 | 316 | withHarness( |
| 138 | 317 | Effect.gen(function* () { |
| 139 | 318 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -153,18 +332,18 @@ describe("DesktopBackendConfiguration", () => { |
| 153 | 332 | }), |
| 154 | 333 | ); |
| 155 | 334 | |
| 156 | const config = yield* configuration.resolve; | |
| 335 | const config = yield* configuration.resolvePrimary; | |
| 157 | 336 | assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); |
| 158 | 337 | assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); |
| 159 | 338 | }), |
| 160 | 339 | ), |
| 161 | 340 | ); |
| 162 | 341 | |
| 163 | it.effect("omits backend observability endpoints when settings are missing", () => | |
| 342 | it.effect("resolvePrimary omits backend observability endpoints when settings are missing", () => | |
| 164 | 343 | withHarness( |
| 165 | 344 | Effect.gen(function* () { |
| 166 | 345 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| 167 | const config = yield* configuration.resolve; | |
| 346 | const config = yield* configuration.resolvePrimary; | |
| 168 | 347 | |
| 169 | 348 | assert.isUndefined(config.bootstrap.otlpTracesUrl); |
| 170 | 349 | assert.isUndefined(config.bootstrap.otlpMetricsUrl); |
| ... | ... | @@ -175,10 +354,11 @@ describe("DesktopBackendConfiguration", () => { |
| 175 | 354 | it.effect("logs structured context when persisted observability settings cannot be read", () => |
| 176 | 355 | Effect.gen(function* () { |
| 177 | 356 | const fileSystem = yield* FileSystem.FileSystem; |
| 357 | const path = yield* Path.Path; | |
| 178 | 358 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ |
| 179 | 359 | prefix: "t3-desktop-backend-config-test-", |
| 180 | 360 | }); |
| 181 | const settingsPath = `${baseDir}/userdata/settings.json`; | |
| 361 | const settingsPath = path.join(baseDir, "userdata", "settings.json"); | |
| 182 | 362 | const cause = PlatformError.systemError({ |
| 183 | 363 | _tag: "PermissionDenied", |
| 184 | 364 | module: "FileSystem", |
| ... | ... | @@ -198,12 +378,14 @@ describe("DesktopBackendConfiguration", () => { |
| 198 | 378 | |
| 199 | 379 | const config = yield* Effect.gen(function* () { |
| 200 | 380 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| 201 | return yield* configuration.resolve; | |
| 381 | return yield* configuration.resolvePrimary; | |
| 202 | 382 | }).pipe( |
| 203 | 383 | Effect.provide( |
| 204 | 384 | Layer.mergeAll( |
| 205 | 385 | DesktopBackendConfiguration.layer.pipe( |
| 206 | 386 | Layer.provideMerge(serverExposureLayer), |
| 387 | Layer.provideMerge(DesktopAppSettings.layerTest()), | |
| 388 | Layer.provideMerge(DesktopWslEnvironment.layerTest()), | |
| 207 | 389 | Layer.provideMerge(makeEnvironmentLayer(baseDir)), |
| 208 | 390 | Layer.provideMerge(failingFileSystemLayer), |
| 209 | 391 | ), |
| ... | ... | @@ -228,7 +410,7 @@ describe("DesktopBackendConfiguration", () => { |
| 228 | 410 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 229 | 411 | ); |
| 230 | 412 | |
| 231 | it.effect("captures backend output in development so child process logs can be persisted", () => | |
| 413 | it.effect("resolvePrimary captures backend output in dev so child logs can be persisted", () => | |
| 232 | 414 | Effect.gen(function* () { |
| 233 | 415 | const fileSystem = yield* FileSystem.FileSystem; |
| 234 | 416 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ |
| ... | ... | @@ -237,12 +419,14 @@ describe("DesktopBackendConfiguration", () => { |
| 237 | 419 | |
| 238 | 420 | yield* Effect.gen(function* () { |
| 239 | 421 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| 240 | const config = yield* configuration.resolve; | |
| 422 | const config = yield* configuration.resolvePrimary; | |
| 241 | 423 | assert.equal(config.captureOutput, true); |
| 242 | 424 | }).pipe( |
| 243 | 425 | Effect.provide( |
| 244 | 426 | DesktopBackendConfiguration.layer.pipe( |
| 245 | 427 | Layer.provideMerge(serverExposureLayer), |
| 428 | Layer.provideMerge(DesktopAppSettings.layerTest()), | |
| 429 | Layer.provideMerge(DesktopWslEnvironment.layerTest()), | |
| 246 | 430 | Layer.provideMerge( |
| 247 | 431 | makeEnvironmentLayer(baseDir, { |
| 248 | 432 | isPackaged: false, |
| ... | ... | @@ -254,4 +438,362 @@ describe("DesktopBackendConfiguration", () => { |
| 254 | 438 | ); |
| 255 | 439 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 256 | 440 | ); |
| 441 | ||
| 442 | it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () => | |
| 443 | Effect.gen(function* () { | |
| 444 | const fileSystem = yield* FileSystem.FileSystem; | |
| 445 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 446 | prefix: "t3-desktop-backend-config-test-", | |
| 447 | }); | |
| 448 | ||
| 449 | const previousWslEnv = process.env.WSLENV; | |
| 450 | const previousOpenAiKey = process.env.OPENAI_API_KEY; | |
| 451 | const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; | |
| 452 | try { | |
| 453 | process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; | |
| 454 | process.env.OPENAI_API_KEY = "openai-key"; | |
| 455 | process.env.ANTHROPIC_API_KEY = "anthropic-key"; | |
| 456 | ||
| 457 | yield* Effect.gen(function* () { | |
| 458 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 459 | const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); | |
| 460 | ||
| 461 | assert.equal(config.executablePath, "wsl.exe"); | |
| 462 | assert.equal(config.bootstrap.port, 5050); | |
| 463 | // Binds to 0.0.0.0 inside WSL so the backend is reachable via | |
| 464 | // both wslhost-forwarded localhost and the distro's eth0 IP. | |
| 465 | assert.equal(config.bootstrap.host, "0.0.0.0"); | |
| 466 | assert.equal(config.bootstrap.tailscaleServeEnabled, false); | |
| 467 | // httpBaseUrl uses the resolved distro IP from the test stub, | |
| 468 | // not localhost — the renderer reaches the backend directly to | |
| 469 | // avoid relying on wslhost forwarding. | |
| 470 | assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); | |
| 471 | assert.equal(config.env.OPENAI_API_KEY, "openai-key"); | |
| 472 | assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); | |
| 473 | // The existing WSLENV is preserved byte-for-byte (note the empty | |
| 474 | // "::" segment survives — WSL ignores it, so we don't normalize | |
| 475 | // it away) and ANTHROPIC_API_KEY is appended. OPENAI_API_KEY is | |
| 476 | // already declared, so it isn't forwarded twice. | |
| 477 | assert.equal( | |
| 478 | config.env.WSLENV, | |
| 479 | "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY", | |
| 480 | ); | |
| 481 | }).pipe( | |
| 482 | Effect.provide( | |
| 483 | DesktopBackendConfiguration.layer.pipe( | |
| 484 | Layer.provideMerge(serverExposureLayer), | |
| 485 | Layer.provideMerge(DesktopAppSettings.layerTest()), | |
| 486 | Layer.provideMerge( | |
| 487 | DesktopWslEnvironment.layerTest({ | |
| 488 | isAvailable: true, | |
| 489 | windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), | |
| 490 | getDistroIp: () => Option.some("172.27.0.99"), | |
| 491 | }), | |
| 492 | ), | |
| 493 | Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), | |
| 494 | ), | |
| 495 | ), | |
| 496 | ); | |
| 497 | } finally { | |
| 498 | restoreEnv("WSLENV", previousWslEnv); | |
| 499 | restoreEnv("OPENAI_API_KEY", previousOpenAiKey); | |
| 500 | restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); | |
| 501 | } | |
| 502 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 503 | ); | |
| 504 | ||
| 505 | it.effect( | |
| 506 | "resolvePrimary falls back to the Windows primary when wsl-only but WSL is unavailable", | |
| 507 | () => | |
| 508 | Effect.gen(function* () { | |
| 509 | const fileSystem = yield* FileSystem.FileSystem; | |
| 510 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 511 | prefix: "t3-desktop-backend-config-test-", | |
| 512 | }); | |
| 513 | ||
| 514 | yield* Effect.gen(function* () { | |
| 515 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 516 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 517 | const config = yield* configuration.resolvePrimary; | |
| 518 | ||
| 519 | // wsl-only is persisted but WSL is unavailable, so the primary must | |
| 520 | // not spawn wsl.exe (which would loop on preflight failures while the | |
| 521 | // Connections backend control is hidden). Resolve the Windows primary. | |
| 522 | assert.equal(config.executablePath, process.execPath); | |
| 523 | assert.equal(config.bootstrap.t3Home, environment.baseDir); | |
| 524 | assert.isTrue(Option.isNone(config.preflightFailure)); | |
| 525 | }).pipe( | |
| 526 | Effect.provide( | |
| 527 | DesktopBackendConfiguration.layer.pipe( | |
| 528 | Layer.provideMerge(serverExposureLayer), | |
| 529 | Layer.provideMerge( | |
| 530 | DesktopAppSettings.layerTest({ | |
| 531 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | |
| 532 | wslBackendEnabled: true, | |
| 533 | wslOnly: true, | |
| 534 | }), | |
| 535 | ), | |
| 536 | Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })), | |
| 537 | Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), | |
| 538 | ), | |
| 539 | ), | |
| 540 | ); | |
| 541 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 542 | ); | |
| 543 | ||
| 544 | it.effect( | |
| 545 | "resolvePrimary marks a removed persisted WSL distro as a fatal preflight failure", | |
| 546 | () => | |
| 547 | Effect.gen(function* () { | |
| 548 | const fileSystem = yield* FileSystem.FileSystem; | |
| 549 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 550 | prefix: "t3-desktop-backend-config-test-", | |
| 551 | }); | |
| 552 | ||
| 553 | yield* Effect.gen(function* () { | |
| 554 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 555 | const config = yield* configuration.resolvePrimary; | |
| 556 | const failure = Option.getOrThrow(config.preflightFailure); | |
| 557 | ||
| 558 | assert.equal(config.executablePath, "wsl.exe"); | |
| 559 | assert.isTrue(failure.fatal); | |
| 560 | assert.include(failure.reason, "Removed-Distro"); | |
| 561 | }).pipe( | |
| 562 | Effect.provide( | |
| 563 | DesktopBackendConfiguration.layer.pipe( | |
| 564 | Layer.provideMerge(serverExposureLayer), | |
| 565 | Layer.provideMerge( | |
| 566 | DesktopAppSettings.layerTest({ | |
| 567 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | |
| 568 | wslBackendEnabled: true, | |
| 569 | wslOnly: true, | |
| 570 | wslDistro: "Removed-Distro", | |
| 571 | }), | |
| 572 | ), | |
| 573 | Layer.provideMerge( | |
| 574 | DesktopWslEnvironment.layerTest({ | |
| 575 | isAvailable: true, | |
| 576 | distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], | |
| 577 | }), | |
| 578 | ), | |
| 579 | Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), | |
| 580 | ), | |
| 581 | ), | |
| 582 | ); | |
| 583 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 584 | ); | |
| 585 | ||
| 586 | it.effect("resolveWsl keeps a transient distro-list failure retryable", () => | |
| 587 | Effect.gen(function* () { | |
| 588 | const fileSystem = yield* FileSystem.FileSystem; | |
| 589 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 590 | prefix: "t3-desktop-backend-config-test-", | |
| 591 | }); | |
| 592 | ||
| 593 | yield* Effect.gen(function* () { | |
| 594 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 595 | const config = yield* configuration.resolveWsl({ port: 5050, distro: "Ubuntu" }); | |
| 596 | const failure = Option.getOrThrow(config.preflightFailure); | |
| 597 | ||
| 598 | assert.isFalse(failure.fatal); | |
| 599 | assert.equal(failure.retryLimit, 12); | |
| 600 | assert.include(failure.reason, "timed out"); | |
| 601 | }).pipe( | |
| 602 | Effect.provide( | |
| 603 | DesktopBackendConfiguration.layer.pipe( | |
| 604 | Layer.provideMerge(serverExposureLayer), | |
| 605 | Layer.provideMerge(DesktopAppSettings.layerTest()), | |
| 606 | Layer.provideMerge( | |
| 607 | DesktopWslEnvironment.layerTest({ | |
| 608 | isAvailable: true, | |
| 609 | distroListError: new DesktopWslEnvironment.DesktopWslDistroListError({ | |
| 610 | reason: "wsl.exe --list --verbose timed out", | |
| 611 | }), | |
| 612 | }), | |
| 613 | ), | |
| 614 | Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), | |
| 615 | ), | |
| 616 | ), | |
| 617 | ); | |
| 618 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 619 | ); | |
| 620 | ||
| 621 | it.effect("resolveWsl marks a missing packaged server entry as fatal", () => | |
| 622 | Effect.gen(function* () { | |
| 623 | const fileSystem = yield* FileSystem.FileSystem; | |
| 624 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 625 | prefix: "t3-desktop-backend-config-test-", | |
| 626 | }); | |
| 627 | ||
| 628 | yield* Effect.gen(function* () { | |
| 629 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 630 | const config = yield* configuration.resolveWsl({ port: 5050, distro: "Ubuntu" }); | |
| 631 | const failure = Option.getOrThrow(config.preflightFailure); | |
| 632 | ||
| 633 | assert.isTrue(failure.fatal); | |
| 634 | assert.include(failure.reason, "missing server entry"); | |
| 635 | }).pipe( | |
| 636 | Effect.provide( | |
| 637 | DesktopBackendConfiguration.layer.pipe( | |
| 638 | Layer.provideMerge(serverExposureLayer), | |
| 639 | Layer.provideMerge(DesktopAppSettings.layerTest()), | |
| 640 | Layer.provideMerge( | |
| 641 | DesktopWslEnvironment.layerTest({ | |
| 642 | isAvailable: true, | |
| 643 | distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], | |
| 644 | }), | |
| 645 | ), | |
| 646 | Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), | |
| 647 | ), | |
| 648 | ), | |
| 649 | ); | |
| 650 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 651 | ); | |
| 652 | ||
| 653 | it.effect("resolveWsl marks a missing selected distro as a fatal preflight failure", () => | |
| 654 | Effect.gen(function* () { | |
| 655 | const fileSystem = yield* FileSystem.FileSystem; | |
| 656 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 657 | prefix: "t3-desktop-backend-config-test-", | |
| 658 | }); | |
| 659 | ||
| 660 | yield* Effect.gen(function* () { | |
| 661 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 662 | const config = yield* configuration.resolveWsl({ port: 5050, distro: "Removed-Distro" }); | |
| 663 | const failure = Option.getOrThrow(config.preflightFailure); | |
| 664 | ||
| 665 | assert.isTrue(failure.fatal); | |
| 666 | assert.include(failure.reason, "Removed-Distro"); | |
| 667 | }).pipe( | |
| 668 | Effect.provide( | |
| 669 | DesktopBackendConfiguration.layer.pipe( | |
| 670 | Layer.provideMerge(serverExposureLayer), | |
| 671 | Layer.provideMerge(DesktopAppSettings.layerTest()), | |
| 672 | Layer.provideMerge( | |
| 673 | DesktopWslEnvironment.layerTest({ | |
| 674 | isAvailable: true, | |
| 675 | distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], | |
| 676 | }), | |
| 677 | ), | |
| 678 | Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), | |
| 679 | ), | |
| 680 | ), | |
| 681 | ); | |
| 682 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 683 | ); | |
| 684 | ||
| 685 | it.effect("resolvePrimaryLabel reports the WSL distro when wsl-only and WSL is available", () => | |
| 686 | Effect.gen(function* () { | |
| 687 | const fileSystem = yield* FileSystem.FileSystem; | |
| 688 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 689 | prefix: "t3-desktop-backend-config-test-", | |
| 690 | }); | |
| 691 | ||
| 692 | yield* Effect.gen(function* () { | |
| 693 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 694 | const label = yield* configuration.resolvePrimaryLabel; | |
| 695 | assert.equal(label, "WSL (Ubuntu)"); | |
| 696 | }).pipe( | |
| 697 | Effect.provide( | |
| 698 | DesktopBackendConfiguration.layer.pipe( | |
| 699 | Layer.provideMerge(serverExposureLayer), | |
| 700 | Layer.provideMerge( | |
| 701 | DesktopAppSettings.layerTest({ | |
| 702 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | |
| 703 | wslBackendEnabled: true, | |
| 704 | wslOnly: true, | |
| 705 | wslDistro: "Ubuntu", | |
| 706 | }), | |
| 707 | ), | |
| 708 | Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), | |
| 709 | Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), | |
| 710 | ), | |
| 711 | ), | |
| 712 | ); | |
| 713 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 714 | ); | |
| 715 | ||
| 716 | it.effect("resolvePrimaryLabel reports the local environment on non-Windows platforms", () => | |
| 717 | withHarness( | |
| 718 | Effect.gen(function* () { | |
| 719 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 720 | const label = yield* configuration.resolvePrimaryLabel; | |
| 721 | assert.equal(label, "Local environment"); | |
| 722 | }), | |
| 723 | ), | |
| 724 | ); | |
| 725 | ||
| 726 | it.effect("resolvePrimaryLabel reports Windows when wsl-only but WSL is unavailable", () => | |
| 727 | Effect.gen(function* () { | |
| 728 | const fileSystem = yield* FileSystem.FileSystem; | |
| 729 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 730 | prefix: "t3-desktop-backend-config-test-", | |
| 731 | }); | |
| 732 | ||
| 733 | yield* Effect.gen(function* () { | |
| 734 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 735 | // Mirrors the resolvePrimary fall-back: the label must follow the | |
| 736 | // backend that actually resolves, not the persisted preference, so the | |
| 737 | // env switcher can't show "WSL" for a Windows backend. | |
| 738 | const label = yield* configuration.resolvePrimaryLabel; | |
| 739 | assert.equal(label, "Windows"); | |
| 740 | }).pipe( | |
| 741 | Effect.provide( | |
| 742 | DesktopBackendConfiguration.layer.pipe( | |
| 743 | Layer.provideMerge(serverExposureLayer), | |
| 744 | Layer.provideMerge( | |
| 745 | DesktopAppSettings.layerTest({ | |
| 746 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | |
| 747 | wslBackendEnabled: true, | |
| 748 | wslOnly: true, | |
| 749 | wslDistro: "Ubuntu", | |
| 750 | }), | |
| 751 | ), | |
| 752 | Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })), | |
| 753 | Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), | |
| 754 | ), | |
| 755 | ), | |
| 756 | ); | |
| 757 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 758 | ); | |
| 759 | ||
| 760 | it("resolvePrimaryLabel is runSync-safe against the real WSL availability probe", async () => { | |
| 761 | // getLocalEnvironmentBootstraps is a sync IPC method: it resolves the | |
| 762 | // primary instance's lazy label through Effect.runSync. The label chains | |
| 763 | // to wslEnvironment.isAvailable, whose real layer probes the filesystem. | |
| 764 | // That probe must run once at layer build and expose a resolved value, not | |
| 765 | // a live async effect — otherwise runSync throws in the handler. Build the | |
| 766 | // real WSL layer (not the sync test stub) and resolve the label with a | |
| 767 | // top-level runSync, exactly as the handler does. | |
| 768 | // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- This test intentionally replicates the sync IPC handler's runSync path to catch a regression to async-only resolution; it.effect would mask it. | |
| 769 | const runtime = ManagedRuntime.make( | |
| 770 | DesktopBackendConfiguration.layer.pipe( | |
| 771 | Layer.provideMerge(serverExposureLayer), | |
| 772 | Layer.provideMerge(DesktopAppSettings.layerTest()), | |
| 773 | Layer.provideMerge(DesktopWslEnvironment.layer), | |
| 774 | // isAvailable on win32 only touches the filesystem, never the spawner, | |
| 775 | // so a die-stub is enough to satisfy the layer's deps. | |
| 776 | Layer.provideMerge( | |
| 777 | Layer.succeed( | |
| 778 | ChildProcessSpawner.ChildProcessSpawner, | |
| 779 | ChildProcessSpawner.make(() => | |
| 780 | Effect.die("spawner should not be used while probing WSL availability"), | |
| 781 | ), | |
| 782 | ), | |
| 783 | ), | |
| 784 | Layer.provideMerge(makeEnvironmentLayer("/tmp/t3-wsl-isavailable", { platform: "win32" })), | |
| 785 | Layer.provide(NodeServices.layer), | |
| 786 | ), | |
| 787 | ); | |
| 788 | try { | |
| 789 | const configuration = await runtime.runPromise( | |
| 790 | DesktopBackendConfiguration.DesktopBackendConfiguration, | |
| 791 | ); | |
| 792 | // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- Same reason: this is the synchronous resolution the IPC handler performs. | |
| 793 | const label = Effect.runSync(configuration.resolvePrimaryLabel); | |
| 794 | assert.equal(typeof label, "string"); | |
| 795 | } finally { | |
| 796 | await runtime.dispose(); | |
| 797 | } | |
| 798 | }); | |
| 257 | 799 | }); |
apps/desktop/src/backend/DesktopBackendConfiguration.ts+550-48| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | ||
| 1 | 3 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 2 | 4 | import * as Context from "effect/Context"; |
| 3 | 5 | import * as Crypto from "effect/Crypto"; |
| ... | ... | @@ -7,12 +9,16 @@ import * as FileSystem from "effect/FileSystem"; |
| 7 | 9 | import * as Layer from "effect/Layer"; |
| 8 | 10 | import * as Option from "effect/Option"; |
| 9 | 11 | import * as PlatformError from "effect/PlatformError"; |
| 10 | import * as Ref from "effect/Ref"; | |
| 11 | 12 | import * as Schema from "effect/Schema"; |
| 13 | import * as SynchronizedRef from "effect/SynchronizedRef"; | |
| 14 | ||
| 15 | import serverPackageJson from "../../../server/package.json" with { type: "json" }; | |
| 12 | 16 | |
| 13 | 17 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 14 | 18 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 15 | 19 | import * as DesktopServerExposure from "./DesktopServerExposure.ts"; |
| 20 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | |
| 21 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; | |
| 16 | 22 | |
| 17 | 23 | export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass<DesktopBackendObservabilitySettingsReadError>()( |
| 18 | 24 | "DesktopBackendObservabilitySettingsReadError", |
| ... | ... | @@ -29,10 +35,31 @@ export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedE |
| 29 | 35 | export class DesktopBackendConfiguration extends Context.Service< |
| 30 | 36 | DesktopBackendConfiguration, |
| 31 | 37 | { |
| 32 | readonly resolve: Effect.Effect< | |
| 38 | // Build the Windows-native primary backend's start config. Reads the | |
| 39 | // primary's port/host/exposure from DesktopServerExposure. Can fail | |
| 40 | // with PlatformError because bootstrap token generation now uses | |
| 41 | // crypto.randomBytes under the hood (post Effect 4 migration). | |
| 42 | readonly resolvePrimary: Effect.Effect< | |
| 33 | 43 | DesktopBackendManager.DesktopBackendStartConfig, |
| 34 | 44 | PlatformError.PlatformError |
| 35 | 45 | >; |
| 46 | // Build a WSL backend start config for the given distro on the given | |
| 47 | // port. The WSL backend is always loopback-only (the primary owns LAN | |
| 48 | // exposure when the user opts in), so this takes the port directly and | |
| 49 | // hardcodes 127.0.0.1. Distro=null means "WSL default distro" and is | |
| 50 | // forwarded to wsl.exe with no -d flag. | |
| 51 | readonly resolveWsl: (input: { | |
| 52 | readonly port: number; | |
| 53 | readonly distro: string | null; | |
| 54 | }) => Effect.Effect< | |
| 55 | DesktopBackendManager.DesktopBackendStartConfig, | |
| 56 | PlatformError.PlatformError | |
| 57 | >; | |
| 58 | // The renderer-facing label for the primary instance, derived from the | |
| 59 | // same decision resolvePrimary makes (including the WSL-availability | |
| 60 | // fall-back to Windows), so the env switcher can't show "WSL" for a | |
| 61 | // backend that actually resolved to Windows. | |
| 62 | readonly resolvePrimaryLabel: Effect.Effect<string>; | |
| 36 | 63 | } |
| 37 | 64 | >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {} |
| 38 | 65 | |
| ... | ... | @@ -59,9 +86,48 @@ const DESKTOP_BACKEND_ENV_NAMES = [ |
| 59 | 86 | "T3CODE_TAILSCALE_SERVE_PORT", |
| 60 | 87 | ] as const; |
| 61 | 88 | |
| 89 | // Sensitive env vars that the WSL backend needs but Windows process.env won't | |
| 90 | // forward across the wsl.exe boundary without WSLENV. The dev-server URL is | |
| 91 | // handled separately via a `--dev-url` CLI flag because WSLENV translation of | |
| 92 | // URL-shaped values (colons / slashes) is unreliable. | |
| 93 | const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const; | |
| 94 | ||
| 95 | const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; | |
| 96 | ||
| 62 | 97 | const backendChildEnvPatch = (): Record<string, string | undefined> => |
| 63 | 98 | Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); |
| 64 | 99 | |
| 100 | const getWslEnvEntryName = (entry: string): string => { | |
| 101 | const slashIndex = entry.indexOf("/"); | |
| 102 | return slashIndex === -1 ? entry : entry.slice(0, slashIndex); | |
| 103 | }; | |
| 104 | ||
| 105 | const mergeWslEnv = ( | |
| 106 | existingWslEnv: string | undefined, | |
| 107 | forwardedEnvNames: ReadonlyArray<string>, | |
| 108 | ): string | undefined => { | |
| 109 | const existing = existingWslEnv?.trim() ?? ""; | |
| 110 | ||
| 111 | // Names already declared, so we don't forward a duplicate. We parse the | |
| 112 | // existing value only for this membership test — the string itself is | |
| 113 | // preserved verbatim below rather than re-serialized. | |
| 114 | const seenNames = new Set( | |
| 115 | existing | |
| 116 | .split(":") | |
| 117 | .map((entry) => getWslEnvEntryName(entry.trim())) | |
| 118 | .filter((name) => name.length > 0), | |
| 119 | ); | |
| 120 | ||
| 121 | const additions = forwardedEnvNames.filter((name) => !seenNames.has(name)); | |
| 122 | ||
| 123 | // Preserve the user's WSLENV exactly as Windows handed it to us — empty | |
| 124 | // "::" segments and duplicate entries are harmless no-ops to WSL and not | |
| 125 | // ours to normalize — and only append the secrets we need to forward | |
| 126 | // across the wsl.exe boundary. | |
| 127 | const parts = [existing, ...additions].filter((part) => part.length > 0); | |
| 128 | return parts.length > 0 ? parts.join(":") : undefined; | |
| 129 | }; | |
| 130 | ||
| 65 | 131 | const logBackendObservabilitySettingsReadFailure = ( |
| 66 | 132 | settingsPath: string, |
| 67 | 133 | cause: PlatformError.PlatformError, |
| ... | ... | @@ -100,11 +166,168 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { |
| 100 | 166 | }; |
| 101 | 167 | }); |
| 102 | 168 | |
| 103 | const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolveStartConfig")( | |
| 104 | function* (input: { | |
| 105 | readonly bootstrapToken: string; | |
| 106 | readonly observabilitySettings: BackendObservabilitySettings; | |
| 107 | }): Effect.fn.Return< | |
| 169 | interface SharedBootstrapInput { | |
| 170 | readonly bootstrapToken: string; | |
| 171 | readonly observabilitySettings: BackendObservabilitySettings; | |
| 172 | } | |
| 173 | ||
| 174 | interface WslPreflightSuccess { | |
| 175 | readonly _tag: "Ready"; | |
| 176 | readonly runningDistro: string; | |
| 177 | readonly linuxEntryPath: string; | |
| 178 | // Absolute path to the node binary the preflight validated after the shared | |
| 179 | // remote resolver repaired PATH. The launch must use this exact path so it | |
| 180 | // doesn't fall through to a different/old node than the one node-pty was | |
| 181 | // built against. | |
| 182 | readonly nodePath: string; | |
| 183 | // PATH captured from the same login shell after the shared resolver loaded | |
| 184 | // version managers. The launch forwards this value directly without a shell. | |
| 185 | readonly resolvedPath: string; | |
| 186 | } | |
| 187 | ||
| 188 | interface WslPreflightFailure { | |
| 189 | readonly _tag: "Failed"; | |
| 190 | readonly reason: string; | |
| 191 | // Fatal: the WSL distro is misconfigured (no node, wrong version, missing | |
| 192 | // build tools) and retrying won't help — surface it and (wsl-only) fall back | |
| 193 | // to Windows. Non-fatal: transient (WSL not ready yet, wslpath while it | |
| 194 | // boots), with a bounded window for self-healing before fallback. | |
| 195 | readonly fatal: boolean; | |
| 196 | readonly retryLimit?: number; | |
| 197 | } | |
| 198 | ||
| 199 | const WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT = 12; | |
| 200 | ||
| 201 | const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(function* (input: { | |
| 202 | readonly distro: string | null; | |
| 203 | readonly windowsEntryPath: string; | |
| 204 | readonly windowsRepoRoot: string; | |
| 205 | readonly allowBuild: boolean; | |
| 206 | }): Effect.fn.Return< | |
| 207 | WslPreflightSuccess | WslPreflightFailure, | |
| 208 | never, | |
| 209 | DesktopWslEnvironment.DesktopWslEnvironment | FileSystem.FileSystem | |
| 210 | > { | |
| 211 | const wslEnv = yield* DesktopWslEnvironment.DesktopWslEnvironment; | |
| 212 | const fileSystem = yield* FileSystem.FileSystem; | |
| 213 | ||
| 214 | const wslAvailable = yield* wslEnv.isAvailable; | |
| 215 | if (!wslAvailable) { | |
| 216 | return { | |
| 217 | _tag: "Failed", | |
| 218 | reason: "WSL is not available on this system", | |
| 219 | fatal: false, | |
| 220 | } as const; | |
| 221 | } | |
| 222 | ||
| 223 | const distroProbe = yield* wslEnv.probeDistros.pipe( | |
| 224 | Effect.map((distros) => ({ _tag: "Success", distros }) as const), | |
| 225 | Effect.catch((error) => Effect.succeed({ _tag: "Failure", error } as const)), | |
| 226 | ); | |
| 227 | if (distroProbe._tag === "Failure") { | |
| 228 | return { | |
| 229 | _tag: "Failed", | |
| 230 | reason: `Unable to list WSL distributions: ${distroProbe.error.message}`, | |
| 231 | fatal: false, | |
| 232 | } as const; | |
| 233 | } | |
| 234 | ||
| 235 | const installedDistros = distroProbe.distros; | |
| 236 | const runningDistro = input.distro | |
| 237 | ? (installedDistros.find( | |
| 238 | (installed) => installed.name.toLowerCase() === input.distro?.toLowerCase(), | |
| 239 | )?.name ?? null) | |
| 240 | : (installedDistros.find((installed) => installed.isDefault)?.name ?? null); | |
| 241 | if (runningDistro === null) { | |
| 242 | return { | |
| 243 | _tag: "Failed", | |
| 244 | reason: input.distro | |
| 245 | ? `WSL distro is not installed: ${input.distro}` | |
| 246 | : installedDistros.length === 0 | |
| 247 | ? "WSL has no installed distributions" | |
| 248 | : "WSL has no default distribution", | |
| 249 | fatal: true, | |
| 250 | } as const; | |
| 251 | } | |
| 252 | ||
| 253 | const entryExists = yield* fileSystem | |
| 254 | .exists(input.windowsEntryPath) | |
| 255 | .pipe(Effect.orElseSucceed(() => false)); | |
| 256 | if (!entryExists) { | |
| 257 | return { | |
| 258 | _tag: "Failed", | |
| 259 | reason: `missing server entry at ${input.windowsEntryPath}`, | |
| 260 | fatal: true, | |
| 261 | } as const; | |
| 262 | } | |
| 263 | ||
| 264 | const linuxEntry = yield* wslEnv.windowsToWslPath(runningDistro, input.windowsEntryPath); | |
| 265 | if (Option.isNone(linuxEntry)) { | |
| 266 | return { | |
| 267 | _tag: "Failed", | |
| 268 | reason: `wslpath conversion failed for ${input.windowsEntryPath}`, | |
| 269 | fatal: false, | |
| 270 | } as const; | |
| 271 | } | |
| 272 | ||
| 273 | const nodePtyResult = yield* wslEnv.ensureNodePty(runningDistro, input.windowsRepoRoot, { | |
| 274 | allowBuild: input.allowBuild, | |
| 275 | nodeEngineRange: serverPackageJson.engines.node, | |
| 276 | }); | |
| 277 | if (!nodePtyResult.ok) { | |
| 278 | return { | |
| 279 | _tag: "Failed", | |
| 280 | reason: `WSL node-pty unavailable: ${nodePtyResult.reason}`, | |
| 281 | fatal: nodePtyResult.fatal, | |
| 282 | ...(nodePtyResult.retryLimit === undefined ? {} : { retryLimit: nodePtyResult.retryLimit }), | |
| 283 | } as const; | |
| 284 | } | |
| 285 | ||
| 286 | return { | |
| 287 | _tag: "Ready", | |
| 288 | runningDistro, | |
| 289 | linuxEntryPath: linuxEntry.value, | |
| 290 | nodePath: nodePtyResult.nodePath, | |
| 291 | resolvedPath: nodePtyResult.resolvedPath, | |
| 292 | } as const; | |
| 293 | }); | |
| 294 | ||
| 295 | // True when the given IPv4 belongs to a Windows-side network | |
| 296 | // interface. In WSL2 mirrored mode the distro's eth0 IP equals the | |
| 297 | // host's, which is the signature we use to detect that mode and | |
| 298 | // switch the renderer URL to loopback. | |
| 299 | const isLocalHostIpv4 = (ip: string): boolean => { | |
| 300 | const interfaces = NodeOS.networkInterfaces(); | |
| 301 | for (const list of Object.values(interfaces)) { | |
| 302 | if (!list) continue; | |
| 303 | for (const entry of list) { | |
| 304 | // os.networkInterfaces() reports IPv4 `family` as the string "IPv4" on | |
| 305 | // the Node build Electron ships (41 / Node 22, verified), but some Node | |
| 306 | // builds report the numeric 4. Normalize to a string so a future runtime | |
| 307 | // bump can't silently break mirrored-mode detection and leave the | |
| 308 | // renderer pointed at the distro IP instead of loopback. | |
| 309 | const family = String(entry.family); | |
| 310 | if ((family === "IPv4" || family === "4") && entry.address === ip) return true; | |
| 311 | } | |
| 312 | } | |
| 313 | return false; | |
| 314 | }; | |
| 315 | ||
| 316 | const buildObservabilityFragment = (observabilitySettings: BackendObservabilitySettings) => ({ | |
| 317 | ...Option.match(observabilitySettings.otlpTracesUrl, { | |
| 318 | onNone: () => ({}), | |
| 319 | onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), | |
| 320 | }), | |
| 321 | ...Option.match(observabilitySettings.otlpMetricsUrl, { | |
| 322 | onNone: () => ({}), | |
| 323 | onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), | |
| 324 | }), | |
| 325 | }); | |
| 326 | ||
| 327 | const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")( | |
| 328 | function* ( | |
| 329 | input: SharedBootstrapInput, | |
| 330 | ): Effect.fn.Return< | |
| 108 | 331 | DesktopBackendManager.DesktopBackendStartConfig, |
| 109 | 332 | never, |
| 110 | 333 | DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure |
| ... | ... | @@ -113,70 +336,349 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv |
| 113 | 336 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; |
| 114 | 337 | const backendExposure = yield* serverExposure.backendConfig; |
| 115 | 338 | |
| 339 | const bootstrap = { | |
| 340 | mode: "desktop" as const, | |
| 341 | noBrowser: true, | |
| 342 | port: backendExposure.port, | |
| 343 | t3Home: environment.baseDir, | |
| 344 | host: backendExposure.bindHost, | |
| 345 | desktopBootstrapToken: input.bootstrapToken, | |
| 346 | tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, | |
| 347 | tailscaleServePort: backendExposure.tailscaleServePort, | |
| 348 | ...buildObservabilityFragment(input.observabilitySettings), | |
| 349 | }; | |
| 350 | ||
| 116 | 351 | return { |
| 117 | 352 | executablePath: process.execPath, |
| 353 | args: [environment.backendEntryPath, "--bootstrap-fd", "3"], | |
| 118 | 354 | entryPath: environment.backendEntryPath, |
| 119 | 355 | cwd: environment.backendCwd, |
| 120 | 356 | env: { |
| 121 | 357 | ...backendChildEnvPatch(), |
| 122 | 358 | ELECTRON_RUN_AS_NODE: "1", |
| 123 | 359 | }, |
| 124 | bootstrap: { | |
| 125 | mode: "desktop", | |
| 126 | noBrowser: true, | |
| 127 | port: backendExposure.port, | |
| 128 | t3Home: environment.baseDir, | |
| 129 | host: backendExposure.bindHost, | |
| 130 | desktopBootstrapToken: input.bootstrapToken, | |
| 131 | tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, | |
| 132 | tailscaleServePort: backendExposure.tailscaleServePort, | |
| 133 | ...Option.match(input.observabilitySettings.otlpTracesUrl, { | |
| 134 | onNone: () => ({}), | |
| 135 | onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), | |
| 136 | }), | |
| 137 | ...Option.match(input.observabilitySettings.otlpMetricsUrl, { | |
| 138 | onNone: () => ({}), | |
| 139 | onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), | |
| 140 | }), | |
| 141 | }, | |
| 360 | // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). | |
| 361 | extendEnv: true, | |
| 362 | bootstrap, | |
| 363 | bootstrapDelivery: "fd3", | |
| 142 | 364 | httpBaseUrl: backendExposure.httpBaseUrl, |
| 143 | 365 | captureOutput: true, |
| 144 | }; | |
| 366 | preflightFailure: Option.none(), | |
| 367 | } satisfies DesktopBackendManager.DesktopBackendStartConfig; | |
| 145 | 368 | }, |
| 146 | 369 | ); |
| 147 | 370 | |
| 371 | const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl")(function* ( | |
| 372 | input: SharedBootstrapInput & { | |
| 373 | readonly port: number; | |
| 374 | readonly distro: string | null; | |
| 375 | }, | |
| 376 | ): Effect.fn.Return< | |
| 377 | DesktopBackendManager.DesktopBackendStartConfig, | |
| 378 | never, | |
| 379 | | DesktopEnvironment.DesktopEnvironment | |
| 380 | | DesktopWslEnvironment.DesktopWslEnvironment | |
| 381 | | FileSystem.FileSystem | |
| 382 | > { | |
| 383 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 384 | const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; | |
| 385 | ||
| 386 | // Bind to 0.0.0.0 inside WSL so the backend is reachable both via | |
| 387 | // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1 | |
| 388 | // -> WSL 127.0.0.1) AND via the distro's eth0 IP directly from | |
| 389 | // Windows. wslhost forwarding is unreliable on some Windows hosts: | |
| 390 | // the desktop's readiness probe and the renderer's saved-env-style | |
| 391 | // fetch both saw "Failed to fetch" when the backend only bound to | |
| 392 | // 127.0.0.1 inside WSL. Binding to 0.0.0.0 plus advertising the | |
| 393 | // WSL IP as the renderer-visible URL avoids that dependency. | |
| 394 | // Security-wise this is acceptable for the local-only WSL backend: | |
| 395 | // the network it exposes on is the WSL-vEthernet network, not the | |
| 396 | // LAN; the primary owns LAN exposure when the user opts in. | |
| 397 | const wslBindHost = "0.0.0.0"; | |
| 398 | ||
| 399 | const bootstrap = { | |
| 400 | mode: "desktop" as const, | |
| 401 | noBrowser: true, | |
| 402 | port: input.port, | |
| 403 | // Omit t3Home so the Linux backend uses its own home dir instead of | |
| 404 | // the Windows-side baseDir (which would be a /mnt/c path and share | |
| 405 | // the SQLite file with the primary). | |
| 406 | host: wslBindHost, | |
| 407 | desktopBootstrapToken: input.bootstrapToken, | |
| 408 | // PortSchema rejects 0, so when tailscale serve is disabled we still | |
| 409 | // need a valid number in this slot. The backend reads tailscaleServePort | |
| 410 | // only when tailscaleServeEnabled is true, so the actual value here is | |
| 411 | // inert. | |
| 412 | tailscaleServeEnabled: false, | |
| 413 | tailscaleServePort: 443, | |
| 414 | ...buildObservabilityFragment(input.observabilitySettings), | |
| 415 | }; | |
| 416 | ||
| 417 | // In packaged builds environment.appRoot is .../resources/app.asar — an | |
| 418 | // archive FILE. The Windows primary reads its entry through | |
| 419 | // ELECTRON_RUN_AS_NODE (asar-aware), but the WSL backend launches plain | |
| 420 | // `wsl.exe -- node`, which can't read inside an asar. electron-builder unpacks | |
| 421 | // the server bundle + node-pty (see asarUnpack in build-desktop-artifact.ts) | |
| 422 | // to the app.asar.unpacked sibling, so point WSL there. In dev appRoot is | |
| 423 | // already a real directory, so this is a no-op. | |
| 424 | const wslAppRoot = environment.isPackaged | |
| 425 | ? environment.path.join(environment.resourcesPath, "app.asar.unpacked") | |
| 426 | : environment.appRoot; | |
| 427 | const wslEntryPath = environment.path.join(wslAppRoot, "apps/server/dist/bin.mjs"); | |
| 428 | ||
| 429 | const preflight = yield* runWslPreflight({ | |
| 430 | distro: input.distro, | |
| 431 | windowsEntryPath: wslEntryPath, | |
| 432 | windowsRepoRoot: wslAppRoot, | |
| 433 | // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and | |
| 434 | // attached to the Windows artifact — see build-desktop-artifact.ts), so the | |
| 435 | // WSL backend never needs a compiler, node-gyp, or network on first launch. | |
| 436 | // Compiling from source is a dev-only convenience: a checkout has no shipped | |
| 437 | // prebuilt, and developers have the toolchain. In packaged builds we instead | |
| 438 | // surface a clear diagnostic if the prebuilt can't load (unsupported | |
| 439 | // arch/distro), rather than silently dropping into a fragile runtime build. | |
| 440 | allowBuild: !environment.isPackaged, | |
| 441 | }); | |
| 442 | ||
| 443 | // Every operation after preflight uses the same concrete distro. In | |
| 444 | // default-tracking mode this closes the race where the system default | |
| 445 | // changes between probing and spawning the backend. | |
| 446 | const runningDistro = preflight._tag === "Ready" ? preflight.runningDistro : null; | |
| 447 | const distroForConfig = runningDistro ?? input.distro; | |
| 448 | ||
| 449 | // Resolve the selected distro's IPv4 address. In mirrored mode the distro | |
| 450 | // reports a host interface, so use loopback instead; a failed probe also | |
| 451 | // falls back to loopback and preserves the previous behavior. | |
| 452 | const distroIp = yield* wslEnvironment.getDistroIp(distroForConfig); | |
| 453 | const usesSharedNetworkStack = Option.match(distroIp, { | |
| 454 | onNone: () => false, | |
| 455 | onSome: (ip) => isLocalHostIpv4(ip), | |
| 456 | }); | |
| 457 | const rendererHost = usesSharedNetworkStack | |
| 458 | ? "127.0.0.1" | |
| 459 | : Option.getOrElse(distroIp, () => "127.0.0.1"); | |
| 460 | const httpBaseUrl = new URL(`http://${rendererHost}:${input.port}`); | |
| 461 | ||
| 462 | const distroArgs = distroForConfig ? ["-d", distroForConfig] : []; | |
| 463 | const forwardedEnv: Record<string, string> = {}; | |
| 464 | const forwardedEnvNames: string[] = []; | |
| 465 | for (const name of WSL_FORWARDED_ENV_NAMES) { | |
| 466 | const value = process.env[name]; | |
| 467 | if (value !== undefined && value.length > 0) { | |
| 468 | forwardedEnv[name] = value; | |
| 469 | forwardedEnvNames.push(name); | |
| 470 | } | |
| 471 | } | |
| 472 | ||
| 473 | // Build an explicit copy of process.env minus T3CODE_HOME (dev-runner | |
| 474 | // exports the Windows-side base dir for the primary; if it leaks into | |
| 475 | // the WSL backend the Linux side ends up sharing C:\Users\...\.t3 via | |
| 476 | // /mnt/c, which means both backends read/write the same database and | |
| 477 | // their env-ids collide). | |
| 478 | const parentEnvWithoutT3Home: Record<string, string | undefined> = {}; | |
| 479 | for (const [key, value] of Object.entries(process.env)) { | |
| 480 | if (key === "T3CODE_HOME") continue; | |
| 481 | parentEnvWithoutT3Home[key] = value; | |
| 482 | } | |
| 483 | const wslEnv = mergeWslEnv(parentEnvWithoutT3Home.WSLENV, forwardedEnvNames); | |
| 484 | ||
| 485 | const baseConfig = { | |
| 486 | executablePath: "wsl.exe", | |
| 487 | entryPath: wslEntryPath, | |
| 488 | cwd: environment.backendCwd, | |
| 489 | env: { | |
| 490 | ...parentEnvWithoutT3Home, | |
| 491 | ...backendChildEnvPatch(), | |
| 492 | ...forwardedEnv, | |
| 493 | ...(wslEnv !== undefined ? { WSLENV: wslEnv } : {}), | |
| 494 | }, | |
| 495 | // env is already a complete process.env minus T3CODE_HOME; pass it | |
| 496 | // verbatim instead of letting the spawner re-merge process.env on top. | |
| 497 | extendEnv: false, | |
| 498 | bootstrap, | |
| 499 | bootstrapDelivery: "stdin" as const, | |
| 500 | httpBaseUrl, | |
| 501 | captureOutput: true, | |
| 502 | ...(runningDistro !== null ? { runningDistro } : {}), | |
| 503 | }; | |
| 504 | ||
| 505 | // Forward the dev-server URL as an explicit CLI flag so the WSL backend's | |
| 506 | // config resolution lands in dev/ instead of userdata/. Inheriting through | |
| 507 | // WSLENV is unreliable in practice (URL-shaped values with colons / | |
| 508 | // slashes get translated unpredictably depending on flags), and the | |
| 509 | // packaged build leaves devServerUrl as None anyway. | |
| 510 | const devUrlArgs = Option.match(environment.devServerUrl, { | |
| 511 | onNone: () => [] as ReadonlyArray<string>, | |
| 512 | onSome: (url) => ["--dev-url", url.href], | |
| 513 | }); | |
| 514 | ||
| 515 | if (preflight._tag === "Failed") { | |
| 516 | const retryLimit = | |
| 517 | preflight.retryLimit ?? (preflight.fatal ? undefined : WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT); | |
| 518 | return { | |
| 519 | ...baseConfig, | |
| 520 | args: [...distroArgs, "--", "node", "--version"], | |
| 521 | preflightFailure: Option.some({ | |
| 522 | reason: preflight.reason, | |
| 523 | fatal: preflight.fatal, | |
| 524 | ...(retryLimit === undefined ? {} : { retryLimit }), | |
| 525 | }), | |
| 526 | } satisfies DesktopBackendManager.DesktopBackendStartConfig; | |
| 527 | } | |
| 528 | ||
| 529 | // The WSL server spawns commands its providers reference by name — `npm`/`npx` | |
| 530 | // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those | |
| 531 | // live in the resolved Node's bin dir, which `wsl.exe -- node` does NOT put on | |
| 532 | // the process PATH, so `npm install -g ...` fails with NotFound. Pass the | |
| 533 | // user PATH entries captured by the login-shell preflight. Every dynamic | |
| 534 | // value is a separate argv entry under `wsl.exe --exec`; no shell command is | |
| 535 | // involved, so Windows cannot mangle nested quotes and stdin remains reserved | |
| 536 | // for the bootstrap envelope. | |
| 537 | const lastSlash = preflight.nodePath.lastIndexOf("/"); | |
| 538 | const nodeBinDir = lastSlash > 0 ? preflight.nodePath.slice(0, lastSlash) : "/usr/bin"; | |
| 539 | const launchPath = `${nodeBinDir}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`; | |
| 540 | ||
| 541 | return { | |
| 542 | ...baseConfig, | |
| 543 | args: [ | |
| 544 | ...distroArgs, | |
| 545 | "--exec", | |
| 546 | "env", | |
| 547 | `PATH=${launchPath}`, | |
| 548 | preflight.nodePath, | |
| 549 | preflight.linuxEntryPath, | |
| 550 | "--bootstrap-fd", | |
| 551 | "0", | |
| 552 | ...devUrlArgs, | |
| 553 | ], | |
| 554 | preflightFailure: Option.none(), | |
| 555 | } satisfies DesktopBackendManager.DesktopBackendStartConfig; | |
| 556 | }); | |
| 557 | ||
| 148 | 558 | export const make = Effect.gen(function* () { |
| 149 | 559 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 150 | 560 | const fileSystem = yield* FileSystem.FileSystem; |
| 151 | 561 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; |
| 562 | const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; | |
| 563 | const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 152 | 564 | const crypto = yield* Crypto.Crypto; |
| 153 | const tokenRef = yield* Ref.make(Option.none<string>()); | |
| 154 | const getOrCreateBootstrapToken = Effect.gen(function* () { | |
| 155 | const existing = yield* Ref.get(tokenRef); | |
| 156 | if (Option.isSome(existing)) { | |
| 157 | return existing.value; | |
| 158 | } | |
| 565 | // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic. | |
| 566 | // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can | |
| 567 | // resolve concurrently; with a plain Ref both could observe None, generate | |
| 568 | // distinct tokens, and one would overwrite the other — leaving the two | |
| 569 | // backends holding mismatched tokens and breaking the shared-token | |
| 570 | // invariant the renderer relies on. modifyEffect serializes the whole | |
| 571 | // get-or-create so the first caller wins and the rest reuse its token. | |
| 572 | const tokenRef = yield* SynchronizedRef.make(Option.none<string>()); | |
| 573 | const getOrCreateBootstrapToken = SynchronizedRef.modifyEffect(tokenRef, (current) => | |
| 574 | Option.match(current, { | |
| 575 | onSome: (token) => Effect.succeed([token, current] as const), | |
| 576 | onNone: () => | |
| 577 | crypto.randomBytes(24).pipe( | |
| 578 | Effect.map((bytes) => { | |
| 579 | const token = Encoding.encodeHex(bytes); | |
| 580 | return [token, Option.some(token)] as const; | |
| 581 | }), | |
| 582 | ), | |
| 583 | }), | |
| 584 | ); | |
| 585 | ||
| 586 | // Both resolvers share the same bootstrap token: the renderer holds a | |
| 587 | // single token and uses it against whichever backend it's currently | |
| 588 | // talking to. Observability settings get re-read each resolve so a | |
| 589 | // hot-swap of the server-settings file is picked up on the next | |
| 590 | // restart cycle without having to bounce the desktop process. | |
| 591 | const sharedInputs = Effect.gen(function* () { | |
| 592 | const bootstrapToken = yield* getOrCreateBootstrapToken; | |
| 593 | const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( | |
| 594 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 595 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | |
| 596 | ); | |
| 597 | return { bootstrapToken, observabilitySettings } satisfies SharedBootstrapInput; | |
| 598 | }); | |
| 159 | 599 | |
| 160 | const token = Encoding.encodeHex(yield* crypto.randomBytes(24)); | |
| 161 | yield* Ref.set(tokenRef, Option.some(token)); | |
| 162 | return token; | |
| 600 | const buildWslPrimaryConfig = Effect.gen(function* () { | |
| 601 | // wsl-only mode pipes the WSL backend through the same port the | |
| 602 | // Windows primary would normally take. That way the renderer | |
| 603 | // still loads from the local-only endpoint advertised by | |
| 604 | // DesktopServerExposure, and primary-aware code paths (cookie | |
| 605 | // auth, the env switcher's "primary" id) keep working without | |
| 606 | // a parallel "secondary" registration. | |
| 607 | const backendExposure = yield* serverExposure.backendConfig; | |
| 608 | const persistedSettings = yield* settings.get; | |
| 609 | const shared = yield* sharedInputs; | |
| 610 | return yield* resolveWslStartConfig({ | |
| 611 | ...shared, | |
| 612 | port: backendExposure.port, | |
| 613 | distro: persistedSettings.wslDistro, | |
| 614 | }).pipe( | |
| 615 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | |
| 616 | Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), | |
| 617 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 618 | ); | |
| 619 | }); | |
| 620 | ||
| 621 | const buildWindowsPrimaryConfig = Effect.gen(function* () { | |
| 622 | const shared = yield* sharedInputs; | |
| 623 | return yield* resolvePrimaryStartConfig(shared).pipe( | |
| 624 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | |
| 625 | Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), | |
| 626 | ); | |
| 627 | }); | |
| 628 | ||
| 629 | // Single source of truth for what the primary actually runs as. Both | |
| 630 | // the start-config dispatch and the renderer-facing label derive from | |
| 631 | // this, so they can't disagree — e.g. the label reading "WSL" while the | |
| 632 | // config silently fell back to Windows because WSL is unavailable. | |
| 633 | // Dispatch happens at resolve time so toggling wsl-only between restarts | |
| 634 | // is picked up on the next start cycle (the pool's primary instance is | |
| 635 | // created once at layer init, but configResolve fires on each restart). | |
| 636 | const describePrimary = Effect.gen(function* () { | |
| 637 | const persistedSettings = yield* settings.get; | |
| 638 | const wslRequested = persistedSettings.wslOnly && persistedSettings.wslBackendEnabled; | |
| 639 | // Only honor wsl-only when WSL is actually usable. If the user | |
| 640 | // persisted wsl-only but WSL has since become unavailable (wsl.exe | |
| 641 | // removed, no distro), fall back to the Windows primary instead of | |
| 642 | // looping forever on preflight failures: the Connections backend | |
| 643 | // control is hidden while WSL is unavailable, so a stuck WSL primary | |
| 644 | // would otherwise leave no in-app way back to Windows. | |
| 645 | const useWsl = wslRequested && (yield* wslEnvironment.isAvailable); | |
| 646 | return { useWsl, wslRequested, distro: persistedSettings.wslDistro }; | |
| 163 | 647 | }); |
| 164 | 648 | |
| 165 | 649 | return DesktopBackendConfiguration.of({ |
| 166 | resolve: Effect.gen(function* () { | |
| 167 | const bootstrapToken = yield* getOrCreateBootstrapToken; | |
| 168 | const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( | |
| 169 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 170 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | |
| 171 | ); | |
| 172 | return yield* resolveBackendStartConfig({ | |
| 173 | bootstrapToken, | |
| 174 | observabilitySettings, | |
| 650 | resolvePrimary: Effect.gen(function* () { | |
| 651 | const { useWsl, wslRequested } = yield* describePrimary; | |
| 652 | if (useWsl) { | |
| 653 | return yield* buildWslPrimaryConfig; | |
| 654 | } | |
| 655 | if (wslRequested) { | |
| 656 | yield* Effect.logWarning( | |
| 657 | "WSL-only backend requested but WSL is unavailable; starting the Windows primary instead.", | |
| 658 | ); | |
| 659 | } | |
| 660 | return yield* buildWindowsPrimaryConfig; | |
| 661 | }).pipe(Effect.withSpan("desktop.backendConfiguration.resolvePrimary")), | |
| 662 | resolvePrimaryLabel: Effect.gen(function* () { | |
| 663 | const { useWsl, distro } = yield* describePrimary; | |
| 664 | if (!useWsl) { | |
| 665 | return environment.platform === "win32" ? "Windows" : "Local environment"; | |
| 666 | } | |
| 667 | return distro ? `WSL (${distro})` : "WSL"; | |
| 668 | }).pipe(Effect.withSpan("desktop.backendConfiguration.resolvePrimaryLabel")), | |
| 669 | resolveWsl: (input) => | |
| 670 | Effect.gen(function* () { | |
| 671 | const shared = yield* sharedInputs; | |
| 672 | return yield* resolveWslStartConfig({ ...shared, ...input }).pipe( | |
| 673 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | |
| 674 | Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), | |
| 675 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 676 | ); | |
| 175 | 677 | }).pipe( |
| 176 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | |
| 177 | Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), | |
| 178 | ); | |
| 179 | }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")), | |
| 678 | Effect.withSpan("desktop.backendConfiguration.resolveWsl", { | |
| 679 | attributes: { port: input.port, distro: input.distro ?? null }, | |
| 680 | }), | |
| 681 | ), | |
| 180 | 682 | }); |
| 181 | 683 | }); |
| 182 | 684 |
apps/desktop/src/backend/DesktopBackendManager.test.ts+500-536| ... | ... | @@ -3,15 +3,12 @@ import { |
| 3 | 3 | type DesktopBackendBootstrap as DesktopBackendBootstrapValue, |
| 4 | 4 | } from "@t3tools/contracts"; |
| 5 | 5 | import { assert, describe, it } from "@effect/vitest"; |
| 6 | import * as Cause from "effect/Cause"; | |
| 7 | 6 | import * as Deferred from "effect/Deferred"; |
| 8 | 7 | import * as Duration from "effect/Duration"; |
| 9 | 8 | import * as Effect from "effect/Effect"; |
| 10 | 9 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Fiber from "effect/Fiber"; | |
| 12 | 10 | import * as Layer from "effect/Layer"; |
| 13 | 11 | import * as Option from "effect/Option"; |
| 14 | import * as PlatformError from "effect/PlatformError"; | |
| 15 | 12 | import * as Queue from "effect/Queue"; |
| 16 | 13 | import * as Ref from "effect/Ref"; |
| 17 | 14 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -23,18 +20,15 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab |
| 23 | 20 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 24 | 21 | |
| 25 | 22 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 26 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | |
| 27 | 23 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 28 | import * as DesktopState from "../app/DesktopState.ts"; | |
| 29 | import * as DesktopWindow from "../window/DesktopWindow.ts"; | |
| 30 | 24 | |
| 31 | 25 | const decodeDesktopBackendBootstrap = Schema.decodeEffect( |
| 32 | 26 | Schema.fromJsonString(DesktopBackendBootstrap), |
| 33 | 27 | ); |
| 34 | const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError); | |
| 35 | 28 | |
| 36 | 29 | const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { |
| 37 | 30 | executablePath: "/electron", |
| 31 | args: ["/server/bin.mjs", "--bootstrap-fd", "3"], | |
| 38 | 32 | entryPath: "/server/bin.mjs", |
| 39 | 33 | cwd: "/server", |
| 40 | 34 | env: { ELECTRON_RUN_AS_NODE: "1" }, |
| ... | ... | @@ -48,8 +42,11 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { |
| 48 | 42 | tailscaleServeEnabled: false, |
| 49 | 43 | tailscaleServePort: 443, |
| 50 | 44 | }, |
| 45 | bootstrapDelivery: "fd3", | |
| 46 | extendEnv: true, | |
| 51 | 47 | httpBaseUrl: new URL("http://127.0.0.1:3773"), |
| 52 | 48 | captureOutput: true, |
| 49 | preflightFailure: Option.none(), | |
| 53 | 50 | }; |
| 54 | 51 | |
| 55 | 52 | const configWithObservability: DesktopBackendBootstrapValue = { |
| ... | ... | @@ -59,9 +56,9 @@ const configWithObservability: DesktopBackendBootstrapValue = { |
| 59 | 56 | }; |
| 60 | 57 | |
| 61 | 58 | function makeProcess(options?: { |
| 62 | readonly stdout?: Stream.Stream<Uint8Array, PlatformError.PlatformError>; | |
| 63 | readonly stderr?: Stream.Stream<Uint8Array, PlatformError.PlatformError>; | |
| 64 | readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>; | |
| 59 | readonly stdout?: Stream.Stream<Uint8Array>; | |
| 60 | readonly stderr?: Stream.Stream<Uint8Array>; | |
| 61 | readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>; | |
| 65 | 62 | readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; |
| 66 | 63 | }): ChildProcessSpawner.ChildProcessHandle { |
| 67 | 64 | return ChildProcessSpawner.makeHandle({ |
| ... | ... | @@ -105,114 +102,98 @@ function decodeBootstrap(raw: string) { |
| 105 | 102 | return decodeDesktopBackendBootstrap(raw); |
| 106 | 103 | } |
| 107 | 104 | |
| 108 | function makeManagerLayer(input: { | |
| 105 | interface MakeInstanceInput { | |
| 109 | 106 | readonly spawnerLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>; |
| 110 | 107 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; |
| 111 | readonly backendOutputLog?: Partial<DesktopObservability.DesktopBackendOutputLog["Service"]>; | |
| 112 | readonly desktopState?: DesktopState.DesktopState["Service"]; | |
| 113 | readonly desktopWindow?: Partial<DesktopWindow.DesktopWindow["Service"]>; | |
| 108 | readonly backendOutputLog?: Partial<DesktopObservability.DesktopBackendOutputLogShape>; | |
| 109 | readonly onReady?: Effect.Effect<void>; | |
| 110 | readonly onShutdown?: Effect.Effect<void>; | |
| 111 | readonly onPreflightFailed?: ( | |
| 112 | failure: DesktopBackendManager.PreflightFailure, | |
| 113 | ) => Effect.Effect<boolean>; | |
| 114 | 114 | readonly config?: DesktopBackendManager.DesktopBackendStartConfig; |
| 115 | }) { | |
| 116 | return DesktopBackendManager.layer.pipe( | |
| 117 | Layer.provide( | |
| 118 | Layer.mergeAll( | |
| 119 | FileSystem.layerNoop({ | |
| 120 | exists: () => Effect.succeed(true), | |
| 121 | }), | |
| 122 | Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { | |
| 123 | resolve: Effect.succeed(input.config ?? baseConfig), | |
| 124 | }), | |
| 125 | input.spawnerLayer, | |
| 126 | input.httpClientLayer ?? healthyHttpClientLayer, | |
| 127 | input.desktopState | |
| 128 | ? Layer.succeed(DesktopState.DesktopState, input.desktopState) | |
| 129 | : DesktopState.layer, | |
| 130 | Layer.succeed(DesktopObservability.DesktopBackendOutputLog, { | |
| 131 | writeSessionBoundary: () => Effect.void, | |
| 132 | writeOutputChunk: () => Effect.void, | |
| 133 | ...input.backendOutputLog, | |
| 134 | } satisfies DesktopObservability.DesktopBackendOutputLog["Service"]), | |
| 135 | Layer.succeed(DesktopWindow.DesktopWindow, { | |
| 136 | createMain: Effect.die("unexpected createMain"), | |
| 137 | ensureMain: Effect.die("unexpected ensureMain"), | |
| 138 | revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), | |
| 139 | activate: Effect.void, | |
| 140 | createMainIfBackendReady: Effect.void, | |
| 141 | handleBackendReady: Effect.void, | |
| 142 | dispatchMenuAction: () => Effect.void, | |
| 143 | syncAppearance: Effect.void, | |
| 144 | ...input.desktopWindow, | |
| 145 | } satisfies DesktopWindow.DesktopWindow["Service"]), | |
| 146 | ), | |
| 147 | ), | |
| 148 | ); | |
| 115 | readonly configResolve?: Effect.Effect<DesktopBackendManager.DesktopBackendStartConfig>; | |
| 149 | 116 | } |
| 150 | 117 | |
| 151 | describe("DesktopBackendManager", () => { | |
| 152 | it("preserves the complete restart cause and schedule context", () => { | |
| 153 | const cause = Cause.combine( | |
| 154 | Cause.fail(new Error("start failed")), | |
| 155 | Cause.die(new Error("restart defect")), | |
| 156 | ); | |
| 157 | const error = new DesktopBackendManager.DesktopBackendRestartError({ | |
| 158 | reason: "backend exited with code 1", | |
| 159 | delayMs: 500, | |
| 160 | cause, | |
| 161 | }); | |
| 162 | ||
| 163 | assert.strictEqual(error.cause, cause); | |
| 164 | assert.equal(error.reason, "backend exited with code 1"); | |
| 165 | assert.equal(error.delayMs, 500); | |
| 166 | assert.equal(error.message, "Desktop backend restart failed after a scheduled 500ms delay."); | |
| 118 | // Helper that constructs a primary backend instance using the factory | |
| 119 | // directly. The factory's deps (FileSystem, ChildProcessSpawner, | |
| 120 | // HttpClient, DesktopBackendOutputLogFactory) are provided per-test via | |
| 121 | // a scoped layer; tests yield the returned Effect inside `Effect.scoped` | |
| 122 | // to drive the instance's lifecycle. | |
| 123 | function makeTestInstance(input: MakeInstanceInput) { | |
| 124 | const stubLog: DesktopObservability.DesktopBackendOutputLogShape = { | |
| 125 | writeSessionBoundary: () => Effect.void, | |
| 126 | writeOutputChunk: () => Effect.void, | |
| 127 | ...input.backendOutputLog, | |
| 128 | }; | |
| 129 | const servicesLayer = Layer.mergeAll( | |
| 130 | FileSystem.layerNoop({ | |
| 131 | exists: () => Effect.succeed(true), | |
| 132 | }), | |
| 133 | input.spawnerLayer, | |
| 134 | input.httpClientLayer ?? healthyHttpClientLayer, | |
| 135 | Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { | |
| 136 | forInstance: () => Effect.succeed(stubLog), | |
| 137 | } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]), | |
| 138 | ); | |
| 139 | ||
| 140 | const instance = DesktopBackendManager.makeBackendInstance({ | |
| 141 | id: DesktopBackendManager.PRIMARY_INSTANCE_ID, | |
| 142 | label: Effect.succeed("Windows"), | |
| 143 | configResolve: input.configResolve ?? Effect.succeed(input.config ?? baseConfig), | |
| 144 | ...(input.onReady ? { onReady: () => input.onReady! } : {}), | |
| 145 | ...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}), | |
| 146 | ...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}), | |
| 167 | 147 | }); |
| 168 | 148 | |
| 149 | return instance.pipe(Effect.provide(servicesLayer)); | |
| 150 | } | |
| 151 | ||
| 152 | describe("DesktopBackendManager", () => { | |
| 169 | 153 | it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => |
| 170 | Effect.gen(function* () { | |
| 171 | let spawnedCommand: ChildProcess.Command | undefined; | |
| 172 | let bootstrapJson = ""; | |
| 173 | let readyCount = 0; | |
| 174 | const ready = yield* Deferred.make<void>(); | |
| 175 | const exited = yield* Queue.unbounded<void>(); | |
| 176 | ||
| 177 | const spawnerLayer = Layer.succeed( | |
| 178 | ChildProcessSpawner.ChildProcessSpawner, | |
| 179 | ChildProcessSpawner.make((command) => | |
| 180 | Effect.gen(function* () { | |
| 181 | spawnedCommand = command; | |
| 182 | if (command._tag === "StandardCommand") { | |
| 183 | const fd3 = command.options.additionalFds?.fd3; | |
| 184 | if (fd3?.type === "input" && fd3.stream) { | |
| 185 | bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); | |
| 154 | Effect.scoped( | |
| 155 | Effect.gen(function* () { | |
| 156 | let spawnedCommand: ChildProcess.Command | undefined; | |
| 157 | let bootstrapJson = ""; | |
| 158 | let readyCount = 0; | |
| 159 | const ready = yield* Deferred.make<void>(); | |
| 160 | const exited = yield* Queue.unbounded<void>(); | |
| 161 | ||
| 162 | const spawnerLayer = Layer.succeed( | |
| 163 | ChildProcessSpawner.ChildProcessSpawner, | |
| 164 | ChildProcessSpawner.make((command) => | |
| 165 | Effect.gen(function* () { | |
| 166 | spawnedCommand = command; | |
| 167 | if (command._tag === "StandardCommand") { | |
| 168 | const fd3 = command.options.additionalFds?.fd3; | |
| 169 | if (fd3?.type === "input" && fd3.stream) { | |
| 170 | bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); | |
| 171 | } | |
| 186 | 172 | } |
| 187 | } | |
| 188 | 173 | |
| 189 | return makeProcess({ | |
| 190 | exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 191 | }); | |
| 192 | }), | |
| 193 | ), | |
| 194 | ); | |
| 195 | ||
| 196 | const managerLayer = makeManagerLayer({ | |
| 197 | config: { | |
| 198 | ...baseConfig, | |
| 199 | bootstrap: configWithObservability, | |
| 200 | }, | |
| 201 | spawnerLayer, | |
| 202 | desktopWindow: { | |
| 203 | handleBackendReady: Effect.sync(() => { | |
| 174 | return makeProcess({ | |
| 175 | exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 176 | }); | |
| 177 | }), | |
| 178 | ), | |
| 179 | ); | |
| 180 | ||
| 181 | const instance = yield* makeTestInstance({ | |
| 182 | config: { | |
| 183 | ...baseConfig, | |
| 184 | bootstrap: configWithObservability, | |
| 185 | }, | |
| 186 | spawnerLayer, | |
| 187 | onReady: Effect.sync(() => { | |
| 204 | 188 | readyCount += 1; |
| 205 | }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), | |
| 206 | }, | |
| 207 | backendOutputLog: { | |
| 208 | writeSessionBoundary: ({ phase }) => | |
| 209 | phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, | |
| 210 | }, | |
| 211 | }); | |
| 212 | ||
| 213 | yield* Effect.gen(function* () { | |
| 214 | const manager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 215 | yield* manager.start; | |
| 189 | }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), | |
| 190 | backendOutputLog: { | |
| 191 | writeSessionBoundary: ({ phase }) => | |
| 192 | phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, | |
| 193 | }, | |
| 194 | }); | |
| 195 | ||
| 196 | yield* instance.start; | |
| 216 | 197 | yield* Queue.take(exited); |
| 217 | 198 | |
| 218 | 199 | assert.equal(readyCount, 1); |
| ... | ... | @@ -235,292 +216,52 @@ describe("DesktopBackendManager", () => { |
| 235 | 216 | ); |
| 236 | 217 | |
| 237 | 218 | assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); |
| 238 | }).pipe(Effect.provide(managerLayer)); | |
| 239 | }), | |
| 240 | ); | |
| 241 | ||
| 242 | it.effect("preserves the readiness timeout cause and process context", () => | |
| 243 | Effect.gen(function* () { | |
| 244 | const requested = yield* Deferred.make<HttpClientRequest.HttpClientRequest>(); | |
| 245 | const layer = Layer.merge( | |
| 246 | TestClock.layer(), | |
| 247 | httpClientLayer((request) => | |
| 248 | Deferred.succeed(requested, request).pipe(Effect.andThen(Effect.never)), | |
| 249 | ), | |
| 250 | ); | |
| 251 | ||
| 252 | yield* Effect.gen(function* () { | |
| 253 | const readiness = yield* DesktopBackendManager.waitForHttpReady({ | |
| 254 | executablePath: baseConfig.executablePath, | |
| 255 | entryPath: baseConfig.entryPath, | |
| 256 | cwd: baseConfig.cwd, | |
| 257 | httpBaseUrl: baseConfig.httpBaseUrl, | |
| 258 | timeout: Duration.millis(50), | |
| 259 | }).pipe(Effect.flip, Effect.forkChild); | |
| 260 | ||
| 261 | const request = yield* Deferred.await(requested); | |
| 262 | assert.equal(request.url, "http://127.0.0.1:3773/.well-known/t3/environment"); | |
| 263 | ||
| 264 | yield* TestClock.adjust(Duration.millis(50)); | |
| 265 | const error = yield* Fiber.join(readiness); | |
| 266 | ||
| 267 | assert.instanceOf(error, DesktopBackendManager.BackendReadinessTimeoutError); | |
| 268 | assert.equal(error.executablePath, "/electron"); | |
| 269 | assert.equal(error.entryPath, "/server/bin.mjs"); | |
| 270 | assert.equal(error.cwd, "/server"); | |
| 271 | assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); | |
| 272 | assert.equal(error.readinessUrl.href, "http://127.0.0.1:3773/.well-known/t3/environment"); | |
| 273 | assert.equal(error.timeoutMs, 50); | |
| 274 | assert.isTrue(Cause.isTimeoutError(error.cause)); | |
| 275 | assert.equal( | |
| 276 | error.message, | |
| 277 | "Timed out after 50ms waiting for desktop backend readiness at http://127.0.0.1:3773/.well-known/t3/environment.", | |
| 278 | ); | |
| 279 | }).pipe(Effect.provide(layer)); | |
| 280 | }), | |
| 281 | ); | |
| 282 | ||
| 283 | it.effect("reports bootstrap encoding failures with stable process context", () => | |
| 284 | Effect.gen(function* () { | |
| 285 | const spawnerLayer = Layer.succeed( | |
| 286 | ChildProcessSpawner.ChildProcessSpawner, | |
| 287 | ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), | |
| 288 | ); | |
| 289 | const error = yield* DesktopBackendManager.runBackendProcess({ | |
| 290 | ...baseConfig, | |
| 291 | bootstrap: { | |
| 292 | ...baseConfig.bootstrap, | |
| 293 | port: 0, | |
| 294 | }, | |
| 295 | }).pipe( | |
| 296 | Effect.flip, | |
| 297 | Effect.scoped, | |
| 298 | Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), | |
| 299 | ); | |
| 300 | ||
| 301 | if (error._tag !== "BackendProcessBootstrapEncodeError") { | |
| 302 | return assert.fail(`Expected bootstrap encode error, received ${error._tag}`); | |
| 303 | } | |
| 304 | assert.equal(error.executablePath, "/electron"); | |
| 305 | assert.equal(error.entryPath, "/server/bin.mjs"); | |
| 306 | assert.equal(error.cwd, "/server"); | |
| 307 | assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); | |
| 308 | assert.isDefined(error.cause); | |
| 309 | assert.equal( | |
| 310 | error.message, | |
| 311 | "Failed to encode the desktop backend bootstrap payload for /server/bin.mjs.", | |
| 312 | ); | |
| 313 | assert.isTrue(isBackendProcessError(error)); | |
| 314 | }), | |
| 315 | ); | |
| 316 | ||
| 317 | it.effect("preserves spawn failures without deriving their message from the cause", () => | |
| 318 | Effect.gen(function* () { | |
| 319 | const spawnCause = PlatformError.systemError({ | |
| 320 | _tag: "PermissionDenied", | |
| 321 | module: "ChildProcessSpawner", | |
| 322 | method: "spawn", | |
| 323 | pathOrDescriptor: baseConfig.executablePath, | |
| 324 | description: "low-level detail that must not become the public message", | |
| 325 | }); | |
| 326 | const spawnerLayer = Layer.succeed( | |
| 327 | ChildProcessSpawner.ChildProcessSpawner, | |
| 328 | ChildProcessSpawner.make(() => Effect.fail(spawnCause)), | |
| 329 | ); | |
| 330 | const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe( | |
| 331 | Effect.flip, | |
| 332 | Effect.scoped, | |
| 333 | Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), | |
| 334 | ); | |
| 335 | ||
| 336 | if (error._tag !== "BackendProcessSpawnError") { | |
| 337 | return assert.fail(`Expected backend spawn error, received ${error._tag}`); | |
| 338 | } | |
| 339 | assert.equal(error.executablePath, "/electron"); | |
| 340 | assert.equal(error.entryPath, "/server/bin.mjs"); | |
| 341 | assert.equal(error.cwd, "/server"); | |
| 342 | assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); | |
| 343 | assert.strictEqual(error.cause, spawnCause); | |
| 344 | assert.equal( | |
| 345 | error.message, | |
| 346 | "Failed to spawn desktop backend entry /server/bin.mjs with /electron.", | |
| 347 | ); | |
| 348 | assert.notInclude(error.message, spawnCause.message); | |
| 349 | assert.isTrue(isBackendProcessError(error)); | |
| 350 | }), | |
| 351 | ); | |
| 352 | ||
| 353 | it.effect("preserves exit-status failures without copying their detail into the message", () => | |
| 354 | Effect.gen(function* () { | |
| 355 | const exitCause = PlatformError.systemError({ | |
| 356 | _tag: "PermissionDenied", | |
| 357 | module: "ChildProcess", | |
| 358 | method: "exitCode", | |
| 359 | description: "exit-status-secret-sentinel", | |
| 360 | }); | |
| 361 | const spawnerLayer = Layer.succeed( | |
| 362 | ChildProcessSpawner.ChildProcessSpawner, | |
| 363 | ChildProcessSpawner.make(() => | |
| 364 | Effect.succeed( | |
| 365 | makeProcess({ | |
| 366 | exitCode: Effect.fail(exitCause), | |
| 367 | }), | |
| 368 | ), | |
| 369 | ), | |
| 370 | ); | |
| 371 | const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe( | |
| 372 | Effect.flip, | |
| 373 | Effect.scoped, | |
| 374 | Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), | |
| 375 | ); | |
| 376 | ||
| 377 | if (error._tag !== "BackendProcessExitStatusError") { | |
| 378 | return assert.fail(`Expected backend exit-status error, received ${error._tag}`); | |
| 379 | } | |
| 380 | assert.equal(error.pid, 123); | |
| 381 | assert.equal(error.executablePath, "/electron"); | |
| 382 | assert.equal(error.entryPath, "/server/bin.mjs"); | |
| 383 | assert.equal(error.cwd, "/server"); | |
| 384 | assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); | |
| 385 | assert.strictEqual(error.cause, exitCause); | |
| 386 | assert.equal(error.message, "Failed to read the exit status of desktop backend process 123."); | |
| 387 | assert.notInclude(error.message, "exit-status-secret-sentinel"); | |
| 388 | assert.isTrue(isBackendProcessError(error)); | |
| 389 | }), | |
| 390 | ); | |
| 391 | ||
| 392 | it.effect("reports output stream failures with process and stream context", () => | |
| 393 | Effect.gen(function* () { | |
| 394 | const outputCause = PlatformError.systemError({ | |
| 395 | _tag: "BadResource", | |
| 396 | module: "ChildProcess", | |
| 397 | method: "stdout", | |
| 398 | description: "output-stream-secret-sentinel", | |
| 399 | }); | |
| 400 | const reported = yield* Deferred.make<DesktopBackendManager.BackendProcessOutputError>(); | |
| 401 | const spawnerLayer = Layer.succeed( | |
| 402 | ChildProcessSpawner.ChildProcessSpawner, | |
| 403 | ChildProcessSpawner.make(() => | |
| 404 | Effect.succeed( | |
| 405 | makeProcess({ | |
| 406 | stdout: Stream.fail(outputCause), | |
| 407 | exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 408 | }), | |
| 409 | ), | |
| 410 | ), | |
| 411 | ); | |
| 412 | ||
| 413 | const exit = yield* DesktopBackendManager.runBackendProcess({ | |
| 414 | ...baseConfig, | |
| 415 | onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), | |
| 416 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); | |
| 417 | const error = yield* Deferred.await(reported); | |
| 418 | ||
| 419 | assert.equal(exit.code.pipe(Option.getOrUndefined), 0); | |
| 420 | if (error._tag !== "BackendProcessOutputReadError") { | |
| 421 | return assert.fail(`Expected output read error, received ${error._tag}`); | |
| 422 | } | |
| 423 | assert.equal(error.executablePath, "/electron"); | |
| 424 | assert.equal(error.entryPath, "/server/bin.mjs"); | |
| 425 | assert.equal(error.cwd, "/server"); | |
| 426 | assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); | |
| 427 | assert.equal(error.pid, 123); | |
| 428 | assert.equal(error.streamName, "stdout"); | |
| 429 | assert.strictEqual(error.cause, outputCause); | |
| 430 | assert.equal(error.message, "Failed to read stdout from desktop backend process 123."); | |
| 431 | assert.notInclude(error.message, "output-stream-secret-sentinel"); | |
| 432 | }), | |
| 219 | }), | |
| 220 | ), | |
| 433 | 221 | ); |
| 434 | 222 | |
| 435 | it.effect("reports output handler failures separately from stream read failures", () => | |
| 436 | Effect.gen(function* () { | |
| 437 | const chunk = new TextEncoder().encode("backend output"); | |
| 438 | const outputCause = new Error("output-handler-secret-sentinel"); | |
| 439 | const reported = yield* Deferred.make<DesktopBackendManager.BackendProcessOutputError>(); | |
| 440 | const spawnerLayer = Layer.succeed( | |
| 441 | ChildProcessSpawner.ChildProcessSpawner, | |
| 442 | ChildProcessSpawner.make(() => | |
| 443 | Effect.succeed( | |
| 444 | makeProcess({ | |
| 445 | stdout: Stream.make(chunk), | |
| 446 | exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 447 | }), | |
| 223 | it.effect("retries HTTP readiness before reporting the backend ready", () => | |
| 224 | Effect.scoped( | |
| 225 | Effect.gen(function* () { | |
| 226 | const requestUrls: Array<string> = []; | |
| 227 | const statuses = [503, 200]; | |
| 228 | let readyCount = 0; | |
| 229 | const firstRequest = yield* Deferred.make<void>(); | |
| 230 | const ready = yield* Deferred.make<void>(); | |
| 231 | const exited = yield* Queue.unbounded<void>(); | |
| 232 | ||
| 233 | const spawnerLayer = Layer.succeed( | |
| 234 | ChildProcessSpawner.ChildProcessSpawner, | |
| 235 | ChildProcessSpawner.make(() => | |
| 236 | Effect.succeed( | |
| 237 | makeProcess({ | |
| 238 | exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 239 | }), | |
| 240 | ), | |
| 448 | 241 | ), |
| 449 | ), | |
| 450 | ); | |
| 451 | ||
| 452 | const exit = yield* DesktopBackendManager.runBackendProcess({ | |
| 453 | ...baseConfig, | |
| 454 | onOutput: () => Effect.fail(outputCause), | |
| 455 | onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), | |
| 456 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); | |
| 457 | const error = yield* Deferred.await(reported); | |
| 458 | ||
| 459 | assert.equal(exit.code.pipe(Option.getOrUndefined), 0); | |
| 460 | if (error._tag !== "BackendProcessOutputHandlingError") { | |
| 461 | return assert.fail(`Expected output handling error, received ${error._tag}`); | |
| 462 | } | |
| 463 | assert.equal(error.executablePath, "/electron"); | |
| 464 | assert.equal(error.entryPath, "/server/bin.mjs"); | |
| 465 | assert.equal(error.cwd, "/server"); | |
| 466 | assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); | |
| 467 | assert.equal(error.pid, 123); | |
| 468 | assert.equal(error.streamName, "stdout"); | |
| 469 | assert.equal(error.chunkByteLength, chunk.byteLength); | |
| 470 | assert.strictEqual(error.cause, outputCause); | |
| 471 | assert.equal( | |
| 472 | error.message, | |
| 473 | `Failed to handle ${chunk.byteLength} bytes from stdout of desktop backend process 123.`, | |
| 474 | ); | |
| 475 | assert.notInclude(error.message, "output-handler-secret-sentinel"); | |
| 476 | }), | |
| 477 | ); | |
| 242 | ); | |
| 478 | 243 | |
| 479 | it.effect("retries HTTP readiness before reporting the backend ready", () => | |
| 480 | Effect.gen(function* () { | |
| 481 | const requestUrls: Array<string> = []; | |
| 482 | const statuses = [503, 200]; | |
| 483 | let readyCount = 0; | |
| 484 | const firstRequest = yield* Deferred.make<void>(); | |
| 485 | const ready = yield* Deferred.make<void>(); | |
| 486 | const exited = yield* Queue.unbounded<void>(); | |
| 487 | ||
| 488 | const spawnerLayer = Layer.succeed( | |
| 489 | ChildProcessSpawner.ChildProcessSpawner, | |
| 490 | ChildProcessSpawner.make(() => | |
| 491 | Effect.succeed( | |
| 492 | makeProcess({ | |
| 493 | exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 244 | const instance = yield* makeTestInstance({ | |
| 245 | spawnerLayer, | |
| 246 | httpClientLayer: httpClientLayer((request) => | |
| 247 | Effect.gen(function* () { | |
| 248 | const status = statuses.shift(); | |
| 249 | assert.isDefined(status); | |
| 250 | requestUrls.push(request.url); | |
| 251 | yield* Deferred.succeed(firstRequest, void 0); | |
| 252 | return responseForRequest(request, status); | |
| 494 | 253 | }), |
| 495 | 254 | ), |
| 496 | ), | |
| 497 | ); | |
| 498 | ||
| 499 | const managerLayer = makeManagerLayer({ | |
| 500 | spawnerLayer, | |
| 501 | httpClientLayer: httpClientLayer((request) => | |
| 502 | Effect.gen(function* () { | |
| 503 | const status = statuses.shift(); | |
| 504 | assert.isDefined(status); | |
| 505 | requestUrls.push(request.url); | |
| 506 | yield* Deferred.succeed(firstRequest, void 0); | |
| 507 | return responseForRequest(request, status); | |
| 508 | }), | |
| 509 | ), | |
| 510 | desktopWindow: { | |
| 511 | handleBackendReady: Effect.sync(() => { | |
| 255 | onReady: Effect.sync(() => { | |
| 512 | 256 | readyCount += 1; |
| 513 | }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), | |
| 514 | }, | |
| 515 | backendOutputLog: { | |
| 516 | writeSessionBoundary: ({ phase }) => | |
| 517 | phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, | |
| 518 | }, | |
| 519 | }); | |
| 520 | ||
| 521 | yield* Effect.gen(function* () { | |
| 522 | const manager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 523 | yield* manager.start; | |
| 257 | }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), | |
| 258 | backendOutputLog: { | |
| 259 | writeSessionBoundary: ({ phase }) => | |
| 260 | phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, | |
| 261 | }, | |
| 262 | }); | |
| 263 | ||
| 264 | yield* instance.start; | |
| 524 | 265 | yield* Deferred.await(firstRequest); |
| 525 | 266 | |
| 526 | 267 | assert.equal(readyCount, 0); |
| ... | ... | @@ -534,106 +275,136 @@ describe("DesktopBackendManager", () => { |
| 534 | 275 | "http://127.0.0.1:3773/.well-known/t3/environment", |
| 535 | 276 | "http://127.0.0.1:3773/.well-known/t3/environment", |
| 536 | 277 | ]); |
| 537 | }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); | |
| 538 | }), | |
| 278 | }).pipe(Effect.provide(TestClock.layer())), | |
| 279 | ), | |
| 539 | 280 | ); |
| 540 | 281 | |
| 541 | 282 | it.effect("starts the configured backend and closes the scoped process on stop", () => |
| 542 | Effect.gen(function* () { | |
| 543 | let startCount = 0; | |
| 544 | let closedCount = 0; | |
| 545 | const closed = yield* Deferred.make<void>(); | |
| 546 | const startedPids = yield* Queue.unbounded<number>(); | |
| 547 | const ready = yield* Deferred.make<void>(); | |
| 548 | const backendReady = yield* Ref.make(false); | |
| 549 | const quitting = yield* Ref.make(false); | |
| 550 | ||
| 551 | const spawnerLayer = Layer.succeed( | |
| 552 | ChildProcessSpawner.ChildProcessSpawner, | |
| 553 | ChildProcessSpawner.make(() => | |
| 554 | Effect.gen(function* () { | |
| 555 | const scope = yield* Scope.Scope; | |
| 556 | startCount += 1; | |
| 557 | yield* Queue.offer(startedPids, 123); | |
| 558 | const close = Effect.sync(() => { | |
| 559 | closedCount += 1; | |
| 560 | }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid); | |
| 561 | ||
| 562 | yield* Scope.addFinalizer(scope, close); | |
| 563 | ||
| 564 | return makeProcess({ | |
| 565 | exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 566 | kill: () => close, | |
| 567 | }); | |
| 568 | }), | |
| 569 | ), | |
| 570 | ); | |
| 571 | ||
| 572 | const managerLayer = makeManagerLayer({ | |
| 573 | spawnerLayer, | |
| 574 | desktopState: { | |
| 575 | backendReady, | |
| 576 | quitting, | |
| 577 | }, | |
| 578 | desktopWindow: { | |
| 579 | handleBackendReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid), | |
| 580 | }, | |
| 581 | }); | |
| 582 | ||
| 583 | yield* Effect.gen(function* () { | |
| 584 | const manager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 585 | assert.isTrue(Option.isNone(yield* manager.currentConfig)); | |
| 586 | ||
| 587 | yield* manager.start; | |
| 283 | Effect.scoped( | |
| 284 | Effect.gen(function* () { | |
| 285 | let startCount = 0; | |
| 286 | let closedCount = 0; | |
| 287 | const closed = yield* Deferred.make<void>(); | |
| 288 | const startedPids = yield* Queue.unbounded<number>(); | |
| 289 | const ready = yield* Deferred.make<void>(); | |
| 290 | const backendReadyFlag = yield* Ref.make(false); | |
| 291 | ||
| 292 | const spawnerLayer = Layer.succeed( | |
| 293 | ChildProcessSpawner.ChildProcessSpawner, | |
| 294 | ChildProcessSpawner.make(() => | |
| 295 | Effect.gen(function* () { | |
| 296 | const scope = yield* Scope.Scope; | |
| 297 | startCount += 1; | |
| 298 | yield* Queue.offer(startedPids, 123); | |
| 299 | const close = Effect.sync(() => { | |
| 300 | closedCount += 1; | |
| 301 | }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid); | |
| 302 | ||
| 303 | yield* Scope.addFinalizer(scope, close); | |
| 304 | ||
| 305 | return makeProcess({ | |
| 306 | exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 307 | kill: () => close, | |
| 308 | }); | |
| 309 | }), | |
| 310 | ), | |
| 311 | ); | |
| 312 | ||
| 313 | const instance = yield* makeTestInstance({ | |
| 314 | spawnerLayer, | |
| 315 | onReady: Ref.set(backendReadyFlag, true).pipe( | |
| 316 | Effect.andThen(Deferred.succeed(ready, void 0)), | |
| 317 | Effect.asVoid, | |
| 318 | ), | |
| 319 | onShutdown: Ref.set(backendReadyFlag, false), | |
| 320 | }); | |
| 321 | assert.isTrue(Option.isNone(yield* instance.currentConfig)); | |
| 322 | ||
| 323 | yield* instance.start; | |
| 588 | 324 | assert.equal(yield* Queue.take(startedPids), 123); |
| 589 | 325 | yield* Deferred.await(ready); |
| 590 | assert.isTrue(yield* Ref.get(backendReady)); | |
| 591 | assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig)); | |
| 326 | assert.isTrue(yield* Ref.get(backendReadyFlag)); | |
| 327 | assert.deepEqual(yield* instance.currentConfig, Option.some(baseConfig)); | |
| 592 | 328 | |
| 593 | const runningSnapshot = yield* manager.snapshot; | |
| 329 | const runningSnapshot = yield* instance.snapshot; | |
| 594 | 330 | assert.equal(runningSnapshot.ready, true); |
| 595 | 331 | assert.deepEqual(runningSnapshot.activePid, Option.some(123)); |
| 596 | 332 | |
| 597 | yield* manager.stop(); | |
| 333 | yield* instance.stop(); | |
| 598 | 334 | assert.equal(startCount, 1); |
| 599 | 335 | assert.equal(closedCount, 1); |
| 600 | 336 | |
| 601 | const stoppedSnapshot = yield* manager.snapshot; | |
| 602 | assert.isFalse(yield* Ref.get(backendReady)); | |
| 337 | const stoppedSnapshot = yield* instance.snapshot; | |
| 338 | assert.isFalse(yield* Ref.get(backendReadyFlag)); | |
| 603 | 339 | assert.equal(stoppedSnapshot.desiredRunning, false); |
| 604 | 340 | assert.equal(stoppedSnapshot.ready, false); |
| 605 | 341 | assert.equal(Option.isNone(stoppedSnapshot.activePid), true); |
| 606 | }).pipe(Effect.provide(managerLayer)); | |
| 607 | }), | |
| 342 | }), | |
| 343 | ), | |
| 608 | 344 | ); |
| 609 | 345 | |
| 610 | it.effect("restarts an unexpectedly exited backend with the Effect clock", () => | |
| 611 | Effect.gen(function* () { | |
| 612 | const starts = yield* Queue.unbounded<number>(); | |
| 613 | let startCount = 0; | |
| 614 | ||
| 615 | const spawnerLayer = Layer.succeed( | |
| 616 | ChildProcessSpawner.ChildProcessSpawner, | |
| 617 | ChildProcessSpawner.make(() => | |
| 618 | Effect.sync(() => { | |
| 619 | startCount += 1; | |
| 620 | return makeProcess({ | |
| 621 | exitCode: Queue.offer(starts, startCount).pipe( | |
| 622 | Effect.as(ChildProcessSpawner.ExitCode(1)), | |
| 623 | ), | |
| 624 | }); | |
| 346 | it.effect("does not notify shutdown before the first start has prior state", () => | |
| 347 | Effect.scoped( | |
| 348 | Effect.gen(function* () { | |
| 349 | let shutdownCount = 0; | |
| 350 | const closed = yield* Deferred.make<void>(); | |
| 351 | const startedPids = yield* Queue.unbounded<number>(); | |
| 352 | ||
| 353 | const spawnerLayer = Layer.succeed( | |
| 354 | ChildProcessSpawner.ChildProcessSpawner, | |
| 355 | ChildProcessSpawner.make(() => | |
| 356 | Effect.gen(function* () { | |
| 357 | yield* Queue.offer(startedPids, 123); | |
| 358 | const close = Deferred.succeed(closed, void 0).pipe(Effect.asVoid); | |
| 359 | return makeProcess({ | |
| 360 | exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | |
| 361 | kill: () => close, | |
| 362 | }); | |
| 363 | }), | |
| 364 | ), | |
| 365 | ); | |
| 366 | ||
| 367 | const instance = yield* makeTestInstance({ | |
| 368 | spawnerLayer, | |
| 369 | httpClientLayer: httpClientLayer(() => Effect.never), | |
| 370 | onShutdown: Effect.sync(() => { | |
| 371 | shutdownCount += 1; | |
| 625 | 372 | }), |
| 626 | ), | |
| 627 | ); | |
| 373 | }); | |
| 628 | 374 | |
| 629 | const managerLayer = makeManagerLayer({ | |
| 630 | spawnerLayer, | |
| 631 | httpClientLayer: httpClientLayer(() => Effect.never), | |
| 632 | }); | |
| 375 | yield* instance.start; | |
| 376 | assert.equal(yield* Queue.take(startedPids), 123); | |
| 377 | assert.equal(shutdownCount, 0); | |
| 378 | }), | |
| 379 | ), | |
| 380 | ); | |
| 633 | 381 | |
| 634 | yield* Effect.gen(function* () { | |
| 635 | const manager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 636 | yield* manager.start; | |
| 382 | it.effect("restarts an unexpectedly exited backend with the Effect clock", () => | |
| 383 | Effect.scoped( | |
| 384 | Effect.gen(function* () { | |
| 385 | const starts = yield* Queue.unbounded<number>(); | |
| 386 | let startCount = 0; | |
| 387 | ||
| 388 | const spawnerLayer = Layer.succeed( | |
| 389 | ChildProcessSpawner.ChildProcessSpawner, | |
| 390 | ChildProcessSpawner.make(() => | |
| 391 | Effect.sync(() => { | |
| 392 | startCount += 1; | |
| 393 | return makeProcess({ | |
| 394 | exitCode: Queue.offer(starts, startCount).pipe( | |
| 395 | Effect.as(ChildProcessSpawner.ExitCode(1)), | |
| 396 | ), | |
| 397 | }); | |
| 398 | }), | |
| 399 | ), | |
| 400 | ); | |
| 401 | ||
| 402 | const instance = yield* makeTestInstance({ | |
| 403 | spawnerLayer, | |
| 404 | httpClientLayer: httpClientLayer(() => Effect.never), | |
| 405 | }); | |
| 406 | ||
| 407 | yield* instance.start; | |
| 637 | 408 | |
| 638 | 409 | assert.equal(yield* Queue.take(starts), 1); |
| 639 | 410 | |
| ... | ... | @@ -646,107 +417,300 @@ describe("DesktopBackendManager", () => { |
| 646 | 417 | assert.equal(yield* Queue.size(starts), 0); |
| 647 | 418 | yield* TestClock.adjust(Duration.millis(1)); |
| 648 | 419 | assert.equal(yield* Queue.take(starts), 3); |
| 649 | }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); | |
| 650 | }), | |
| 420 | }).pipe(Effect.provide(TestClock.layer())), | |
| 421 | ), | |
| 422 | ); | |
| 423 | ||
| 424 | it.effect("does not notify shutdown when a scheduled restart starts from non-ready state", () => | |
| 425 | Effect.scoped( | |
| 426 | Effect.gen(function* () { | |
| 427 | let shutdownCount = 0; | |
| 428 | ||
| 429 | const spawnerLayer = Layer.succeed( | |
| 430 | ChildProcessSpawner.ChildProcessSpawner, | |
| 431 | ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), | |
| 432 | ); | |
| 433 | ||
| 434 | const instance = yield* makeTestInstance({ | |
| 435 | spawnerLayer, | |
| 436 | config: { | |
| 437 | ...baseConfig, | |
| 438 | preflightFailure: Option.some({ reason: "preflight failed", fatal: false }), | |
| 439 | }, | |
| 440 | onShutdown: Effect.sync(() => { | |
| 441 | shutdownCount += 1; | |
| 442 | }), | |
| 443 | }); | |
| 444 | ||
| 445 | yield* instance.start; | |
| 446 | assert.equal(shutdownCount, 0); | |
| 447 | ||
| 448 | yield* TestClock.adjust(Duration.millis(500)); | |
| 449 | assert.equal(shutdownCount, 0); | |
| 450 | }).pipe(Effect.provide(TestClock.layer())), | |
| 451 | ), | |
| 452 | ); | |
| 453 | ||
| 454 | it.effect("surfaces a fatal preflight failure once and stops looping after the cap", () => | |
| 455 | Effect.scoped( | |
| 456 | Effect.gen(function* () { | |
| 457 | const failures: string[] = []; | |
| 458 | const spawnerLayer = Layer.succeed( | |
| 459 | ChildProcessSpawner.ChildProcessSpawner, | |
| 460 | ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), | |
| 461 | ); | |
| 462 | ||
| 463 | const instance = yield* makeTestInstance({ | |
| 464 | spawnerLayer, | |
| 465 | config: { | |
| 466 | ...baseConfig, | |
| 467 | preflightFailure: Option.some({ reason: "Node.js not found", fatal: true }), | |
| 468 | }, | |
| 469 | onPreflightFailed: (failure) => | |
| 470 | Effect.sync(() => { | |
| 471 | failures.push(failure.reason); | |
| 472 | }).pipe(Effect.as(false)), | |
| 473 | }); | |
| 474 | ||
| 475 | yield* instance.start; | |
| 476 | assert.deepEqual(failures, []); | |
| 477 | ||
| 478 | // Five fatal attempts with exponential backoff (500ms, 1s, 2s, 4s) reach | |
| 479 | // the cap, at which point the failure is surfaced exactly once. | |
| 480 | yield* TestClock.adjust(Duration.millis(500)); | |
| 481 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 482 | yield* TestClock.adjust(Duration.seconds(2)); | |
| 483 | yield* TestClock.adjust(Duration.seconds(4)); | |
| 484 | assert.deepEqual(failures, ["Node.js not found"]); | |
| 485 | ||
| 486 | // Past the cap the loop stops and nothing else is surfaced. | |
| 487 | yield* TestClock.adjust(Duration.seconds(8)); | |
| 488 | yield* TestClock.adjust(Duration.seconds(30)); | |
| 489 | assert.deepEqual(failures, ["Node.js not found"]); | |
| 490 | }).pipe(Effect.provide(TestClock.layer())), | |
| 491 | ), | |
| 492 | ); | |
| 493 | ||
| 494 | it.effect("can be started again after a fatal preflight cap once config recovers", () => | |
| 495 | Effect.scoped( | |
| 496 | Effect.gen(function* () { | |
| 497 | const failing = yield* Ref.make(true); | |
| 498 | const starts = yield* Queue.unbounded<number>(); | |
| 499 | const spawnerLayer = Layer.succeed( | |
| 500 | ChildProcessSpawner.ChildProcessSpawner, | |
| 501 | ChildProcessSpawner.make(() => | |
| 502 | Queue.offer(starts, 123).pipe( | |
| 503 | Effect.as( | |
| 504 | makeProcess({ | |
| 505 | exitCode: Effect.never, | |
| 506 | }), | |
| 507 | ), | |
| 508 | ), | |
| 509 | ), | |
| 510 | ); | |
| 511 | ||
| 512 | const instance = yield* makeTestInstance({ | |
| 513 | spawnerLayer, | |
| 514 | configResolve: Ref.get(failing).pipe( | |
| 515 | Effect.map((isFailing) => | |
| 516 | isFailing | |
| 517 | ? { | |
| 518 | ...baseConfig, | |
| 519 | preflightFailure: Option.some({ | |
| 520 | reason: "Node.js not found", | |
| 521 | fatal: true, | |
| 522 | }), | |
| 523 | } | |
| 524 | : baseConfig, | |
| 525 | ), | |
| 526 | ), | |
| 527 | }); | |
| 528 | ||
| 529 | yield* instance.start; | |
| 530 | yield* TestClock.adjust(Duration.millis(500)); | |
| 531 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 532 | yield* TestClock.adjust(Duration.seconds(2)); | |
| 533 | yield* TestClock.adjust(Duration.seconds(4)); | |
| 534 | yield* TestClock.adjust(Duration.seconds(8)); | |
| 535 | ||
| 536 | const parked = yield* instance.snapshot; | |
| 537 | assert.equal(parked.desiredRunning, false); | |
| 538 | assert.equal(parked.ready, false); | |
| 539 | assert.isTrue(Option.isNone(parked.activePid)); | |
| 540 | assert.equal(parked.restartScheduled, false); | |
| 541 | assert.equal(yield* Queue.size(starts), 0); | |
| 542 | ||
| 543 | yield* Ref.set(failing, false); | |
| 544 | yield* instance.start; | |
| 545 | ||
| 546 | assert.equal(yield* Queue.take(starts), 123); | |
| 547 | const running = yield* instance.snapshot; | |
| 548 | assert.equal(running.desiredRunning, true); | |
| 549 | assert.deepEqual(running.activePid, Option.some(123)); | |
| 550 | }).pipe(Effect.provide(TestClock.layer())), | |
| 551 | ), | |
| 552 | ); | |
| 553 | ||
| 554 | it.effect("keeps retrying a transient (non-fatal) preflight failure without surfacing", () => | |
| 555 | Effect.scoped( | |
| 556 | Effect.gen(function* () { | |
| 557 | const failures: string[] = []; | |
| 558 | const spawnerLayer = Layer.succeed( | |
| 559 | ChildProcessSpawner.ChildProcessSpawner, | |
| 560 | ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), | |
| 561 | ); | |
| 562 | ||
| 563 | const instance = yield* makeTestInstance({ | |
| 564 | spawnerLayer, | |
| 565 | config: { | |
| 566 | ...baseConfig, | |
| 567 | preflightFailure: Option.some({ reason: "wslpath conversion failed", fatal: false }), | |
| 568 | }, | |
| 569 | onPreflightFailed: (failure) => | |
| 570 | Effect.sync(() => { | |
| 571 | failures.push(failure.reason); | |
| 572 | }).pipe(Effect.as(false)), | |
| 573 | }); | |
| 574 | ||
| 575 | yield* instance.start; | |
| 576 | // Well beyond the fatal cap's worth of time: a transient failure must | |
| 577 | // keep retrying (self-heal) and never surface. | |
| 578 | yield* TestClock.adjust(Duration.minutes(2)); | |
| 579 | assert.deepEqual(failures, []); | |
| 580 | }).pipe(Effect.provide(TestClock.layer())), | |
| 581 | ), | |
| 582 | ); | |
| 583 | ||
| 584 | it.effect("surfaces a bounded transient preflight failure after its retry limit", () => | |
| 585 | Effect.scoped( | |
| 586 | Effect.gen(function* () { | |
| 587 | const failures: string[] = []; | |
| 588 | const spawnerLayer = Layer.succeed( | |
| 589 | ChildProcessSpawner.ChildProcessSpawner, | |
| 590 | ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), | |
| 591 | ); | |
| 592 | ||
| 593 | const instance = yield* makeTestInstance({ | |
| 594 | spawnerLayer, | |
| 595 | config: { | |
| 596 | ...baseConfig, | |
| 597 | preflightFailure: Option.some({ | |
| 598 | reason: "WSL toolchain probe timed out", | |
| 599 | fatal: false, | |
| 600 | retryLimit: 3, | |
| 601 | }), | |
| 602 | }, | |
| 603 | onPreflightFailed: (failure) => | |
| 604 | Effect.sync(() => { | |
| 605 | failures.push(failure.reason); | |
| 606 | }).pipe(Effect.as(false)), | |
| 607 | }); | |
| 608 | ||
| 609 | yield* instance.start; | |
| 610 | yield* TestClock.adjust(Duration.millis(500)); | |
| 611 | assert.deepEqual(failures, []); | |
| 612 | ||
| 613 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 614 | assert.deepEqual(failures, ["WSL toolchain probe timed out"]); | |
| 615 | }).pipe(Effect.provide(TestClock.layer())), | |
| 616 | ), | |
| 651 | 617 | ); |
| 652 | 618 | |
| 653 | 619 | it.effect("cancels a scheduled restart when start is requested manually", () => |
| 654 | Effect.gen(function* () { | |
| 655 | const starts = yield* Queue.unbounded<number>(); | |
| 656 | const secondClosed = yield* Deferred.make<void>(); | |
| 657 | let startCount = 0; | |
| 658 | ||
| 659 | const spawnerLayer = Layer.succeed( | |
| 660 | ChildProcessSpawner.ChildProcessSpawner, | |
| 661 | ChildProcessSpawner.make(() => | |
| 662 | Effect.gen(function* () { | |
| 663 | startCount += 1; | |
| 664 | yield* Queue.offer(starts, startCount); | |
| 665 | ||
| 666 | if (startCount === 1) { | |
| 620 | Effect.scoped( | |
| 621 | Effect.gen(function* () { | |
| 622 | const starts = yield* Queue.unbounded<number>(); | |
| 623 | const secondClosed = yield* Deferred.make<void>(); | |
| 624 | let startCount = 0; | |
| 625 | ||
| 626 | const spawnerLayer = Layer.succeed( | |
| 627 | ChildProcessSpawner.ChildProcessSpawner, | |
| 628 | ChildProcessSpawner.make(() => | |
| 629 | Effect.gen(function* () { | |
| 630 | startCount += 1; | |
| 631 | yield* Queue.offer(starts, startCount); | |
| 632 | ||
| 633 | if (startCount === 1) { | |
| 634 | return makeProcess({ | |
| 635 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), | |
| 636 | }); | |
| 637 | } | |
| 638 | ||
| 639 | const scope = yield* Scope.Scope; | |
| 640 | const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid); | |
| 641 | yield* Scope.addFinalizer(scope, close); | |
| 667 | 642 | return makeProcess({ |
| 668 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), | |
| 643 | exitCode: Deferred.await(secondClosed).pipe( | |
| 644 | Effect.as(ChildProcessSpawner.ExitCode(0)), | |
| 645 | ), | |
| 646 | kill: () => close, | |
| 669 | 647 | }); |
| 670 | } | |
| 671 | ||
| 672 | const scope = yield* Scope.Scope; | |
| 673 | const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid); | |
| 674 | yield* Scope.addFinalizer(scope, close); | |
| 675 | return makeProcess({ | |
| 676 | exitCode: Deferred.await(secondClosed).pipe( | |
| 677 | Effect.as(ChildProcessSpawner.ExitCode(0)), | |
| 678 | ), | |
| 679 | kill: () => close, | |
| 680 | }); | |
| 681 | }), | |
| 682 | ), | |
| 683 | ); | |
| 648 | }), | |
| 649 | ), | |
| 650 | ); | |
| 684 | 651 | |
| 685 | const managerLayer = makeManagerLayer({ | |
| 686 | spawnerLayer, | |
| 687 | httpClientLayer: httpClientLayer(() => Effect.never), | |
| 688 | }); | |
| 652 | const instance = yield* makeTestInstance({ | |
| 653 | spawnerLayer, | |
| 654 | httpClientLayer: httpClientLayer(() => Effect.never), | |
| 655 | }); | |
| 689 | 656 | |
| 690 | yield* Effect.gen(function* () { | |
| 691 | const manager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 692 | yield* manager.start; | |
| 657 | yield* instance.start; | |
| 693 | 658 | |
| 694 | 659 | assert.equal(yield* Queue.take(starts), 1); |
| 695 | 660 | |
| 696 | yield* manager.start; | |
| 661 | yield* instance.start; | |
| 697 | 662 | assert.equal(yield* Queue.take(starts), 2); |
| 698 | 663 | |
| 699 | yield* manager.stop(); | |
| 664 | yield* instance.stop(); | |
| 700 | 665 | yield* TestClock.adjust(Duration.millis(500)); |
| 701 | 666 | |
| 702 | 667 | assert.equal(yield* Queue.size(starts), 0); |
| 703 | }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); | |
| 704 | }), | |
| 668 | }).pipe(Effect.provide(TestClock.layer())), | |
| 669 | ), | |
| 705 | 670 | ); |
| 706 | 671 | |
| 707 | 672 | it.effect("does not restart after stop cancels a scheduled restart", () => |
| 708 | Effect.gen(function* () { | |
| 709 | const starts = yield* Queue.unbounded<number>(); | |
| 710 | let startCount = 0; | |
| 711 | ||
| 712 | const spawnerLayer = Layer.succeed( | |
| 713 | ChildProcessSpawner.ChildProcessSpawner, | |
| 714 | ChildProcessSpawner.make(() => | |
| 715 | Effect.sync(() => { | |
| 716 | startCount += 1; | |
| 717 | return makeProcess({ | |
| 718 | exitCode: Queue.offer(starts, startCount).pipe( | |
| 719 | Effect.as(ChildProcessSpawner.ExitCode(1)), | |
| 720 | ), | |
| 721 | }); | |
| 722 | }), | |
| 723 | ), | |
| 724 | ); | |
| 673 | Effect.scoped( | |
| 674 | Effect.gen(function* () { | |
| 675 | const starts = yield* Queue.unbounded<number>(); | |
| 676 | let startCount = 0; | |
| 677 | ||
| 678 | const spawnerLayer = Layer.succeed( | |
| 679 | ChildProcessSpawner.ChildProcessSpawner, | |
| 680 | ChildProcessSpawner.make(() => | |
| 681 | Effect.sync(() => { | |
| 682 | startCount += 1; | |
| 683 | return makeProcess({ | |
| 684 | exitCode: Queue.offer(starts, startCount).pipe( | |
| 685 | Effect.as(ChildProcessSpawner.ExitCode(1)), | |
| 686 | ), | |
| 687 | }); | |
| 688 | }), | |
| 689 | ), | |
| 690 | ); | |
| 725 | 691 | |
| 726 | const managerLayer = makeManagerLayer({ | |
| 727 | spawnerLayer, | |
| 728 | httpClientLayer: httpClientLayer(() => Effect.never), | |
| 729 | }); | |
| 692 | const instance = yield* makeTestInstance({ | |
| 693 | spawnerLayer, | |
| 694 | httpClientLayer: httpClientLayer(() => Effect.never), | |
| 695 | }); | |
| 730 | 696 | |
| 731 | yield* Effect.gen(function* () { | |
| 732 | const manager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 733 | yield* manager.start; | |
| 697 | yield* instance.start; | |
| 734 | 698 | assert.equal(yield* Queue.take(starts), 1); |
| 735 | 699 | |
| 736 | 700 | let restartScheduled = false; |
| 737 | 701 | while (!restartScheduled) { |
| 738 | restartScheduled = (yield* manager.snapshot).restartScheduled; | |
| 702 | restartScheduled = (yield* instance.snapshot).restartScheduled; | |
| 739 | 703 | if (!restartScheduled) { |
| 740 | 704 | yield* Effect.yieldNow; |
| 741 | 705 | } |
| 742 | 706 | } |
| 743 | 707 | |
| 744 | yield* manager.stop(); | |
| 708 | yield* instance.stop(); | |
| 745 | 709 | yield* TestClock.adjust(Duration.millis(500)); |
| 746 | 710 | |
| 747 | 711 | assert.equal(yield* Queue.size(starts), 0); |
| 748 | assert.equal((yield* manager.snapshot).desiredRunning, false); | |
| 749 | }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); | |
| 750 | }), | |
| 712 | assert.equal((yield* instance.snapshot).desiredRunning, false); | |
| 713 | }).pipe(Effect.provide(TestClock.layer())), | |
| 714 | ), | |
| 751 | 715 | ); |
| 752 | 716 | }); |
apps/desktop/src/backend/DesktopBackendManager.ts+353-312| ... | ... | @@ -1,35 +1,63 @@ |
| 1 | // Per-instance backend factory. Replaces the legacy singleton | |
| 2 | // `DesktopBackendManager` Context.Service: each call to | |
| 3 | // `makeBackendInstance(spec)` constructs an isolated backend lifecycle — | |
| 4 | // its own state Ref, mutex, restart loop, and active child process. The | |
| 5 | // returned `DesktopBackendInstance` exposes start/stop/snapshot/wait | |
| 6 | // methods that operate on that single backend. | |
| 7 | // | |
| 8 | // The pool layer (`DesktopBackendPool.ts`) calls this factory once per | |
| 9 | // backend it wants to run. Today that's the Windows primary; follow-up | |
| 10 | // commits add a second call for the WSL instance. | |
| 11 | // | |
| 12 | // Singleton couplings that the legacy service held inline are now | |
| 13 | // parameterized via the spec: | |
| 14 | // - configResolve replaces the legacy `DesktopBackendConfiguration.resolve` | |
| 15 | // so each instance can resolve its own start config — the primary wires | |
| 16 | // `configuration.resolvePrimary`, the WSL orchestrator wires a | |
| 17 | // `configuration.resolveWsl({ port, distro })` closure. | |
| 18 | // - onReady / onShutdown drive UI side effects (window auto-open, | |
| 19 | // readiness latch) only for instances that want them — the primary's | |
| 20 | // spec passes the window's handleBackendReady/handleBackendNotReady, | |
| 21 | // other pool instances pass nothing. | |
| 22 | // - log writes go through a per-instance writer that the factory | |
| 23 | // pulls from `DesktopBackendOutputLogFactory.forInstance(spec.id)`, | |
| 24 | // so each instance lands in its own rotating file. | |
| 25 | ||
| 26 | import * as Brand from "effect/Brand"; | |
| 1 | 27 | import * as Cause from "effect/Cause"; |
| 2 | import * as Context from "effect/Context"; | |
| 3 | 28 | import * as Duration from "effect/Duration"; |
| 4 | 29 | import * as Effect from "effect/Effect"; |
| 5 | 30 | import * as Exit from "effect/Exit"; |
| 6 | 31 | import * as Fiber from "effect/Fiber"; |
| 7 | 32 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; | |
| 9 | 33 | import * as Option from "effect/Option"; |
| 10 | 34 | import * as PlatformError from "effect/PlatformError"; |
| 11 | 35 | import * as Ref from "effect/Ref"; |
| 36 | import * as Result from "effect/Result"; | |
| 12 | 37 | import * as Schedule from "effect/Schedule"; |
| 13 | 38 | import * as Schema from "effect/Schema"; |
| 14 | 39 | import * as Semaphore from "effect/Semaphore"; |
| 15 | 40 | import * as Scope from "effect/Scope"; |
| 16 | 41 | import * as Stream from "effect/Stream"; |
| 17 | import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 18 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; | |
| 19 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 42 | import { HttpClient } from "effect/unstable/http"; | |
| 43 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 20 | 44 | |
| 21 | 45 | import { |
| 22 | 46 | DesktopBackendBootstrap, |
| 23 | 47 | type DesktopBackendBootstrap as DesktopBackendBootstrapValue, |
| 48 | PRIMARY_LOCAL_ENVIRONMENT_ID, | |
| 24 | 49 | } from "@t3tools/contracts"; |
| 50 | import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness"; | |
| 25 | 51 | |
| 26 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | |
| 27 | 52 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 28 | import * as DesktopState from "../app/DesktopState.ts"; | |
| 29 | import * as DesktopWindow from "../window/DesktopWindow.ts"; | |
| 30 | 53 | |
| 31 | 54 | const INITIAL_RESTART_DELAY = Duration.millis(500); |
| 32 | 55 | const MAX_RESTART_DELAY = Duration.seconds(10); |
| 56 | // After this many consecutive fatal preflight failures, stop the silent | |
| 57 | // restart loop and surface the reason via onPreflightFailed. Transient | |
| 58 | // failures may instead provide their own larger retryLimit when they should | |
| 59 | // self-heal for a while but must not leave the app connecting forever. | |
| 60 | const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5; | |
| 33 | 61 | const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); |
| 34 | 62 | const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); |
| 35 | 63 | const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); |
| ... | ... | @@ -42,145 +70,91 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; |
| 42 | 70 | |
| 43 | 71 | export type BackendProcessOutputStream = "stdout" | "stderr"; |
| 44 | 72 | |
| 45 | export interface BackendProcessContext { | |
| 73 | export type DesktopBackendBootstrapDelivery = "fd3" | "stdin"; | |
| 74 | ||
| 75 | export interface DesktopBackendStartConfig { | |
| 46 | 76 | readonly executablePath: string; |
| 77 | readonly args: ReadonlyArray<string>; | |
| 47 | 78 | readonly entryPath: string; |
| 48 | 79 | readonly cwd: string; |
| 49 | readonly httpBaseUrl: URL; | |
| 50 | } | |
| 51 | ||
| 52 | export interface DesktopBackendStartConfig extends BackendProcessContext { | |
| 53 | 80 | readonly env: Record<string, string | undefined>; |
| 81 | // When true the spawner merges the desktop process.env on top of `env`; | |
| 82 | // when false `env` is passed verbatim. WSL mode opts out so a leaking | |
| 83 | // T3CODE_HOME can't pin the WSL backend to /mnt/c/...\.t3. | |
| 84 | readonly extendEnv: boolean; | |
| 54 | 85 | readonly bootstrap: DesktopBackendBootstrapValue; |
| 86 | readonly bootstrapDelivery: DesktopBackendBootstrapDelivery; | |
| 87 | readonly httpBaseUrl: URL; | |
| 55 | 88 | readonly captureOutput: boolean; |
| 89 | readonly preflightFailure: Option.Option<PreflightFailure>; | |
| 90 | // Present for a WSL run after the configured/default distro has been | |
| 91 | // resolved to the concrete distro passed to wsl.exe. | |
| 92 | readonly runningDistro?: string; | |
| 93 | } | |
| 94 | ||
| 95 | // A preflight failure records whether it is fatal. Transient failures (WSL | |
| 96 | // cold-starting, wslpath while the VM boots) keep retrying so the backend can | |
| 97 | // self-heal; fatal ones (no node, wrong version, missing build tools) are | |
| 98 | // surfaced via onPreflightFailed and stop the restart loop after | |
| 99 | // MAX_PREFLIGHT_FAILURE_ATTEMPTS. | |
| 100 | export interface PreflightFailure { | |
| 101 | readonly reason: string; | |
| 102 | readonly fatal: boolean; | |
| 103 | readonly retryLimit?: number; | |
| 56 | 104 | } |
| 57 | 105 | |
| 58 | 106 | interface BackendProcessExit { |
| 59 | 107 | readonly code: Option.Option<number>; |
| 60 | 108 | readonly reason: string; |
| 109 | readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>; | |
| 61 | 110 | } |
| 62 | 111 | |
| 63 | const backendProcessContextSchema = { | |
| 64 | executablePath: Schema.String, | |
| 65 | entryPath: Schema.String, | |
| 66 | cwd: Schema.String, | |
| 67 | httpBaseUrl: Schema.URL, | |
| 68 | }; | |
| 69 | ||
| 70 | export class BackendReadinessTimeoutError extends Schema.TaggedErrorClass<BackendReadinessTimeoutError>()( | |
| 71 | "BackendReadinessTimeoutError", | |
| 112 | export class BackendTimeoutError extends Schema.TaggedErrorClass<BackendTimeoutError>()( | |
| 113 | "BackendTimeoutError", | |
| 72 | 114 | { |
| 73 | ...backendProcessContextSchema, | |
| 74 | readinessUrl: Schema.URL, | |
| 75 | timeoutMs: Schema.Number, | |
| 76 | cause: Schema.Defect(), | |
| 115 | url: Schema.instanceOf(URL), | |
| 77 | 116 | }, |
| 78 | 117 | ) { |
| 79 | override get message(): string { | |
| 80 | return `Timed out after ${this.timeoutMs}ms waiting for desktop backend readiness at ${this.readinessUrl.href}.`; | |
| 118 | override get message() { | |
| 119 | return `Timed out waiting for backend readiness at ${this.url.href}.`; | |
| 81 | 120 | } |
| 82 | 121 | } |
| 83 | 122 | |
| 84 | export class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()( | |
| 123 | class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()( | |
| 85 | 124 | "BackendProcessBootstrapEncodeError", |
| 86 | 125 | { |
| 87 | ...backendProcessContextSchema, | |
| 126 | entryPath: Schema.String, | |
| 88 | 127 | cause: Schema.Defect(), |
| 89 | 128 | }, |
| 90 | 129 | ) { |
| 91 | override get message(): string { | |
| 130 | override get message() { | |
| 92 | 131 | return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`; |
| 93 | 132 | } |
| 94 | 133 | } |
| 95 | 134 | |
| 96 | export class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()( | |
| 135 | class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()( | |
| 97 | 136 | "BackendProcessSpawnError", |
| 98 | 137 | { |
| 99 | ...backendProcessContextSchema, | |
| 100 | cause: Schema.Defect(), | |
| 101 | }, | |
| 102 | ) { | |
| 103 | override get message(): string { | |
| 104 | return `Failed to spawn desktop backend entry ${this.entryPath} with ${this.executablePath}.`; | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | export class BackendProcessOutputReadError extends Schema.TaggedErrorClass<BackendProcessOutputReadError>()( | |
| 109 | "BackendProcessOutputReadError", | |
| 110 | { | |
| 111 | ...backendProcessContextSchema, | |
| 112 | pid: Schema.Number, | |
| 113 | streamName: Schema.Literals(["stdout", "stderr"]), | |
| 114 | cause: Schema.Defect(), | |
| 115 | }, | |
| 116 | ) { | |
| 117 | override get message(): string { | |
| 118 | return `Failed to read ${this.streamName} from desktop backend process ${this.pid}.`; | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | export class BackendProcessOutputHandlingError extends Schema.TaggedErrorClass<BackendProcessOutputHandlingError>()( | |
| 123 | "BackendProcessOutputHandlingError", | |
| 124 | { | |
| 125 | ...backendProcessContextSchema, | |
| 126 | pid: Schema.Number, | |
| 127 | streamName: Schema.Literals(["stdout", "stderr"]), | |
| 128 | chunkByteLength: Schema.Number, | |
| 129 | cause: Schema.Defect(), | |
| 130 | }, | |
| 131 | ) { | |
| 132 | override get message(): string { | |
| 133 | return `Failed to handle ${this.chunkByteLength} bytes from ${this.streamName} of desktop backend process ${this.pid}.`; | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | export type BackendProcessOutputError = | |
| 138 | | BackendProcessOutputReadError | |
| 139 | | BackendProcessOutputHandlingError; | |
| 140 | ||
| 141 | export class BackendProcessExitStatusError extends Schema.TaggedErrorClass<BackendProcessExitStatusError>()( | |
| 142 | "BackendProcessExitStatusError", | |
| 143 | { | |
| 144 | ...backendProcessContextSchema, | |
| 145 | pid: Schema.Number, | |
| 146 | cause: Schema.Defect(), | |
| 147 | }, | |
| 148 | ) { | |
| 149 | override get message(): string { | |
| 150 | return `Failed to read the exit status of desktop backend process ${this.pid}.`; | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | export class DesktopBackendRestartError extends Schema.TaggedErrorClass<DesktopBackendRestartError>()( | |
| 155 | "DesktopBackendRestartError", | |
| 156 | { | |
| 157 | reason: Schema.String, | |
| 158 | delayMs: Schema.Number, | |
| 138 | executablePath: Schema.String, | |
| 159 | 139 | cause: Schema.Defect(), |
| 160 | 140 | }, |
| 161 | 141 | ) { |
| 162 | override get message(): string { | |
| 163 | return `Desktop backend restart failed after a scheduled ${this.delayMs}ms delay.`; | |
| 142 | override get message() { | |
| 143 | return `Failed to spawn the desktop backend process at ${this.executablePath}.`; | |
| 164 | 144 | } |
| 165 | 145 | } |
| 166 | 146 | |
| 167 | export const BackendProcessError = Schema.Union([ | |
| 168 | BackendProcessBootstrapEncodeError, | |
| 169 | BackendProcessSpawnError, | |
| 170 | BackendProcessExitStatusError, | |
| 171 | ]); | |
| 172 | export type BackendProcessError = typeof BackendProcessError.Type; | |
| 147 | type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; | |
| 173 | 148 | |
| 174 | 149 | interface RunBackendProcessOptions extends DesktopBackendStartConfig { |
| 175 | 150 | readonly readinessTimeout?: Duration.Duration; |
| 176 | 151 | readonly onStarted?: (pid: number) => Effect.Effect<void>; |
| 177 | 152 | readonly onReady?: () => Effect.Effect<void>; |
| 178 | readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect<void>; | |
| 153 | readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect<void>; | |
| 179 | 154 | readonly onOutput?: ( |
| 180 | 155 | streamName: BackendProcessOutputStream, |
| 181 | 156 | chunk: Uint8Array, |
| 182 | ) => Effect.Effect<void, Error>; | |
| 183 | readonly onOutputFailure?: (error: BackendProcessOutputError) => Effect.Effect<void>; | |
| 157 | ) => Effect.Effect<void>; | |
| 184 | 158 | } |
| 185 | 159 | |
| 186 | 160 | export interface DesktopBackendSnapshot { |
| ... | ... | @@ -191,18 +165,60 @@ export interface DesktopBackendSnapshot { |
| 191 | 165 | readonly restartScheduled: boolean; |
| 192 | 166 | } |
| 193 | 167 | |
| 194 | export class DesktopBackendManager extends Context.Service< | |
| 195 | DesktopBackendManager, | |
| 196 | { | |
| 197 | readonly start: Effect.Effect<void>; | |
| 198 | readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect<void>; | |
| 199 | readonly currentConfig: Effect.Effect<Option.Option<DesktopBackendStartConfig>>; | |
| 200 | readonly snapshot: Effect.Effect<DesktopBackendSnapshot>; | |
| 201 | } | |
| 202 | >()("@t3tools/desktop/backend/DesktopBackendManager") {} | |
| 168 | // Opaque identifier for one backend process inside the pool. Today only | |
| 169 | // PRIMARY_INSTANCE_ID is registered. Follow-up commits add WSL distros | |
| 170 | // under ids derived from the distro name (e.g. "wsl:ubuntu"). Eventually | |
| 171 | // these map 1:1 with environment ids on the frontend; keeping them | |
| 172 | // desktop-local for now avoids leaking the contracts dependency. | |
| 173 | export type BackendInstanceId = string & Brand.Brand<"BackendInstanceId">; | |
| 174 | export const BackendInstanceId = Brand.nominal<BackendInstanceId>(); | |
| 175 | ||
| 176 | export const PRIMARY_INSTANCE_ID: BackendInstanceId = BackendInstanceId( | |
| 177 | PRIMARY_LOCAL_ENVIRONMENT_ID, | |
| 178 | ); | |
| 179 | ||
| 180 | // One pooled backend instance. Same lifecycle surface as the legacy | |
| 181 | // `DesktopBackendManagerShape`; the id and label give the pool registry | |
| 182 | // + UI something to route on. | |
| 183 | export interface DesktopBackendInstance { | |
| 184 | readonly id: BackendInstanceId; | |
| 185 | readonly label: Effect.Effect<string>; | |
| 186 | readonly start: Effect.Effect<void>; | |
| 187 | readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect<void>; | |
| 188 | readonly currentConfig: Effect.Effect<Option.Option<DesktopBackendStartConfig>>; | |
| 189 | readonly snapshot: Effect.Effect<DesktopBackendSnapshot>; | |
| 190 | // Polls desiredRunning + the instance's own ready flag until the | |
| 191 | // backend reports ready, or the timeout elapses. Returns true on | |
| 192 | // ready, false on timeout. Used by the WSL backend swap to drive its | |
| 193 | // rollback path. | |
| 194 | readonly waitForReady: (timeout: Duration.Duration) => Effect.Effect<boolean>; | |
| 195 | } | |
| 203 | 196 | |
| 204 | const { logWarning: logBackendManagerWarning, logError: logBackendManagerError } = | |
| 205 | DesktopObservability.makeComponentLogger("desktop-backend-manager"); | |
| 197 | // Spec describing one backend instance to spawn. The configResolve | |
| 198 | // effect is awaited each time the instance is (re)started so live | |
| 199 | // settings changes are picked up on the next start cycle. onReady and | |
| 200 | // onShutdown let the primary instance trigger UI side effects (window | |
| 201 | // open, global readiness flag) without coupling the factory to those | |
| 202 | // concerns; other instances pass them as undefined. | |
| 203 | export interface BackendInstanceSpec { | |
| 204 | readonly id: BackendInstanceId; | |
| 205 | readonly label: Effect.Effect<string>; | |
| 206 | // configResolve can now fail with PlatformError because the | |
| 207 | // bootstrap-token closure inside DesktopBackendConfiguration uses | |
| 208 | // crypto.randomBytes (Effect 4 beta.73 migration). | |
| 209 | readonly configResolve: Effect.Effect<DesktopBackendStartConfig, PlatformError.PlatformError>; | |
| 210 | // Receives the *resolved* httpBaseUrl of the run that just became | |
| 211 | // ready. The window service uses this to decide what URL to load | |
| 212 | // (the WSL backend reports its distro IP, the Windows backend reports | |
| 213 | // 127.0.0.1). Splitting this off from configResolve avoids races | |
| 214 | // between "fired onReady" and "currentConfig already advanced". | |
| 215 | readonly onReady?: (httpBaseUrl: URL) => Effect.Effect<void>; | |
| 216 | readonly onShutdown?: () => Effect.Effect<void>; | |
| 217 | // Fired once when a fatal or bounded preflight failure has exhausted its | |
| 218 | // retries. Returns true when the callback changed configuration and the | |
| 219 | // manager should resolve once more; false stops the failed instance. | |
| 220 | readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect<boolean>; | |
| 221 | } | |
| 206 | 222 | |
| 207 | 223 | interface ActiveBackendRun { |
| 208 | 224 | readonly id: number; |
| ... | ... | @@ -217,6 +233,9 @@ interface BackendManagerState { |
| 217 | 233 | readonly config: Option.Option<DesktopBackendStartConfig>; |
| 218 | 234 | readonly active: Option.Option<ActiveBackendRun>; |
| 219 | 235 | readonly restartAttempt: number; |
| 236 | // Consecutive bounded/fatal preflight failures, reset on a clean or | |
| 237 | // unbounded-transient preflight. restartAttempt counts all restarts. | |
| 238 | readonly preflightFailureAttempt: number; | |
| 220 | 239 | readonly restartFiber: Option.Option<Fiber.Fiber<void, never>>; |
| 221 | 240 | readonly nextRunId: number; |
| 222 | 241 | } |
| ... | ... | @@ -227,6 +246,7 @@ const initialState: BackendManagerState = { |
| 227 | 246 | config: Option.none(), |
| 228 | 247 | active: Option.none(), |
| 229 | 248 | restartAttempt: 0, |
| 249 | preflightFailureAttempt: 0, | |
| 230 | 250 | restartFiber: Option.none(), |
| 231 | 251 | nextRunId: 1, |
| 232 | 252 | }; |
| ... | ... | @@ -259,198 +279,135 @@ const closeRun = ( |
| 259 | 279 | ).pipe(Effect.ignore); |
| 260 | 280 | }; |
| 261 | 281 | |
| 262 | export const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( | |
| 263 | options: BackendProcessContext & { readonly timeout: Duration.Duration }, | |
| 264 | ): Effect.fn.Return<void, BackendReadinessTimeoutError, HttpClient.HttpClient> { | |
| 265 | const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl); | |
| 266 | const client = (yield* HttpClient.HttpClient).pipe( | |
| 267 | HttpClient.filterStatusOk, | |
| 268 | HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)), | |
| 269 | HttpClient.retry(Schedule.spaced(DEFAULT_BACKEND_READINESS_INTERVAL)), | |
| 270 | ); | |
| 282 | const waitForHttpReady = ( | |
| 283 | baseUrl: URL, | |
| 284 | timeout: Duration.Duration, | |
| 285 | ): Effect.Effect<void, BackendTimeoutError, HttpClient.HttpClient> => { | |
| 286 | const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl); | |
| 287 | return waitForHttpReadyShared({ | |
| 288 | baseUrl: baseUrl.href, | |
| 289 | path: BACKEND_READINESS_PATH, | |
| 290 | timeoutMs: Duration.toMillis(timeout), | |
| 291 | intervalMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_INTERVAL), | |
| 292 | probeTimeoutMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT), | |
| 293 | makeError: () => new BackendTimeoutError({ url: readinessUrl }), | |
| 294 | }); | |
| 295 | }; | |
| 271 | 296 | |
| 272 | yield* client.get(readinessUrl).pipe( | |
| 273 | Effect.asVoid, | |
| 274 | Effect.timeout(options.timeout), | |
| 275 | Effect.mapError( | |
| 276 | (cause) => | |
| 277 | new BackendReadinessTimeoutError({ | |
| 278 | executablePath: options.executablePath, | |
| 279 | entryPath: options.entryPath, | |
| 280 | cwd: options.cwd, | |
| 281 | httpBaseUrl: options.httpBaseUrl, | |
| 282 | readinessUrl, | |
| 283 | timeoutMs: Duration.toMillis(options.timeout), | |
| 284 | cause, | |
| 285 | }), | |
| 286 | ), | |
| 287 | ); | |
| 288 | }); | |
| 297 | function describeProcessExit( | |
| 298 | result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>, | |
| 299 | ): BackendProcessExit { | |
| 300 | if (Result.isSuccess(result)) { | |
| 301 | return { | |
| 302 | code: Option.some(result.success), | |
| 303 | reason: `code=${result.success}`, | |
| 304 | result, | |
| 305 | }; | |
| 306 | } | |
| 307 | ||
| 308 | return { | |
| 309 | code: Option.none(), | |
| 310 | reason: result.failure.message, | |
| 311 | result, | |
| 312 | }; | |
| 313 | } | |
| 289 | 314 | |
| 290 | 315 | function drainBackendOutput( |
| 291 | context: BackendProcessContext & { readonly pid: number }, | |
| 292 | 316 | streamName: BackendProcessOutputStream, |
| 293 | 317 | stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>, |
| 294 | onOutput: ( | |
| 295 | streamName: BackendProcessOutputStream, | |
| 296 | chunk: Uint8Array, | |
| 297 | ) => Effect.Effect<void, Error>, | |
| 298 | onOutputFailure: (error: BackendProcessOutputError) => Effect.Effect<void>, | |
| 318 | onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect<void>, | |
| 299 | 319 | ): Effect.Effect<void> { |
| 300 | 320 | return stream.pipe( |
| 301 | Stream.mapError( | |
| 302 | (cause) => | |
| 303 | new BackendProcessOutputReadError({ | |
| 304 | ...context, | |
| 305 | streamName, | |
| 306 | cause, | |
| 307 | }), | |
| 308 | ), | |
| 309 | Stream.runForEach((chunk) => | |
| 310 | onOutput(streamName, chunk).pipe( | |
| 311 | Effect.mapError( | |
| 312 | (cause) => | |
| 313 | new BackendProcessOutputHandlingError({ | |
| 314 | ...context, | |
| 315 | streamName, | |
| 316 | chunkByteLength: chunk.byteLength, | |
| 317 | cause, | |
| 318 | }), | |
| 319 | ), | |
| 320 | ), | |
| 321 | ), | |
| 322 | Effect.catchTags({ | |
| 323 | BackendProcessOutputReadError: onOutputFailure, | |
| 324 | BackendProcessOutputHandlingError: onOutputFailure, | |
| 325 | }), | |
| 321 | Stream.runForEach((chunk) => onOutput(streamName, chunk)), | |
| 322 | Effect.ignore, | |
| 326 | 323 | ); |
| 327 | 324 | } |
| 328 | 325 | |
| 329 | 326 | const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); |
| 330 | 327 | |
| 331 | export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( | |
| 328 | const runBackendProcess = Effect.fn("runBackendProcess")(function* ( | |
| 332 | 329 | options: RunBackendProcessOptions, |
| 333 | 330 | ): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> { |
| 334 | 331 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 335 | 332 | const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( |
| 336 | 333 | Effect.mapError( |
| 337 | (cause) => | |
| 338 | new BackendProcessBootstrapEncodeError({ | |
| 339 | executablePath: options.executablePath, | |
| 340 | entryPath: options.entryPath, | |
| 341 | cwd: options.cwd, | |
| 342 | httpBaseUrl: options.httpBaseUrl, | |
| 343 | cause, | |
| 344 | }), | |
| 334 | (cause) => new BackendProcessBootstrapEncodeError({ entryPath: options.entryPath, cause }), | |
| 345 | 335 | ), |
| 346 | 336 | ); |
| 347 | 337 | const onOutput = options.onOutput ?? (() => Effect.void); |
| 348 | const command = ChildProcess.make( | |
| 349 | options.executablePath, | |
| 350 | [options.entryPath, "--bootstrap-fd", "3"], | |
| 351 | { | |
| 352 | cwd: options.cwd, | |
| 353 | env: options.env, | |
| 354 | extendEnv: true, | |
| 355 | // In Electron main, process.execPath points to the Electron binary. | |
| 356 | // Run the child in Node mode so this backend process does not become a GUI app instance. | |
| 357 | stdin: "ignore", | |
| 358 | stdout: options.captureOutput ? "pipe" : "inherit", | |
| 359 | stderr: options.captureOutput ? "pipe" : "inherit", | |
| 360 | killSignal: "SIGTERM", | |
| 361 | forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, | |
| 362 | additionalFds: { | |
| 363 | fd3: { | |
| 364 | type: "input", | |
| 365 | stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)), | |
| 366 | }, | |
| 367 | }, | |
| 368 | }, | |
| 369 | ); | |
| 338 | const bootstrapStream = Stream.encodeText(Stream.make(`${bootstrapJson}\n`)); | |
| 339 | const command = ChildProcess.make(options.executablePath, options.args, { | |
| 340 | cwd: options.cwd, | |
| 341 | env: options.env, | |
| 342 | extendEnv: options.extendEnv, | |
| 343 | // In Electron main, process.execPath points to the Electron binary. | |
| 344 | // Run the child in Node mode so this backend process does not become a GUI app instance. | |
| 345 | stdin: options.bootstrapDelivery === "stdin" ? bootstrapStream : "ignore", | |
| 346 | stdout: options.captureOutput ? "pipe" : "inherit", | |
| 347 | stderr: options.captureOutput ? "pipe" : "inherit", | |
| 348 | killSignal: "SIGTERM", | |
| 349 | forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, | |
| 350 | // wsl.exe drops additional file descriptors when forwarding to the Linux | |
| 351 | // side, so the WSL spawn path delivers the bootstrap envelope via stdin | |
| 352 | // (`--bootstrap-fd 0`) instead. | |
| 353 | ...(options.bootstrapDelivery === "fd3" | |
| 354 | ? { additionalFds: { fd3: { type: "input" as const, stream: bootstrapStream } } } | |
| 355 | : {}), | |
| 356 | }); | |
| 370 | 357 | |
| 371 | const handle = yield* spawner.spawn(command).pipe( | |
| 372 | Effect.mapError( | |
| 373 | (cause) => | |
| 374 | new BackendProcessSpawnError({ | |
| 375 | executablePath: options.executablePath, | |
| 376 | entryPath: options.entryPath, | |
| 377 | cwd: options.cwd, | |
| 378 | httpBaseUrl: options.httpBaseUrl, | |
| 379 | cause, | |
| 380 | }), | |
| 381 | ), | |
| 382 | ); | |
| 358 | const handle = yield* spawner | |
| 359 | .spawn(command) | |
| 360 | .pipe( | |
| 361 | Effect.mapError( | |
| 362 | (cause) => new BackendProcessSpawnError({ executablePath: options.executablePath, cause }), | |
| 363 | ), | |
| 364 | ); | |
| 383 | 365 | |
| 384 | 366 | yield* options.onStarted?.(handle.pid) ?? Effect.void; |
| 385 | 367 | if (options.captureOutput) { |
| 386 | const outputContext = { | |
| 387 | executablePath: options.executablePath, | |
| 388 | entryPath: options.entryPath, | |
| 389 | cwd: options.cwd, | |
| 390 | httpBaseUrl: options.httpBaseUrl, | |
| 391 | pid: Number(handle.pid), | |
| 392 | }; | |
| 393 | const onOutputFailure = options.onOutputFailure ?? (() => Effect.void); | |
| 394 | yield* drainBackendOutput( | |
| 395 | outputContext, | |
| 396 | "stdout", | |
| 397 | handle.stdout, | |
| 398 | onOutput, | |
| 399 | onOutputFailure, | |
| 400 | ).pipe(Effect.forkScoped); | |
| 401 | yield* drainBackendOutput( | |
| 402 | outputContext, | |
| 403 | "stderr", | |
| 404 | handle.stderr, | |
| 405 | onOutput, | |
| 406 | onOutputFailure, | |
| 407 | ).pipe(Effect.forkScoped); | |
| 368 | yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); | |
| 369 | yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); | |
| 408 | 370 | } |
| 409 | yield* waitForHttpReady({ | |
| 410 | executablePath: options.executablePath, | |
| 411 | entryPath: options.entryPath, | |
| 412 | cwd: options.cwd, | |
| 413 | httpBaseUrl: options.httpBaseUrl, | |
| 414 | timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, | |
| 415 | }).pipe( | |
| 371 | yield* waitForHttpReady( | |
| 372 | options.httpBaseUrl, | |
| 373 | options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, | |
| 374 | ).pipe( | |
| 416 | 375 | Effect.tap(() => options.onReady?.() ?? Effect.void), |
| 417 | Effect.catchTags({ | |
| 418 | BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void, | |
| 419 | }), | |
| 376 | Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), | |
| 420 | 377 | Effect.forkScoped, |
| 421 | 378 | ); |
| 422 | 379 | |
| 423 | const exitCode = yield* handle.exitCode.pipe( | |
| 424 | Effect.mapError( | |
| 425 | (cause) => | |
| 426 | new BackendProcessExitStatusError({ | |
| 427 | executablePath: options.executablePath, | |
| 428 | entryPath: options.entryPath, | |
| 429 | cwd: options.cwd, | |
| 430 | httpBaseUrl: options.httpBaseUrl, | |
| 431 | pid: Number(handle.pid), | |
| 432 | cause, | |
| 433 | }), | |
| 434 | ), | |
| 435 | ); | |
| 436 | return { | |
| 437 | code: Option.some(exitCode), | |
| 438 | reason: `code=${exitCode}`, | |
| 439 | } satisfies BackendProcessExit; | |
| 380 | return describeProcessExit(yield* Effect.result(handle.exitCode)); | |
| 440 | 381 | }); |
| 441 | 382 | |
| 442 | export const make = Effect.gen(function* () { | |
| 383 | // Factory for one pooled backend instance. The returned instance owns | |
| 384 | // its own state Ref, mutex, restart loop, and active child process; | |
| 385 | // nothing is shared between instances created from separate | |
| 386 | // makeBackendInstance calls. The instance shuts down automatically when | |
| 387 | // the calling scope closes (typically the application scope). | |
| 388 | export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( | |
| 389 | spec: BackendInstanceSpec, | |
| 390 | ): Effect.fn.Return< | |
| 391 | DesktopBackendInstance, | |
| 392 | never, | |
| 393 | | FileSystem.FileSystem | |
| 394 | | ChildProcessSpawner.ChildProcessSpawner | |
| 395 | | HttpClient.HttpClient | |
| 396 | | DesktopObservability.DesktopBackendOutputLogFactory | |
| 397 | | Scope.Scope | |
| 398 | > { | |
| 443 | 399 | const parentScope = yield* Scope.Scope; |
| 444 | 400 | const fileSystem = yield* FileSystem.FileSystem; |
| 445 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 446 | const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog; | |
| 447 | const desktopState = yield* DesktopState.DesktopState; | |
| 448 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | |
| 401 | const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory; | |
| 402 | const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id); | |
| 449 | 403 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 450 | 404 | const httpClient = yield* HttpClient.HttpClient; |
| 451 | 405 | const state = yield* Ref.make(initialState); |
| 452 | 406 | const mutex = yield* Semaphore.make(1); |
| 453 | 407 | |
| 408 | const { logWarning: logInstanceWarning, logError: logInstanceError } = | |
| 409 | DesktopObservability.makeComponentLogger(`desktop-backend-instance:${spec.id}`); | |
| 410 | ||
| 454 | 411 | const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => |
| 455 | 412 | Ref.update(state, withActiveRun(runId, f)); |
| 456 | 413 | |
| ... | ... | @@ -490,11 +447,16 @@ export const make = Effect.gen(function* () { |
| 490 | 447 | return; |
| 491 | 448 | } |
| 492 | 449 | |
| 493 | yield* Ref.set(desktopState.backendReady, false); | |
| 494 | const config = yield* configuration.resolve.pipe( | |
| 450 | if (current.ready) { | |
| 451 | yield* spec.onShutdown?.() ?? Effect.void; | |
| 452 | yield* Ref.update(state, (latest) => | |
| 453 | latest.ready ? { ...latest, ready: false } : latest, | |
| 454 | ); | |
| 455 | } | |
| 456 | const config = yield* spec.configResolve.pipe( | |
| 495 | 457 | Effect.tapError((error) => |
| 496 | logBackendManagerError("failed to generate desktop backend configuration", { | |
| 497 | cause: error, | |
| 458 | logInstanceError("failed to generate desktop backend configuration", { | |
| 459 | cause: error.message, | |
| 498 | 460 | }), |
| 499 | 461 | ), |
| 500 | 462 | Effect.option, |
| ... | ... | @@ -506,14 +468,84 @@ export const make = Effect.gen(function* () { |
| 506 | 468 | .exists(config.value.entryPath) |
| 507 | 469 | .pipe(Effect.orElseSucceed(() => false)); |
| 508 | 470 | |
| 471 | const resetFatalPreflightCounter = | |
| 472 | !current.desiredRunning && current.preflightFailureAttempt > 0; | |
| 509 | 473 | yield* cancelRestart; |
| 510 | 474 | yield* Ref.update(state, (latest) => ({ |
| 511 | 475 | ...latest, |
| 512 | 476 | desiredRunning: true, |
| 513 | 477 | ready: false, |
| 514 | 478 | config: Option.some(config.value), |
| 479 | preflightFailureAttempt: resetFatalPreflightCounter ? 0 : latest.preflightFailureAttempt, | |
| 515 | 480 | })); |
| 516 | 481 | |
| 482 | const preflightFailure = config.value.preflightFailure; | |
| 483 | if (Option.isSome(preflightFailure)) { | |
| 484 | const { reason, fatal, retryLimit } = preflightFailure.value; | |
| 485 | if (!fatal && retryLimit === undefined) { | |
| 486 | // Transient (WSL cold-starting, wslpath while the VM boots). Keep | |
| 487 | // retrying so the backend self-heals once WSL is ready. Reset a | |
| 488 | // prior bounded/fatal streak because this is a different failure. | |
| 489 | yield* Ref.update(state, (latest) => | |
| 490 | latest.preflightFailureAttempt === 0 | |
| 491 | ? latest | |
| 492 | : { ...latest, preflightFailureAttempt: 0 }, | |
| 493 | ); | |
| 494 | yield* scheduleRestart(reason); | |
| 495 | return; | |
| 496 | } | |
| 497 | const attemptLimit = retryLimit ?? MAX_PREFLIGHT_FAILURE_ATTEMPTS; | |
| 498 | const attempt = yield* Ref.modify(state, (latest) => { | |
| 499 | const next = latest.preflightFailureAttempt + 1; | |
| 500 | return [next, { ...latest, preflightFailureAttempt: next }] as const; | |
| 501 | }); | |
| 502 | if (attempt > attemptLimit) { | |
| 503 | // We already surfaced and asked for the Windows fallback, yet we're | |
| 504 | // still resolving the WSL primary — the fallback didn't take (e.g. | |
| 505 | // the settings write failed). Stop rather than loop forever. | |
| 506 | yield* logInstanceError("backend preflight still failing after fallback; stopping", { | |
| 507 | reason, | |
| 508 | attempt, | |
| 509 | }); | |
| 510 | yield* Ref.update(state, (latest) => ({ | |
| 511 | ...latest, | |
| 512 | desiredRunning: false, | |
| 513 | ready: false, | |
| 514 | })); | |
| 515 | return; | |
| 516 | } | |
| 517 | if (attempt === attemptLimit) { | |
| 518 | // Fatal/bounded and out of retries. Surface the reason (onPreflightFailed, | |
| 519 | // on the primary, shows a dialog and persists Windows mode), then | |
| 520 | // schedule one more restart so the next resolve picks up the Windows | |
| 521 | // primary and a window can open. | |
| 522 | yield* logInstanceError( | |
| 523 | "backend preflight failed repeatedly; surfacing and falling back", | |
| 524 | { reason, attempt }, | |
| 525 | ); | |
| 526 | const shouldRestart = yield* ( | |
| 527 | spec.onPreflightFailed?.(preflightFailure.value) ?? Effect.succeed(false) | |
| 528 | ); | |
| 529 | if (shouldRestart) { | |
| 530 | yield* scheduleRestart(reason); | |
| 531 | } else { | |
| 532 | yield* Ref.update(state, (latest) => ({ | |
| 533 | ...latest, | |
| 534 | desiredRunning: false, | |
| 535 | ready: false, | |
| 536 | })); | |
| 537 | } | |
| 538 | return; | |
| 539 | } | |
| 540 | yield* scheduleRestart(reason); | |
| 541 | return; | |
| 542 | } | |
| 543 | // Clean preflight — reset the fatal counter so a later failure gets a | |
| 544 | // fresh allowance. | |
| 545 | yield* Ref.update(state, (latest) => | |
| 546 | latest.preflightFailureAttempt === 0 ? latest : { ...latest, preflightFailureAttempt: 0 }, | |
| 547 | ); | |
| 548 | ||
| 517 | 549 | if (!entryExists) { |
| 518 | 550 | yield* scheduleRestart(`missing server entry at ${config.value.entryPath}`); |
| 519 | 551 | return; |
| ... | ... | @@ -534,7 +566,7 @@ export const make = Effect.gen(function* () { |
| 534 | 566 | }, |
| 535 | 567 | ]); |
| 536 | 568 | |
| 537 | const finalizeRun = Effect.fn("desktop.backendManager.finalizeRun")(function* ( | |
| 569 | const finalizeRun = Effect.fn("desktop.backendInstance.finalizeRun")(function* ( | |
| 538 | 570 | reason: string, |
| 539 | 571 | ) { |
| 540 | 572 | yield* mutex.withPermits(1)( |
| ... | ... | @@ -586,7 +618,7 @@ export const make = Effect.gen(function* () { |
| 586 | 618 | details: `pid=${pid.value} ${reason}`, |
| 587 | 619 | }); |
| 588 | 620 | } |
| 589 | yield* Ref.set(desktopState.backendReady, false); | |
| 621 | yield* spec.onShutdown?.() ?? Effect.void; | |
| 590 | 622 | } |
| 591 | 623 | |
| 592 | 624 | if (isCurrentRun && nextState.desiredRunning) { |
| ... | ... | @@ -598,7 +630,7 @@ export const make = Effect.gen(function* () { |
| 598 | 630 | |
| 599 | 631 | const program = runBackendProcess({ |
| 600 | 632 | ...config.value, |
| 601 | onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) { | |
| 633 | onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) { | |
| 602 | 634 | yield* updateActiveRun(runId, (run) => ({ |
| 603 | 635 | ...run, |
| 604 | 636 | pid: Option.some(pid), |
| ... | ... | @@ -608,7 +640,7 @@ export const make = Effect.gen(function* () { |
| 608 | 640 | details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`, |
| 609 | 641 | }); |
| 610 | 642 | }), |
| 611 | onReady: Effect.fn("desktop.backendManager.onReady")(function* () { | |
| 643 | onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { | |
| 612 | 644 | const isCurrentRun = yield* Ref.modify(state, (latest) => { |
| 613 | 645 | const activeRun = Option.getOrUndefined(latest.active); |
| 614 | 646 | if (activeRun?.id !== runId) { |
| ... | ... | @@ -628,30 +660,19 @@ export const make = Effect.gen(function* () { |
| 628 | 660 | return; |
| 629 | 661 | } |
| 630 | 662 | |
| 631 | yield* Ref.set(desktopState.backendReady, true); | |
| 632 | yield* desktopWindow.handleBackendReady.pipe( | |
| 633 | Effect.catch((error) => | |
| 634 | logBackendManagerError("failed to open main window after backend readiness", { | |
| 635 | cause: error, | |
| 636 | }), | |
| 637 | ), | |
| 638 | ); | |
| 663 | yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void; | |
| 639 | 664 | }), |
| 640 | 665 | onReadinessFailure: (error) => |
| 641 | logBackendManagerWarning("backend readiness check failed during bootstrap", { | |
| 642 | error, | |
| 666 | logInstanceWarning("backend readiness check failed during bootstrap", { | |
| 667 | error: error.message, | |
| 643 | 668 | }), |
| 644 | 669 | onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), |
| 645 | onOutputFailure: (error) => logBackendManagerError(error.message, { error }), | |
| 646 | 670 | }).pipe( |
| 647 | 671 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 648 | 672 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 649 | 673 | Scope.provide(runScope), |
| 650 | 674 | Effect.matchEffect({ |
| 651 | onFailure: (error) => | |
| 652 | logBackendManagerError(error.message, { error }).pipe( | |
| 653 | Effect.andThen(finalizeRun(error.message)), | |
| 654 | ), | |
| 675 | onFailure: (error) => finalizeRun(error.message), | |
| 655 | 676 | onSuccess: (exit) => finalizeRun(exit.reason), |
| 656 | 677 | }), |
| 657 | 678 | Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), |
| ... | ... | @@ -664,9 +685,9 @@ export const make = Effect.gen(function* () { |
| 664 | 685 | })); |
| 665 | 686 | }), |
| 666 | 687 | ), |
| 667 | ).pipe(Effect.withSpan("desktop.backendManager.start")); | |
| 688 | ).pipe(Effect.withSpan("desktop.backendInstance.start", { attributes: { id: spec.id } })); | |
| 668 | 689 | |
| 669 | const scheduleRestart = Effect.fn("desktop.backendManager.scheduleRestart")(function* ( | |
| 690 | const scheduleRestart = Effect.fn("desktop.backendInstance.scheduleRestart")(function* ( | |
| 670 | 691 | reason: string, |
| 671 | 692 | ) { |
| 672 | 693 | const scheduled = yield* Ref.modify(state, (latest) => { |
| ... | ... | @@ -686,8 +707,8 @@ export const make = Effect.gen(function* () { |
| 686 | 707 | |
| 687 | 708 | yield* Option.match(scheduled, { |
| 688 | 709 | onNone: () => Effect.void, |
| 689 | onSome: Effect.fn("desktop.backendManager.scheduleRestartFiber")(function* (delay) { | |
| 690 | yield* logBackendManagerError("backend exited unexpectedly; restart scheduled", { | |
| 710 | onSome: Effect.fn("desktop.backendInstance.scheduleRestartFiber")(function* (delay) { | |
| 711 | yield* logInstanceError("backend exited unexpectedly; restart scheduled", { | |
| 691 | 712 | reason, |
| 692 | 713 | delayMs: Duration.toMillis(delay), |
| 693 | 714 | }); |
| ... | ... | @@ -706,17 +727,11 @@ export const make = Effect.gen(function* () { |
| 706 | 727 | }), |
| 707 | 728 | ), |
| 708 | 729 | Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), |
| 709 | Effect.catchCause((cause) => { | |
| 710 | if (Cause.hasInterruptsOnly(cause)) { | |
| 711 | return Effect.void; | |
| 712 | } | |
| 713 | const error = new DesktopBackendRestartError({ | |
| 714 | reason, | |
| 715 | delayMs: Duration.toMillis(delay), | |
| 716 | cause, | |
| 717 | }); | |
| 718 | return logBackendManagerError(error.message, { error }); | |
| 719 | }), | |
| 730 | Effect.catchCause((cause) => | |
| 731 | logInstanceError("desktop backend restart fiber failed", { | |
| 732 | cause: Cause.pretty(cause), | |
| 733 | }), | |
| 734 | ), | |
| 720 | 735 | ), |
| 721 | 736 | parentScope, |
| 722 | 737 | ); |
| ... | ... | @@ -732,7 +747,7 @@ export const make = Effect.gen(function* () { |
| 732 | 747 | }); |
| 733 | 748 | }); |
| 734 | 749 | |
| 735 | const stop = Effect.fn("desktop.backendManager.stop")(function* (options?: { | |
| 750 | const stop = Effect.fn("desktop.backendInstance.stop")(function* (options?: { | |
| 736 | 751 | readonly timeout?: Duration.Duration; |
| 737 | 752 | }) { |
| 738 | 753 | const { active, restartFiber } = yield* mutex.withPermits(1)( |
| ... | ... | @@ -750,7 +765,15 @@ export const make = Effect.gen(function* () { |
| 750 | 765 | restartFiber: Option.none<Fiber.Fiber<void, never>>(), |
| 751 | 766 | }, |
| 752 | 767 | ]); |
| 753 | yield* Ref.set(desktopState.backendReady, false); | |
| 768 | // Ignore failures from spec.onShutdown so a downstream throw | |
| 769 | // can't abort the rest of stop(). Ref.modify above already | |
| 770 | // flipped state to "no active run / no restart fiber", and the | |
| 771 | // physical cleanup (Fiber.interrupt + closeRun) runs after the | |
| 772 | // mutex releases. If onShutdown were allowed to propagate, both | |
| 773 | // would be skipped and the child process + restart fiber would | |
| 774 | // be orphaned while state claimed nothing was running — the | |
| 775 | // next start() would then spawn a second backend on top. | |
| 776 | yield* (spec.onShutdown?.() ?? Effect.void).pipe(Effect.ignore); | |
| 754 | 777 | return result; |
| 755 | 778 | }), |
| 756 | 779 | ); |
| ... | ... | @@ -765,14 +788,32 @@ export const make = Effect.gen(function* () { |
| 765 | 788 | }); |
| 766 | 789 | }); |
| 767 | 790 | |
| 791 | const waitForReady = (timeout: Duration.Duration): Effect.Effect<boolean> => | |
| 792 | Effect.gen(function* () { | |
| 793 | const current = yield* Ref.get(state); | |
| 794 | // Return false early if an external `stop()` flipped desiredRunning off | |
| 795 | // — no point polling for a backend that is being torn down. | |
| 796 | if (!current.desiredRunning) return { done: true, ready: false }; | |
| 797 | return current.ready ? { done: true, ready: true } : { done: false, ready: false }; | |
| 798 | }).pipe( | |
| 799 | Effect.repeat({ | |
| 800 | until: (status) => status.done, | |
| 801 | schedule: Schedule.spaced(Duration.millis(100)), | |
| 802 | }), | |
| 803 | Effect.map((status) => status.ready), | |
| 804 | Effect.timeoutOption(timeout), | |
| 805 | Effect.map(Option.getOrElse(() => false)), | |
| 806 | ); | |
| 807 | ||
| 768 | 808 | yield* Effect.addFinalizer(() => stop()); |
| 769 | 809 | |
| 770 | return DesktopBackendManager.of({ | |
| 810 | return { | |
| 811 | id: spec.id, | |
| 812 | label: spec.label, | |
| 771 | 813 | start, |
| 772 | 814 | stop, |
| 773 | 815 | currentConfig, |
| 774 | 816 | snapshot, |
| 775 | }); | |
| 817 | waitForReady, | |
| 818 | } satisfies DesktopBackendInstance; | |
| 776 | 819 | }); |
| 777 | ||
| 778 | export const layer = Layer.effect(DesktopBackendManager, make); |
apps/desktop/src/backend/DesktopBackendPool.test.ts created+137| ... | ... | @@ -0,0 +1,137 @@ |
| 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 FileSystem from "effect/FileSystem"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as Ref from "effect/Ref"; | |
| 8 | import { HttpClient } from "effect/unstable/http"; | |
| 9 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 10 | ||
| 11 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | |
| 12 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | |
| 13 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; | |
| 14 | import * as DesktopWindow from "../window/DesktopWindow.ts"; | |
| 15 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | |
| 16 | import * as DesktopBackendPool from "./DesktopBackendPool.ts"; | |
| 17 | import type { DesktopBackendSnapshot, DesktopBackendStartConfig } from "./DesktopBackendManager.ts"; | |
| 18 | ||
| 19 | function makeStubInstance( | |
| 20 | id: DesktopBackendPool.BackendInstanceId, | |
| 21 | label: string, | |
| 22 | ): DesktopBackendPool.DesktopBackendInstance { | |
| 23 | const snapshot: DesktopBackendSnapshot = { | |
| 24 | desiredRunning: false, | |
| 25 | ready: false, | |
| 26 | activePid: Option.none(), | |
| 27 | restartAttempt: 0, | |
| 28 | restartScheduled: false, | |
| 29 | }; | |
| 30 | return { | |
| 31 | id, | |
| 32 | label: Effect.succeed(label), | |
| 33 | start: Effect.void, | |
| 34 | stop: () => Effect.void, | |
| 35 | currentConfig: Effect.succeed(Option.none<DesktopBackendStartConfig>()), | |
| 36 | snapshot: Effect.succeed(snapshot), | |
| 37 | waitForReady: (_timeout: Duration.Duration) => Effect.succeed(false), | |
| 38 | }; | |
| 39 | } | |
| 40 | ||
| 41 | function makePoolLayer( | |
| 42 | labelRef: Ref.Ref<string>, | |
| 43 | ): Layer.Layer<DesktopBackendPool.DesktopBackendPool> { | |
| 44 | return DesktopBackendPool.layer.pipe( | |
| 45 | Layer.provideMerge( | |
| 46 | Layer.mergeAll( | |
| 47 | FileSystem.layerNoop({}), | |
| 48 | Layer.succeed( | |
| 49 | ChildProcessSpawner.ChildProcessSpawner, | |
| 50 | ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")), | |
| 51 | ), | |
| 52 | Layer.succeed( | |
| 53 | HttpClient.HttpClient, | |
| 54 | HttpClient.make(() => Effect.die("unexpected HTTP request")), | |
| 55 | ), | |
| 56 | Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { | |
| 57 | forInstance: () => | |
| 58 | Effect.succeed({ | |
| 59 | writeSessionBoundary: () => Effect.void, | |
| 60 | writeOutputChunk: () => Effect.void, | |
| 61 | } satisfies DesktopObservability.DesktopBackendOutputLogShape), | |
| 62 | } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]), | |
| 63 | Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { | |
| 64 | resolvePrimary: Effect.die("unexpected primary config resolve"), | |
| 65 | resolvePrimaryLabel: Ref.get(labelRef), | |
| 66 | resolveWsl: () => Effect.die("unexpected WSL config resolve"), | |
| 67 | } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), | |
| 68 | DesktopAppSettings.layerTest(), | |
| 69 | ElectronDialog.layer, | |
| 70 | Layer.succeed(DesktopWindow.DesktopWindow, { | |
| 71 | createMain: Effect.die("unexpected window create"), | |
| 72 | ensureMain: Effect.die("unexpected window ensure"), | |
| 73 | revealOrCreateMain: Effect.die("unexpected window reveal"), | |
| 74 | activate: Effect.die("unexpected window activate"), | |
| 75 | createMainIfBackendReady: Effect.die("unexpected window create"), | |
| 76 | showConnectingSplash: Effect.void, | |
| 77 | handleBackendReady: () => Effect.void, | |
| 78 | handleBackendNotReady: Effect.void, | |
| 79 | dispatchMenuAction: () => Effect.die("unexpected menu action"), | |
| 80 | syncAppearance: Effect.void, | |
| 81 | } satisfies DesktopWindow.DesktopWindow["Service"]), | |
| 82 | ), | |
| 83 | ), | |
| 84 | ); | |
| 85 | } | |
| 86 | ||
| 87 | describe("DesktopBackendPool", () => { | |
| 88 | it.effect("layerTest exposes registered instances by id", () => | |
| 89 | Effect.gen(function* () { | |
| 90 | const pool = yield* DesktopBackendPool.DesktopBackendPool; | |
| 91 | const fetchedPrimary = yield* pool.get(DesktopBackendPool.PRIMARY_INSTANCE_ID); | |
| 92 | const fetchedWsl = yield* pool.get(DesktopBackendPool.BackendInstanceId("wsl:ubuntu")); | |
| 93 | const fetchedMissing = yield* pool.get(DesktopBackendPool.BackendInstanceId("missing")); | |
| 94 | const all = yield* pool.list; | |
| 95 | const resolvedPrimary = yield* pool.primary; | |
| 96 | ||
| 97 | assert.equal(yield* Option.getOrThrow(fetchedPrimary).label, "Windows"); | |
| 98 | assert.equal(yield* Option.getOrThrow(fetchedWsl).label, "WSL (Ubuntu)"); | |
| 99 | assert.isTrue(Option.isNone(fetchedMissing)); | |
| 100 | assert.lengthOf(all, 2); | |
| 101 | // First instance becomes primary in layerTest so single-instance | |
| 102 | // stubs don't have to wire an explicit primary. | |
| 103 | assert.equal(resolvedPrimary.id, DesktopBackendPool.PRIMARY_INSTANCE_ID); | |
| 104 | }).pipe( | |
| 105 | Effect.provide( | |
| 106 | DesktopBackendPool.layerTest([ | |
| 107 | makeStubInstance(DesktopBackendPool.PRIMARY_INSTANCE_ID, "Windows"), | |
| 108 | makeStubInstance(DesktopBackendPool.BackendInstanceId("wsl:ubuntu"), "WSL (Ubuntu)"), | |
| 109 | ]), | |
| 110 | ), | |
| 111 | ), | |
| 112 | ); | |
| 113 | ||
| 114 | it.effect("layerTest dies when no instances are supplied", () => | |
| 115 | Effect.exit( | |
| 116 | Effect.gen(function* () { | |
| 117 | yield* DesktopBackendPool.DesktopBackendPool; | |
| 118 | }).pipe(Effect.provide(DesktopBackendPool.layerTest([]))), | |
| 119 | ).pipe(Effect.map((exit) => assert.equal(exit._tag, "Failure"))), | |
| 120 | ); | |
| 121 | ||
| 122 | it.effect("resolves the primary label lazily after pool layer construction", () => | |
| 123 | Effect.scoped( | |
| 124 | Effect.gen(function* () { | |
| 125 | const labelRef = yield* Ref.make("Windows"); | |
| 126 | const pool = yield* DesktopBackendPool.DesktopBackendPool.pipe( | |
| 127 | Effect.provide(makePoolLayer(labelRef)), | |
| 128 | ); | |
| 129 | const primary = yield* pool.primary; | |
| 130 | ||
| 131 | yield* Ref.set(labelRef, "WSL (Ubuntu)"); | |
| 132 | ||
| 133 | assert.equal(yield* primary.label, "WSL (Ubuntu)"); | |
| 134 | }), | |
| 135 | ), | |
| 136 | ); | |
| 137 | }); |
apps/desktop/src/backend/DesktopBackendPool.ts created+470| ... | ... | @@ -0,0 +1,470 @@ |
| 1 | // Pool registry for multiple backend processes. This file is the entry | |
| 2 | // point for the concurrent-Windows+WSL-backend feature; see the design | |
| 3 | // notes below before extending it. | |
| 4 | // | |
| 5 | // Current state: | |
| 6 | // - `DesktopBackendManager.ts` exposes a per-instance factory | |
| 7 | // (`makeBackendInstance(spec)`); the pool calls it once for the | |
| 8 | // Windows primary at startup, and `DesktopWslBackend.reconcile` | |
| 9 | // calls it through `pool.register` to bring up the WSL instance | |
| 10 | // when the user enables it. | |
| 11 | // - The primary spec wires `configResolve` to | |
| 12 | // `DesktopBackendConfiguration.resolvePrimary` and the | |
| 13 | // `onReady`/`onShutdown` callbacks to the window service. WSL | |
| 14 | // instances wire `configResolve: configuration.resolveWsl(...)` | |
| 15 | // and skip onReady/onShutdown — the window only follows the primary. | |
| 16 | // - The pool exposes `register(spec)` and `unregister(id)`. Each | |
| 17 | // registered instance gets its own child scope, so unregister can | |
| 18 | // stop it cleanly without tearing down the pool. The primary's id | |
| 19 | // refuses unregister. | |
| 20 | // - Settings: `wslBackendEnabled: boolean` + `wslDistro: string | null`. | |
| 21 | // The legacy `wslMode: "local" | "wsl"` swap setting is migrated on | |
| 22 | // load. IPC surface is `setWslBackendEnabled(boolean)` + | |
| 23 | // `setWslDistro(string | null)`; both persist and then call the | |
| 24 | // orchestrator's reconcile. No swap, no rollback, primary stays up. | |
| 25 | // - `getLocalEnvironmentBootstraps()` (plural) returns one entry per | |
| 26 | // pool instance currently registered with bootstrap info. The | |
| 27 | // primary keeps the "primary" id; WSL instances are "wsl:default" | |
| 28 | // or "wsl:<distro>". | |
| 29 | // - `pickFolder` accepts an optional `targetEnvironmentId`. Omitting | |
| 30 | // it gives the Windows picker — what every existing caller gets, | |
| 31 | // and what non-WSL users see. WSL targets route to the wsl helpers. | |
| 32 | // - Web settings UX: a plain toggle for "WSL backend" plus a distro | |
| 33 | // picker that shows up when the toggle is on. Default-off, so | |
| 34 | // users who never opted in see the same surface as before. | |
| 35 | // | |
| 36 | // Renderer-side wiring (apps/web/src/environments/local/): | |
| 37 | // - reconcileLocalSecondaryEnvironments() runs at app boot and after | |
| 38 | // WSL settings changes. It reads getLocalEnvironmentBootstraps(), | |
| 39 | // skips the primary (which the existing primary/ runtime owns), | |
| 40 | // and for every other entry POSTs the shared bootstrap token to | |
| 41 | // /api/auth/bootstrap/bearer on that backend's URL, fetches the | |
| 42 | // descriptor, builds a SavedEnvironmentRecord marked desktopLocal, | |
| 43 | // writes the bearer to the secret store, and opens a connection | |
| 44 | // through the same saved-env path remote envs use. | |
| 45 | // - The desktopLocal marker filters records out of saved-env | |
| 46 | // persistence, so toggling WSL off or switching distros doesn't | |
| 47 | // pollute the user's settings file. The sidebar, CommandPalette, | |
| 48 | // env switcher, and project-id routing all read the saved-env | |
| 49 | // registry, so the WSL backend shows up there without any | |
| 50 | // per-surface changes. | |
| 51 | // | |
| 52 | // Browser validation (2026-05-17, dev:desktop with wslBackendEnabled=true, | |
| 53 | // wslDistro="Ubuntu"): | |
| 54 | // - Two backends listening on distinct loopback ports | |
| 55 | // (server.log: 13773 primary, 13774 wsl). | |
| 56 | // - Per-instance log files: server-child.log + server-child-wsl_Ubuntu.log. | |
| 57 | // - Distinct environment ids reported by each backend's | |
| 58 | // /.well-known/t3/environment (Windows vs Linux platform). | |
| 59 | // - Renderer completes the bearer-token bootstrap against the WSL | |
| 60 | // backend (POST /api/auth/bootstrap/bearer 200), obtains a | |
| 61 | // ws-token (POST /api/auth/ws-token 200), and holds an | |
| 62 | // ESTABLISHED WebSocket connection to both ports (netstat). | |
| 63 | // | |
| 64 | // Migration history (commits): | |
| 65 | // 1. Reshape `DesktopBackendManager` into an instance factory and route | |
| 66 | // consumers through the pool. Pool held a single instance. (a8fc7845) | |
| 67 | // 2. Drop `DesktopState.backendReady`. The window owns its own | |
| 68 | // readiness latch via onReady / onShutdown callbacks. (425c7d0b) | |
| 69 | // 3. Per-instance log routing via DesktopBackendOutputLogFactory. (563820ed) | |
| 70 | // 4. Add register/unregister to the pool. (a0eaf560) | |
| 71 | // 5. Wire WSL through the pool: settings rename, BackendConfiguration | |
| 72 | // split, DesktopWslBackend orchestrator, new IPC, web compat. | |
| 73 | // (b1622191 + 31ce3add + 627c80cb) | |
| 74 | // 6. Widen getLocalEnvironmentBootstrap to *Bootstraps (plural). (bad66041) | |
| 75 | // 7. pickFolder takes optional targetEnvironmentId. (5d80468d) | |
| 76 | // 8. Settings UX: toggle + distro picker, no swap dialog. (eb5a03ea) | |
| 77 | // 9. Register WSL backend as desktop-local saved env via | |
| 78 | // reconcileLocalSecondaryEnvironments. (1c7e7873 + c17897bd) | |
| 79 | // 10. CommandPalette enables file-manager picker for desktop-local | |
| 80 | // envs, routes pickFolder by env id. (38e8477a) | |
| 81 | ||
| 82 | import * as Context from "effect/Context"; | |
| 83 | import * as Deferred from "effect/Deferred"; | |
| 84 | import * as Effect from "effect/Effect"; | |
| 85 | import * as Exit from "effect/Exit"; | |
| 86 | import * as Layer from "effect/Layer"; | |
| 87 | import * as Option from "effect/Option"; | |
| 88 | import * as Schema from "effect/Schema"; | |
| 89 | import * as Scope from "effect/Scope"; | |
| 90 | import * as SynchronizedRef from "effect/SynchronizedRef"; | |
| 91 | ||
| 92 | import * as FileSystem from "effect/FileSystem"; | |
| 93 | import { HttpClient } from "effect/unstable/http"; | |
| 94 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 95 | ||
| 96 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | |
| 97 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; | |
| 98 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | |
| 99 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | |
| 100 | import * as DesktopWindow from "../window/DesktopWindow.ts"; | |
| 101 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; | |
| 102 | ||
| 103 | const { logWarning: logBackendPoolWarning } = | |
| 104 | DesktopObservability.makeComponentLogger("desktop-backend-pool"); | |
| 105 | ||
| 106 | export type BackendInstanceId = DesktopBackendManager.BackendInstanceId; | |
| 107 | export const BackendInstanceId = DesktopBackendManager.BackendInstanceId; | |
| 108 | export const PRIMARY_INSTANCE_ID = DesktopBackendManager.PRIMARY_INSTANCE_ID; | |
| 109 | export type DesktopBackendInstance = DesktopBackendManager.DesktopBackendInstance; | |
| 110 | export type BackendInstanceSpec = DesktopBackendManager.BackendInstanceSpec; | |
| 111 | ||
| 112 | // Caller tried to register an id that's already in the pool. The pool | |
| 113 | // refuses overwrites so two independent orchestrators racing on the | |
| 114 | // same id surface as a typed failure instead of one silently winning. | |
| 115 | export class DesktopBackendPoolInstanceAlreadyRegisteredError extends Schema.TaggedErrorClass<DesktopBackendPoolInstanceAlreadyRegisteredError>()( | |
| 116 | "DesktopBackendPoolInstanceAlreadyRegisteredError", | |
| 117 | { | |
| 118 | id: Schema.String, | |
| 119 | }, | |
| 120 | ) { | |
| 121 | override get message() { | |
| 122 | return `Backend instance "${this.id}" is already registered in the pool.`; | |
| 123 | } | |
| 124 | } | |
| 125 | ||
| 126 | // Primary instance is registered for the pool's lifetime. Unregister is | |
| 127 | // a no-op for it today (no real callers), but if someone wires it up | |
| 128 | // later it's a clear bug rather than something to "handle". | |
| 129 | export class DesktopBackendPoolCannotUnregisterPrimaryError extends Schema.TaggedErrorClass<DesktopBackendPoolCannotUnregisterPrimaryError>()( | |
| 130 | "DesktopBackendPoolCannotUnregisterPrimaryError", | |
| 131 | {}, | |
| 132 | ) { | |
| 133 | override get message() { | |
| 134 | return "Refusing to unregister the primary backend from the pool."; | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | export class DesktopBackendPool extends Context.Service< | |
| 139 | DesktopBackendPool, | |
| 140 | { | |
| 141 | // Look up a registered instance. None when no backend with that id is | |
| 142 | // currently registered (e.g. WSL backend disabled). | |
| 143 | readonly get: (id: BackendInstanceId) => Effect.Effect<Option.Option<DesktopBackendInstance>>; | |
| 144 | // Snapshot of all currently-registered instances. Order is unspecified; | |
| 145 | // callers that need a canonical "primary first" view should sort by id. | |
| 146 | readonly list: Effect.Effect<readonly DesktopBackendInstance[]>; | |
| 147 | // Convenience accessor for the always-registered primary instance. | |
| 148 | // Currently equivalent to `get(PRIMARY_INSTANCE_ID)` unwrapped, but | |
| 149 | // exposed as a typed effect so consumers don't have to handle the | |
| 150 | // Option for the case that's guaranteed to be present. | |
| 151 | readonly primary: Effect.Effect<DesktopBackendInstance>; | |
| 152 | // Build a fresh DesktopBackendInstance from `spec` and add it to the | |
| 153 | // registry. The pool owns the instance's scope: unregister(id) or pool | |
| 154 | // teardown closes it and runs the instance's auto-stop finalizer. The | |
| 155 | // returned instance has not been started — callers decide when to | |
| 156 | // start it (and can call start more than once if a retry-after-failure | |
| 157 | // story makes sense for them). | |
| 158 | readonly register: ( | |
| 159 | spec: BackendInstanceSpec, | |
| 160 | ) => Effect.Effect<DesktopBackendInstance, DesktopBackendPoolInstanceAlreadyRegisteredError>; | |
| 161 | // Stop the named instance and remove it from the registry. Closing the | |
| 162 | // instance's scope triggers its auto-stop finalizer; the registry is | |
| 163 | // updated atomically with the scope close so subsequent get(id) calls | |
| 164 | // observe the unregister before the underlying child process has fully | |
| 165 | // exited. | |
| 166 | readonly unregister: ( | |
| 167 | id: BackendInstanceId, | |
| 168 | ) => Effect.Effect<void, DesktopBackendPoolCannotUnregisterPrimaryError>; | |
| 169 | } | |
| 170 | >()("@t3tools/desktop/backend/DesktopBackendPool") {} | |
| 171 | ||
| 172 | // Services required by makeBackendInstance — exported so caller | |
| 173 | // orchestrators that build their own specs can confirm the layer graph | |
| 174 | // satisfies them at compile time. | |
| 175 | export type BackendInstanceFactoryRequirements = | |
| 176 | | FileSystem.FileSystem | |
| 177 | | ChildProcessSpawner.ChildProcessSpawner | |
| 178 | | HttpClient.HttpClient | |
| 179 | | DesktopObservability.DesktopBackendOutputLogFactory; | |
| 180 | ||
| 181 | interface ActiveRegisteredInstance { | |
| 182 | readonly _tag: "Active"; | |
| 183 | readonly instance: DesktopBackendInstance; | |
| 184 | // None for the primary (which lives in the pool's own layer scope and | |
| 185 | // is never unregistered); Some for instances added via register, whose | |
| 186 | // scope unregister closes to stop them. | |
| 187 | readonly scope: Option.Option<Scope.Closeable>; | |
| 188 | } | |
| 189 | ||
| 190 | interface ClosingRegisteredInstance { | |
| 191 | readonly _tag: "Closing"; | |
| 192 | readonly done: Deferred.Deferred<void>; | |
| 193 | } | |
| 194 | ||
| 195 | type RegisteredInstance = ActiveRegisteredInstance | ClosingRegisteredInstance; | |
| 196 | ||
| 197 | type RegisterAction = | |
| 198 | | { readonly _tag: "Registered"; readonly instance: DesktopBackendInstance } | |
| 199 | | { readonly _tag: "Wait"; readonly done: Deferred.Deferred<void> }; | |
| 200 | ||
| 201 | type UnregisterAction = | |
| 202 | | { readonly _tag: "Absent" } | |
| 203 | | { readonly _tag: "Wait"; readonly done: Deferred.Deferred<void> } | |
| 204 | | { readonly _tag: "Close"; readonly entry: ActiveRegisteredInstance }; | |
| 205 | ||
| 206 | export const layer = Layer.effect( | |
| 207 | DesktopBackendPool, | |
| 208 | Effect.gen(function* () { | |
| 209 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 210 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | |
| 211 | const electronDialog = yield* ElectronDialog.ElectronDialog; | |
| 212 | const appSettings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 213 | // Anchor the pool's lifetime to its layer scope so registered | |
| 214 | // instance scopes can be forked off it. Without this, instance | |
| 215 | // scopes are orphaned: they only close via explicit unregister() | |
| 216 | // calls, so on app shutdown the WSL backend child process gets | |
| 217 | // hard-killed by the OS instead of receiving the graceful | |
| 218 | // SIGTERM + grace period the instance's stop finalizer would | |
| 219 | // otherwise run. | |
| 220 | const layerScope = yield* Scope.Scope; | |
| 221 | // Capture the services needed to build any future instance from the | |
| 222 | // pool's layer scope. register() runs `makeBackendInstance` against | |
| 223 | // a fresh child scope but reuses these services so the instance gets | |
| 224 | // the same FileSystem, spawner, HTTP client and log factory the | |
| 225 | // primary instance uses. | |
| 226 | const factoryContext = yield* Effect.context<BackendInstanceFactoryRequirements>(); | |
| 227 | ||
| 228 | // A WSL preflight failure on the primary only happens in wsl-only mode. | |
| 229 | // Fatal configuration failures persist the Windows fallback. Bounded | |
| 230 | // transport failures use an in-memory fallback for this launch so the app | |
| 231 | // opens without overwriting the user's WSL preference. | |
| 232 | const handlePrimaryPreflightFailure = Effect.fn("desktop.backendPool.primaryPreflightFailed")( | |
| 233 | function* (failure: DesktopBackendManager.PreflightFailure) { | |
| 234 | const { reason, fatal } = failure; | |
| 235 | if (!fatal) { | |
| 236 | yield* logBackendPoolWarning( | |
| 237 | "primary WSL preflight retry window exhausted; using Windows for this launch", | |
| 238 | { reason }, | |
| 239 | ); | |
| 240 | yield* electronDialog.showErrorBox( | |
| 241 | "WSL backend is still unavailable", | |
| 242 | `${reason}\n\nT3 Code will use the Windows backend for this launch and retry WSL the next time the app starts.`, | |
| 243 | ); | |
| 244 | yield* appSettings.applyWslWindowsFallbackInMemory; | |
| 245 | return true; | |
| 246 | } | |
| 247 | ||
| 248 | yield* logBackendPoolWarning("primary WSL preflight failed; falling back to Windows", { | |
| 249 | reason, | |
| 250 | }); | |
| 251 | yield* electronDialog.showErrorBox( | |
| 252 | "WSL backend couldn't start", | |
| 253 | `${reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`, | |
| 254 | ); | |
| 255 | // Fully disable the WSL backend — both flags, matching the "Switch to | |
| 256 | // Windows" recovery path — so the manager's next restart re-resolves the | |
| 257 | // primary as Windows and reconcile won't register a secondary WSL backend | |
| 258 | // against the same broken setup. Clearing wslBackendEnabled alone would | |
| 259 | // leave a stale wslOnly:true that silently re-traps the user in wsl-only | |
| 260 | // mode the next time they enable WSL. If the persisted write fails, keep | |
| 261 | // this process recoverable by applying the fallback to in-memory settings. | |
| 262 | yield* appSettings.applyWslWindowsFallback.pipe( | |
| 263 | Effect.catch((error) => | |
| 264 | logBackendPoolWarning( | |
| 265 | "failed to persist Windows fallback after WSL preflight failure", | |
| 266 | { | |
| 267 | error: error.message, | |
| 268 | }, | |
| 269 | ).pipe(Effect.andThen(appSettings.applyWslWindowsFallbackInMemory)), | |
| 270 | ), | |
| 271 | ); | |
| 272 | return true; | |
| 273 | }, | |
| 274 | ); | |
| 275 | ||
| 276 | const primary = yield* DesktopBackendManager.makeBackendInstance({ | |
| 277 | id: DesktopBackendManager.PRIMARY_INSTANCE_ID, | |
| 278 | // Keep this lazy. The pool layer is initialized before startup loads | |
| 279 | // persisted desktop settings, so resolving the primary label here would | |
| 280 | // permanently capture DEFAULT_DESKTOP_SETTINGS and mislabel WSL-only | |
| 281 | // primaries as Windows. | |
| 282 | label: configuration.resolvePrimaryLabel, | |
| 283 | configResolve: configuration.resolvePrimary, | |
| 284 | // Window creation errors propagating out of handleBackendReady must | |
| 285 | // not block the readiness callback (that would prevent restartAttempt | |
| 286 | // from being reset), so we absorb them here. The window service only | |
| 287 | // logs on success, so log the failure here before swallowing it — | |
| 288 | // otherwise a post-readiness window-open failure vanishes silently and | |
| 289 | // is near-impossible to diagnose in production. | |
| 290 | onReady: (httpBaseUrl) => | |
| 291 | desktopWindow.handleBackendReady(httpBaseUrl).pipe( | |
| 292 | Effect.catch((error) => | |
| 293 | logBackendPoolWarning("failed to open main window after backend readiness", { | |
| 294 | error: error.message, | |
| 295 | }), | |
| 296 | ), | |
| 297 | ), | |
| 298 | onShutdown: () => desktopWindow.handleBackendNotReady, | |
| 299 | onPreflightFailed: handlePrimaryPreflightFailure, | |
| 300 | }); | |
| 301 | ||
| 302 | const instancesRef = yield* SynchronizedRef.make< | |
| 303 | ReadonlyMap<BackendInstanceId, RegisteredInstance> | |
| 304 | >( | |
| 305 | new Map([ | |
| 306 | [ | |
| 307 | DesktopBackendManager.PRIMARY_INSTANCE_ID, | |
| 308 | { _tag: "Active", instance: primary, scope: Option.none() }, | |
| 309 | ], | |
| 310 | ]), | |
| 311 | ); | |
| 312 | ||
| 313 | const register: DesktopBackendPool["Service"]["register"] = (spec) => | |
| 314 | Effect.suspend(() => | |
| 315 | SynchronizedRef.modifyEffect( | |
| 316 | instancesRef, | |
| 317 | ( | |
| 318 | current, | |
| 319 | ): Effect.Effect< | |
| 320 | readonly [RegisterAction, ReadonlyMap<BackendInstanceId, RegisteredInstance>], | |
| 321 | DesktopBackendPoolInstanceAlreadyRegisteredError | |
| 322 | > => { | |
| 323 | const existing = current.get(spec.id); | |
| 324 | if (existing?._tag === "Active") { | |
| 325 | return Effect.fail( | |
| 326 | new DesktopBackendPoolInstanceAlreadyRegisteredError({ id: spec.id }), | |
| 327 | ); | |
| 328 | } | |
| 329 | if (existing?._tag === "Closing") { | |
| 330 | return Effect.succeed([ | |
| 331 | { _tag: "Wait", done: existing.done } as const, | |
| 332 | current, | |
| 333 | ] as const); | |
| 334 | } | |
| 335 | return Effect.gen(function* () { | |
| 336 | // Provide the captured factory services first, then the child scope | |
| 337 | // last so instance finalizers are owned by the unregisterable scope. | |
| 338 | const instanceScope = yield* Scope.fork(layerScope, "sequential"); | |
| 339 | const instance = yield* DesktopBackendManager.makeBackendInstance(spec).pipe( | |
| 340 | Effect.provide(factoryContext), | |
| 341 | Scope.provide(instanceScope), | |
| 342 | ); | |
| 343 | const next = new Map(current); | |
| 344 | next.set(spec.id, { | |
| 345 | _tag: "Active", | |
| 346 | instance, | |
| 347 | scope: Option.some(instanceScope), | |
| 348 | }); | |
| 349 | return [ | |
| 350 | { _tag: "Registered", instance } as const, | |
| 351 | next as ReadonlyMap<BackendInstanceId, RegisteredInstance>, | |
| 352 | ] as const; | |
| 353 | }); | |
| 354 | }, | |
| 355 | ).pipe( | |
| 356 | Effect.flatMap((result) => | |
| 357 | result._tag === "Registered" | |
| 358 | ? Effect.succeed(result.instance) | |
| 359 | : Deferred.await(result.done).pipe(Effect.andThen(register(spec))), | |
| 360 | ), | |
| 361 | ), | |
| 362 | ); | |
| 363 | ||
| 364 | const unregister: DesktopBackendPool["Service"]["unregister"] = (id) => | |
| 365 | Effect.gen(function* () { | |
| 366 | if (id === DesktopBackendManager.PRIMARY_INSTANCE_ID) { | |
| 367 | return yield* new DesktopBackendPoolCannotUnregisterPrimaryError(); | |
| 368 | } | |
| 369 | const done = yield* Deferred.make<void>(); | |
| 370 | const action = yield* SynchronizedRef.modifyEffect( | |
| 371 | instancesRef, | |
| 372 | ( | |
| 373 | current, | |
| 374 | ): Effect.Effect< | |
| 375 | readonly [UnregisterAction, ReadonlyMap<BackendInstanceId, RegisteredInstance>] | |
| 376 | > => { | |
| 377 | const entry = current.get(id); | |
| 378 | if (entry === undefined) { | |
| 379 | return Effect.succeed([{ _tag: "Absent" } as const, current] as const); | |
| 380 | } | |
| 381 | if (entry._tag === "Closing") { | |
| 382 | return Effect.succeed([ | |
| 383 | { _tag: "Wait", done: entry.done } as const, | |
| 384 | current, | |
| 385 | ] as const); | |
| 386 | } | |
| 387 | const next = new Map(current); | |
| 388 | next.set(id, { _tag: "Closing", done }); | |
| 389 | return Effect.succeed([ | |
| 390 | { _tag: "Close", entry } as const, | |
| 391 | next as ReadonlyMap<BackendInstanceId, RegisteredInstance>, | |
| 392 | ] as const); | |
| 393 | }, | |
| 394 | ); | |
| 395 | ||
| 396 | if (action._tag === "Absent") return; | |
| 397 | if (action._tag === "Wait") { | |
| 398 | yield* Deferred.await(action.done); | |
| 399 | return; | |
| 400 | } | |
| 401 | ||
| 402 | const finish = SynchronizedRef.modifyEffect(instancesRef, (current) => { | |
| 403 | const closing = current.get(id); | |
| 404 | if (closing?._tag !== "Closing" || closing.done !== done) { | |
| 405 | return Effect.succeed([undefined, current] as const); | |
| 406 | } | |
| 407 | const next = new Map(current); | |
| 408 | next.delete(id); | |
| 409 | return Effect.succeed([ | |
| 410 | undefined, | |
| 411 | next as ReadonlyMap<BackendInstanceId, RegisteredInstance>, | |
| 412 | ] as const); | |
| 413 | }).pipe(Effect.andThen(Deferred.succeed(done, undefined)), Effect.asVoid); | |
| 414 | yield* Option.match(action.entry.scope, { | |
| 415 | onNone: () => Effect.void, | |
| 416 | onSome: (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore), | |
| 417 | }).pipe(Effect.ensuring(finish)); | |
| 418 | }); | |
| 419 | ||
| 420 | return DesktopBackendPool.of({ | |
| 421 | get: (id) => | |
| 422 | SynchronizedRef.get(instancesRef).pipe( | |
| 423 | Effect.map((instances) => { | |
| 424 | const entry = instances.get(id); | |
| 425 | return entry?._tag === "Active" ? Option.some(entry.instance) : Option.none(); | |
| 426 | }), | |
| 427 | ), | |
| 428 | list: SynchronizedRef.get(instancesRef).pipe( | |
| 429 | Effect.map((instances) => | |
| 430 | Array.from(instances.values()).flatMap((entry) => | |
| 431 | entry._tag === "Active" ? [entry.instance] : [], | |
| 432 | ), | |
| 433 | ), | |
| 434 | ), | |
| 435 | primary: Effect.succeed(primary), | |
| 436 | register, | |
| 437 | unregister, | |
| 438 | }); | |
| 439 | }), | |
| 440 | ); | |
| 441 | ||
| 442 | // Test layer for unit tests that want to assert against a known pool | |
| 443 | // composition without standing up the full manager. Each provided | |
| 444 | // instance is registered under its own id; the first one is also | |
| 445 | // surfaced as `primary` so callers can stub a single-instance pool. | |
| 446 | // `register` and `unregister` are stubbed to die so tests that | |
| 447 | // accidentally exercise pool registration fail loudly instead of | |
| 448 | // silently noop'ing. | |
| 449 | export const layerTest = ( | |
| 450 | instances: readonly DesktopBackendInstance[], | |
| 451 | ): Layer.Layer<DesktopBackendPool> => | |
| 452 | Layer.effect( | |
| 453 | DesktopBackendPool, | |
| 454 | Effect.gen(function* () { | |
| 455 | if (instances.length === 0) { | |
| 456 | return yield* Effect.die("DesktopBackendPool.layerTest requires at least one instance"); | |
| 457 | } | |
| 458 | const byId = new Map<BackendInstanceId, DesktopBackendInstance>( | |
| 459 | instances.map((instance) => [instance.id, instance] as const), | |
| 460 | ); | |
| 461 | const primary = instances[0]!; | |
| 462 | return DesktopBackendPool.of({ | |
| 463 | get: (id) => Effect.succeed(Option.fromNullishOr(byId.get(id))), | |
| 464 | list: Effect.succeed(Array.from(byId.values())), | |
| 465 | primary: Effect.succeed(primary), | |
| 466 | register: () => Effect.die("DesktopBackendPool.layerTest does not support register"), | |
| 467 | unregister: () => Effect.die("DesktopBackendPool.layerTest does not support unregister"), | |
| 468 | }); | |
| 469 | }), | |
| 470 | ); |
apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts+13-15| ... | ... | @@ -5,11 +5,12 @@ import * as Option from "effect/Option"; |
| 5 | 5 | import * as Ref from "effect/Ref"; |
| 6 | 6 | import * as HttpClient from "effect/unstable/http/HttpClient"; |
| 7 | 7 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; |
| 8 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | |
| 8 | 9 | |
| 9 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; | |
| 10 | import * as DesktopBackendPool from "./DesktopBackendPool.ts"; | |
| 10 | 11 | import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts"; |
| 11 | 12 | |
| 12 | const config: DesktopBackendManager.DesktopBackendStartConfig = { | |
| 13 | const config = { | |
| 13 | 14 | executablePath: "/electron", |
| 14 | 15 | entryPath: "/server/bin.mjs", |
| 15 | 16 | cwd: "/server", |
| ... | ... | @@ -54,20 +55,17 @@ describe("DesktopLocalEnvironmentAuth", () => { |
| 54 | 55 | ), |
| 55 | 56 | ), |
| 56 | 57 | ); |
| 57 | const managerLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { | |
| 58 | start: Effect.void, | |
| 59 | stop: () => Effect.void, | |
| 60 | currentConfig: Effect.succeed(Option.some(config)), | |
| 61 | snapshot: Effect.succeed({ | |
| 62 | desiredRunning: true, | |
| 63 | ready: true, | |
| 64 | activePid: Option.none(), | |
| 65 | restartAttempt: 0, | |
| 66 | restartScheduled: false, | |
| 67 | }), | |
| 68 | }); | |
| 58 | const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { | |
| 59 | list: Effect.succeed([ | |
| 60 | { | |
| 61 | id: PRIMARY_LOCAL_ENVIRONMENT_ID, | |
| 62 | label: Effect.succeed("Windows"), | |
| 63 | currentConfig: Effect.succeed(Option.some(config)), | |
| 64 | }, | |
| 65 | ]), | |
| 66 | } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); | |
| 69 | 67 | const testLayer = DesktopLocalEnvironmentAuth.layer.pipe( |
| 70 | Layer.provide(Layer.mergeAll(managerLayer, httpClientLayer)), | |
| 68 | Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer)), | |
| 71 | 69 | ); |
| 72 | 70 | |
| 73 | 71 | const [first, second] = yield* Effect.gen(function* () { |
apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts+11-4| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; |
| 2 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | |
| 2 | 3 | import * as Context from "effect/Context"; |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -8,7 +9,7 @@ import * as Schema from "effect/Schema"; |
| 8 | 9 | import * as Semaphore from "effect/Semaphore"; |
| 9 | 10 | import * as HttpClient from "effect/unstable/http/HttpClient"; |
| 10 | 11 | |
| 11 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; | |
| 12 | import * as DesktopBackendPool from "./DesktopBackendPool.ts"; | |
| 12 | 13 | |
| 13 | 14 | export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedErrorClass<DesktopLocalEnvironmentAuthBackendNotConfiguredError>()( |
| 14 | 15 | "DesktopLocalEnvironmentAuthBackendNotConfiguredError", |
| ... | ... | @@ -42,7 +43,7 @@ export class DesktopLocalEnvironmentAuth extends Context.Service< |
| 42 | 43 | >()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {} |
| 43 | 44 | |
| 44 | 45 | export const make = Effect.gen(function* () { |
| 45 | const backendManager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 46 | const pool = yield* DesktopBackendPool.DesktopBackendPool; | |
| 46 | 47 | const httpClient = yield* HttpClient.HttpClient; |
| 47 | 48 | const tokenRef = yield* Ref.make(Option.none<string>()); |
| 48 | 49 | const mutex = yield* Semaphore.make(1); |
| ... | ... | @@ -55,14 +56,20 @@ export const make = Effect.gen(function* () { |
| 55 | 56 | return cached.value; |
| 56 | 57 | } |
| 57 | 58 | |
| 58 | const configOption = yield* backendManager.currentConfig; | |
| 59 | const instances = yield* pool.list; | |
| 60 | const primary = instances.find((instance) => instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID); | |
| 61 | const configOption = primary === undefined ? Option.none() : yield* primary.currentConfig; | |
| 59 | 62 | if (Option.isNone(configOption)) { |
| 60 | 63 | return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); |
| 61 | 64 | } |
| 62 | 65 | const config = configOption.value; |
| 66 | const credential = config.bootstrap.desktopBootstrapToken; | |
| 67 | if (!credential) { | |
| 68 | return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); | |
| 69 | } | |
| 63 | 70 | const session = yield* bootstrapRemoteBearerSession({ |
| 64 | 71 | httpBaseUrl: config.httpBaseUrl.href, |
| 65 | credential: config.bootstrap.desktopBootstrapToken, | |
| 72 | credential, | |
| 66 | 73 | clientMetadata: { |
| 67 | 74 | label: "T3 Code Desktop", |
| 68 | 75 | deviceType: "desktop", |
apps/desktop/src/backend/DesktopServerExposure.test.ts+5| ... | ... | @@ -253,6 +253,11 @@ describe("DesktopServerExposure", () => { |
| 253 | 253 | setServerExposureMode: () => Effect.fail(settingsFailure), |
| 254 | 254 | setTailscaleServe: () => Effect.fail(settingsFailure), |
| 255 | 255 | setUpdateChannel: () => Effect.die("unexpected update channel change"), |
| 256 | setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), | |
| 257 | setWslDistro: () => Effect.die("unexpected WSL distro change"), | |
| 258 | setWslOnly: () => Effect.die("unexpected WSL-only toggle"), | |
| 259 | applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), | |
| 260 | applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), | |
| 256 | 261 | } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); |
| 257 | 262 | |
| 258 | 263 | return withHarness( |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+8-2| ... | ... | @@ -33,21 +33,22 @@ import { |
| 33 | 33 | import { |
| 34 | 34 | confirm, |
| 35 | 35 | getAppBranding, |
| 36 | getLocalEnvironmentBootstraps, | |
| 36 | 37 | getLocalEnvironmentBearerToken, |
| 37 | getLocalEnvironmentBootstrap, | |
| 38 | 38 | openExternal, |
| 39 | 39 | pickFolder, |
| 40 | 40 | setTheme, |
| 41 | 41 | showContextMenu, |
| 42 | 42 | } from "./methods/window.ts"; |
| 43 | 43 | import * as PreviewIpc from "./methods/preview.ts"; |
| 44 | import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; | |
| 44 | 45 | |
| 45 | 46 | export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { |
| 46 | 47 | const ipc = yield* DesktopIpc.DesktopIpc; |
| 47 | 48 | yield* PreviewIpc.installPreviewEventForwarding(); |
| 48 | 49 | |
| 49 | 50 | yield* ipc.handleSync(getAppBranding); |
| 50 | yield* ipc.handleSync(getLocalEnvironmentBootstrap); | |
| 51 | yield* ipc.handleSync(getLocalEnvironmentBootstraps); | |
| 51 | 52 | yield* ipc.handle(getLocalEnvironmentBearerToken); |
| 52 | 53 | |
| 53 | 54 | yield* ipc.handle(getClientSettings); |
| ... | ... | @@ -70,6 +71,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 70 | 71 | yield* ipc.handle(setTailscaleServeEnabled); |
| 71 | 72 | yield* ipc.handle(getAdvertisedEndpoints); |
| 72 | 73 | |
| 74 | yield* ipc.handle(getWslState); | |
| 75 | yield* ipc.handle(setWslBackendEnabled); | |
| 76 | yield* ipc.handle(setWslDistro); | |
| 77 | yield* ipc.handle(setWslOnly); | |
| 78 | ||
| 73 | 79 | yield* ipc.handle(pickFolder); |
| 74 | 80 | yield* ipc.handle(confirm); |
| 75 | 81 | yield* ipc.handle(setTheme); |
apps/desktop/src/ipc/channels.ts+5-1| ... | ... | @@ -11,7 +11,7 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 11 | 11 | export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 12 | 12 | export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 13 | 13 | export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; |
| 14 | export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; | |
| 14 | export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; | |
| 15 | 15 | export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = |
| 16 | 16 | "desktop:get-local-environment-bearer-token"; |
| 17 | 17 | export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; |
| ... | ... | @@ -32,6 +32,10 @@ export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-st |
| 32 | 32 | export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; |
| 33 | 33 | export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; |
| 34 | 34 | export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; |
| 35 | export const GET_WSL_STATE_CHANNEL = "desktop:get-wsl-state"; | |
| 36 | export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled"; | |
| 37 | export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro"; | |
| 38 | export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only"; | |
| 35 | 39 | export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; |
| 36 | 40 | export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; |
| 37 | 41 | export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; |
apps/desktop/src/ipc/methods/window.test.ts created+128| ... | ... | @@ -0,0 +1,128 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Option from "effect/Option"; | |
| 4 | ||
| 5 | import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; | |
| 6 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; | |
| 7 | import { getLocalEnvironmentBootstraps } from "./window.ts"; | |
| 8 | ||
| 9 | const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { | |
| 10 | executablePath: "wsl.exe", | |
| 11 | args: ["-d", "Ubuntu", "--", "node", "/app/bin.mjs"], | |
| 12 | entryPath: "/app/bin.mjs", | |
| 13 | cwd: "/app", | |
| 14 | env: {}, | |
| 15 | extendEnv: false, | |
| 16 | bootstrap: { | |
| 17 | mode: "desktop", | |
| 18 | noBrowser: true, | |
| 19 | port: 3774, | |
| 20 | host: "0.0.0.0", | |
| 21 | desktopBootstrapToken: "bootstrap-token", | |
| 22 | tailscaleServeEnabled: false, | |
| 23 | tailscaleServePort: 443, | |
| 24 | }, | |
| 25 | bootstrapDelivery: "stdin", | |
| 26 | httpBaseUrl: new URL("http://127.0.0.1:3774"), | |
| 27 | captureOutput: true, | |
| 28 | preflightFailure: Option.none(), | |
| 29 | runningDistro: "Ubuntu", | |
| 30 | }; | |
| 31 | ||
| 32 | const defaultWslInstance: DesktopBackendManager.DesktopBackendInstance = { | |
| 33 | id: DesktopBackendManager.BackendInstanceId("wsl:default"), | |
| 34 | label: Effect.succeed("WSL (default distro)"), | |
| 35 | start: Effect.void, | |
| 36 | stop: () => Effect.void, | |
| 37 | currentConfig: Effect.succeed(Option.some(readyWslConfig)), | |
| 38 | snapshot: Effect.succeed({ | |
| 39 | desiredRunning: true, | |
| 40 | ready: true, | |
| 41 | activePid: Option.some(123), | |
| 42 | restartAttempt: 0, | |
| 43 | restartScheduled: false, | |
| 44 | }), | |
| 45 | waitForReady: () => Effect.succeed(true), | |
| 46 | }; | |
| 47 | ||
| 48 | describe("getLocalEnvironmentBootstraps", () => { | |
| 49 | it.effect("publishes the concrete running distro without replacing the stable instance id", () => | |
| 50 | Effect.gen(function* () { | |
| 51 | const result = yield* getLocalEnvironmentBootstraps.handler(); | |
| 52 | ||
| 53 | assert.deepEqual(result, [ | |
| 54 | { | |
| 55 | id: "wsl:default", | |
| 56 | label: "WSL (Ubuntu)", | |
| 57 | runningDistro: "Ubuntu", | |
| 58 | httpBaseUrl: "http://127.0.0.1:3774/", | |
| 59 | wsBaseUrl: "ws://127.0.0.1:3774/", | |
| 60 | bootstrapToken: "bootstrap-token", | |
| 61 | }, | |
| 62 | ]); | |
| 63 | }).pipe(Effect.provide(DesktopBackendPool.layerTest([defaultWslInstance]))), | |
| 64 | ); | |
| 65 | ||
| 66 | it.effect("publishes a pending bootstrap only while a transient retry is scheduled", () => { | |
| 67 | const retryingConfig: DesktopBackendManager.DesktopBackendStartConfig = { | |
| 68 | ...readyWslConfig, | |
| 69 | preflightFailure: Option.some({ | |
| 70 | reason: "WSL probe timed out", | |
| 71 | fatal: false, | |
| 72 | retryLimit: 12, | |
| 73 | }), | |
| 74 | }; | |
| 75 | const retryingInstance: DesktopBackendManager.DesktopBackendInstance = { | |
| 76 | ...defaultWslInstance, | |
| 77 | currentConfig: Effect.succeed(Option.some(retryingConfig)), | |
| 78 | snapshot: Effect.succeed({ | |
| 79 | desiredRunning: true, | |
| 80 | ready: false, | |
| 81 | activePid: Option.none(), | |
| 82 | restartAttempt: 2, | |
| 83 | restartScheduled: true, | |
| 84 | }), | |
| 85 | }; | |
| 86 | ||
| 87 | return Effect.gen(function* () { | |
| 88 | const result = yield* getLocalEnvironmentBootstraps.handler(); | |
| 89 | assert.deepEqual(result, [ | |
| 90 | { | |
| 91 | id: "wsl:default", | |
| 92 | label: "WSL (default distro)", | |
| 93 | runningDistro: null, | |
| 94 | httpBaseUrl: null, | |
| 95 | wsBaseUrl: null, | |
| 96 | }, | |
| 97 | ]); | |
| 98 | }).pipe(Effect.provide(DesktopBackendPool.layerTest([retryingInstance]))); | |
| 99 | }); | |
| 100 | ||
| 101 | it.effect("omits a bounded transient bootstrap after retries stop", () => { | |
| 102 | const stoppedInstance: DesktopBackendManager.DesktopBackendInstance = { | |
| 103 | ...defaultWslInstance, | |
| 104 | currentConfig: Effect.succeed( | |
| 105 | Option.some({ | |
| 106 | ...readyWslConfig, | |
| 107 | preflightFailure: Option.some({ | |
| 108 | reason: "WSL probe timed out", | |
| 109 | fatal: false, | |
| 110 | retryLimit: 12, | |
| 111 | }), | |
| 112 | }), | |
| 113 | ), | |
| 114 | snapshot: Effect.succeed({ | |
| 115 | desiredRunning: false, | |
| 116 | ready: false, | |
| 117 | activePid: Option.none(), | |
| 118 | restartAttempt: 12, | |
| 119 | restartScheduled: false, | |
| 120 | }), | |
| 121 | }; | |
| 122 | ||
| 123 | return Effect.gen(function* () { | |
| 124 | const result = yield* getLocalEnvironmentBootstraps.handler(); | |
| 125 | assert.deepEqual(result, []); | |
| 126 | }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance]))); | |
| 127 | }); | |
| 128 | }); |
apps/desktop/src/ipc/methods/window.ts+129-15| ... | ... | @@ -4,14 +4,19 @@ import { |
| 4 | 4 | DesktopEnvironmentBootstrapSchema, |
| 5 | 5 | DesktopThemeSchema, |
| 6 | 6 | PickFolderOptionsSchema, |
| 7 | PRIMARY_LOCAL_ENVIRONMENT_ID, | |
| 8 | type DesktopEnvironmentBootstrap, | |
| 7 | 9 | } from "@t3tools/contracts"; |
| 8 | 10 | import * as Effect from "effect/Effect"; |
| 9 | 11 | import * as Option from "effect/Option"; |
| 10 | 12 | import * as Schema from "effect/Schema"; |
| 11 | 13 | |
| 12 | import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; | |
| 14 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; | |
| 13 | 15 | import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; |
| 14 | 16 | import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; |
| 17 | import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; | |
| 18 | import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; | |
| 19 | import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; | |
| 15 | 20 | import * as ElectronDialog from "../../electron/ElectronDialog.ts"; |
| 16 | 21 | import * as ElectronMenu from "../../electron/ElectronMenu.ts"; |
| 17 | 22 | import * as ElectronShell from "../../electron/ElectronShell.ts"; |
| ... | ... | @@ -19,6 +24,11 @@ import * as ElectronTheme from "../../electron/ElectronTheme.ts"; |
| 19 | 24 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; |
| 20 | 25 | import * as IpcChannels from "../channels.ts"; |
| 21 | 26 | import * as DesktopIpc from "../DesktopIpc.ts"; |
| 27 | import { | |
| 28 | extractDistroFromUncPath, | |
| 29 | resolveWslPickFolderDefaultPath, | |
| 30 | wslUncPathToLinuxPath, | |
| 31 | } from "../../wsl/wslPathParsing.ts"; | |
| 22 | 32 | |
| 23 | 33 | const ContextMenuPosition = Schema.Struct({ |
| 24 | 34 | x: Schema.Number, |
| ... | ... | @@ -45,26 +55,79 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ |
| 45 | 55 | }), |
| 46 | 56 | }); |
| 47 | 57 | |
| 48 | export const getLocalEnvironmentBootstrap = DesktopIpc.makeSyncIpcMethod({ | |
| 49 | channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, | |
| 50 | result: Schema.NullOr(DesktopEnvironmentBootstrapSchema), | |
| 51 | handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstrap")(function* () { | |
| 52 | const backendManager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 53 | const config = yield* backendManager.currentConfig; | |
| 54 | return Option.match(config, { | |
| 55 | onNone: () => null, | |
| 56 | onSome: ({ bootstrap, httpBaseUrl }) => ({ | |
| 57 | label: "Local environment", | |
| 58 | export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ | |
| 59 | channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, | |
| 60 | result: Schema.Array(DesktopEnvironmentBootstrapSchema), | |
| 61 | handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () { | |
| 62 | const pool = yield* DesktopBackendPool.DesktopBackendPool; | |
| 63 | const instances = yield* pool.list; | |
| 64 | const bootstraps: DesktopEnvironmentBootstrap[] = []; | |
| 65 | for (const instance of instances) { | |
| 66 | const isPrimary = instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID; | |
| 67 | const config = yield* instance.currentConfig; | |
| 68 | const snapshot = yield* instance.snapshot; | |
| 69 | // A secondary backend (e.g. a parallel WSL backend) that hasn't produced | |
| 70 | // a config yet (mid-registration, before its first start cycle) or that | |
| 71 | // is retrying a *transient* preflight failure (WSL VM still booting, a | |
| 72 | // not-yet-built linux server entry) is not listening on a port. We | |
| 73 | // surface it as a *pending* bootstrap (null endpoints, no token) so the | |
| 74 | // renderer can show a "Connecting…" indicator while it retries — null | |
| 75 | // endpoints keep the renderer from dialing the dead port, avoiding the | |
| 76 | // needless /api/auth/bootstrap/bearer error cycles a real endpoint would | |
| 77 | // trigger. | |
| 78 | if (Option.isNone(config) || Option.isSome(config.value.preflightFailure)) { | |
| 79 | // Skip the primary (same-origin, no "connecting" affordance) and skip a | |
| 80 | // secondary whose preflight failed *fatally* (no node, wrong version, | |
| 81 | // missing build tools): it has stopped retrying, so an indefinite | |
| 82 | // "Connecting…" would be misleading — its error is surfaced by the | |
| 83 | // WSL-state UI instead. | |
| 84 | const fatalPreflight = | |
| 85 | Option.isSome(config) && | |
| 86 | Option.isSome(config.value.preflightFailure) && | |
| 87 | config.value.preflightFailure.value.fatal; | |
| 88 | const stoppedPreflight = | |
| 89 | Option.isSome(config) && | |
| 90 | Option.isSome(config.value.preflightFailure) && | |
| 91 | (!snapshot.desiredRunning || !snapshot.restartScheduled); | |
| 92 | if (isPrimary || fatalPreflight || stoppedPreflight) continue; | |
| 93 | bootstraps.push({ | |
| 94 | id: instance.id, | |
| 95 | label: yield* instance.label, | |
| 96 | runningDistro: null, | |
| 97 | httpBaseUrl: null, | |
| 98 | wsBaseUrl: null, | |
| 99 | }); | |
| 100 | continue; | |
| 101 | } | |
| 102 | const { bootstrap, httpBaseUrl } = config.value; | |
| 103 | const runningDistro = config.value.runningDistro ?? null; | |
| 104 | bootstraps.push({ | |
| 105 | id: instance.id, | |
| 106 | label: runningDistro === null ? yield* instance.label : `WSL (${runningDistro})`, | |
| 107 | runningDistro, | |
| 58 | 108 | httpBaseUrl: httpBaseUrl.href, |
| 59 | 109 | wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), |
| 60 | 110 | ...(bootstrap.desktopBootstrapToken |
| 61 | 111 | ? { bootstrapToken: bootstrap.desktopBootstrapToken } |
| 62 | 112 | : {}), |
| 63 | }), | |
| 64 | }); | |
| 113 | }); | |
| 114 | } | |
| 115 | return bootstraps; | |
| 65 | 116 | }), |
| 66 | 117 | }); |
| 67 | 118 | |
| 119 | // Pull the distro selection out of a backend instance id like | |
| 120 | // "wsl:ubuntu". Returns null for "wsl:default", which is the sentinel | |
| 121 | // for "track the user's WSL default distro" and maps to the | |
| 122 | // wslEnv-derived default at picker time. | |
| 123 | function extractWslDistroFromEnvironmentId(envId: string): string | null { | |
| 124 | if (!envId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX)) { | |
| 125 | return null; | |
| 126 | } | |
| 127 | const suffix = envId.slice(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX.length); | |
| 128 | return suffix === "default" || suffix.length === 0 ? null : suffix; | |
| 129 | } | |
| 130 | ||
| 68 | 131 | export const getLocalEnvironmentBearerToken = DesktopIpc.makeIpcMethod({ |
| 69 | 132 | channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL, |
| 70 | 133 | payload: Schema.Void, |
| ... | ... | @@ -83,11 +146,62 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ |
| 83 | 146 | const dialog = yield* ElectronDialog.ElectronDialog; |
| 84 | 147 | const electronWindow = yield* ElectronWindow.ElectronWindow; |
| 85 | 148 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 149 | const appSettings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 150 | const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; | |
| 151 | // Three picker modes: | |
| 152 | // - targetEnvironmentId omitted: default to the primary picker. Keeps | |
| 153 | // the historical behavior unchanged for users who never enabled the | |
| 154 | // WSL backend, and is what unfamiliar callers should get out of the | |
| 155 | // box. | |
| 156 | // - targetEnvironmentId starts with "wsl:": route to the WSL picker | |
| 157 | // using the distro encoded in the id (or the user's selected | |
| 158 | // wslDistro when the id is the "wsl:default" sentinel). | |
| 159 | // - anything else (incl. PRIMARY_LOCAL_ENVIRONMENT_ID): primary picker. | |
| 160 | const targetId = options?.targetEnvironmentId; | |
| 161 | const wslDistroFromTarget = | |
| 162 | targetId !== undefined && targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX) | |
| 163 | ? extractWslDistroFromEnvironmentId(targetId) | |
| 164 | : null; | |
| 165 | const useWsl = | |
| 166 | targetId !== undefined && | |
| 167 | targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID && | |
| 168 | targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX); | |
| 169 | const settings = yield* appSettings.get; | |
| 170 | // Fall back to the persisted wslDistro when the id is the | |
| 171 | // "wsl:default" sentinel; the orchestrator uses the same fallback | |
| 172 | // for the actual backend. | |
| 173 | const wslDistro = useWsl ? (wslDistroFromTarget ?? settings.wslDistro) : null; | |
| 174 | const defaultPath = useWsl | |
| 175 | ? Option.fromNullishOr( | |
| 176 | resolveWslPickFolderDefaultPath( | |
| 177 | options, | |
| 178 | { distro: wslDistro }, | |
| 179 | yield* wslEnvironment.listDistros, | |
| 180 | Option.getOrNull(yield* wslEnvironment.getUserHome(wslDistro)), | |
| 181 | ), | |
| 182 | ) | |
| 183 | : environment.resolvePickFolderDefaultPath(options); | |
| 86 | 184 | const selectedPath = yield* dialog.pickFolder({ |
| 87 | 185 | owner: yield* electronWindow.focusedMainOrFirst, |
| 88 | defaultPath: environment.resolvePickFolderDefaultPath(options), | |
| 186 | defaultPath, | |
| 89 | 187 | }); |
| 90 | return Option.getOrNull(selectedPath); | |
| 188 | if (Option.isNone(selectedPath)) { | |
| 189 | return null; | |
| 190 | } | |
| 191 | if (!useWsl) { | |
| 192 | return selectedPath.value; | |
| 193 | } | |
| 194 | ||
| 195 | const linuxUncPath = wslUncPathToLinuxPath(selectedPath.value); | |
| 196 | if (linuxUncPath !== null) { | |
| 197 | return linuxUncPath; | |
| 198 | } | |
| 199 | ||
| 200 | const converted = yield* wslEnvironment.windowsToWslPath( | |
| 201 | extractDistroFromUncPath(selectedPath.value) ?? wslDistro, | |
| 202 | selectedPath.value, | |
| 203 | ); | |
| 204 | return Option.getOrElse(converted, () => selectedPath.value); | |
| 91 | 205 | }), |
| 92 | 206 | }); |
| 93 | 207 |
apps/desktop/src/ipc/methods/wsl.test.ts created+274| ... | ... | @@ -0,0 +1,274 @@ |
| 1 | import { DesktopWslStateSchema } from "@t3tools/contracts"; | |
| 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 Schema from "effect/Schema"; | |
| 7 | ||
| 8 | import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; | |
| 9 | import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; | |
| 10 | import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; | |
| 11 | import * as DesktopState from "../../app/DesktopState.ts"; | |
| 12 | import * as ElectronApp from "../../electron/ElectronApp.ts"; | |
| 13 | import * as ElectronTheme from "../../electron/ElectronTheme.ts"; | |
| 14 | import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; | |
| 15 | import * as DesktopWindow from "../../window/DesktopWindow.ts"; | |
| 16 | import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; | |
| 17 | import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; | |
| 18 | import { setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; | |
| 19 | ||
| 20 | const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema); | |
| 21 | ||
| 22 | const invokeSetWslBackendEnabled = (enabled: boolean) => | |
| 23 | setWslBackendEnabled.handler(enabled).pipe(Effect.flatMap(decodeWslState)); | |
| 24 | const invokeSetWslDistro = (distro: string | null) => | |
| 25 | setWslDistro.handler(distro).pipe(Effect.flatMap(decodeWslState)); | |
| 26 | const invokeSetWslOnly = (enabled: boolean) => | |
| 27 | setWslOnly.handler(enabled).pipe(Effect.flatMap(decodeWslState)); | |
| 28 | ||
| 29 | function makeWslBackendLayer(input: { readonly onReconcile?: Effect.Effect<void> } = {}) { | |
| 30 | return Layer.succeed( | |
| 31 | DesktopWslBackend.DesktopWslBackend, | |
| 32 | DesktopWslBackend.DesktopWslBackend.of({ | |
| 33 | reconcile: input.onReconcile ?? Effect.void, | |
| 34 | lastPreflightError: Effect.succeed(Option.none()), | |
| 35 | }), | |
| 36 | ); | |
| 37 | } | |
| 38 | ||
| 39 | function makeLifecycleLayer(relaunchReasons: Array<string>) { | |
| 40 | return Layer.succeed( | |
| 41 | DesktopLifecycle.DesktopLifecycle, | |
| 42 | DesktopLifecycle.DesktopLifecycle.of({ | |
| 43 | relaunch: (reason) => | |
| 44 | Effect.sync(() => { | |
| 45 | relaunchReasons.push(reason); | |
| 46 | }), | |
| 47 | register: Effect.void, | |
| 48 | }), | |
| 49 | ); | |
| 50 | } | |
| 51 | ||
| 52 | const unusedLifecycleRuntimeLayer = Layer.mergeAll( | |
| 53 | DesktopShutdown.layer, | |
| 54 | DesktopState.layer, | |
| 55 | Layer.succeed( | |
| 56 | DesktopEnvironment.DesktopEnvironment, | |
| 57 | DesktopEnvironment.DesktopEnvironment.of( | |
| 58 | {} as DesktopEnvironment.DesktopEnvironment["Service"], | |
| 59 | ), | |
| 60 | ), | |
| 61 | Layer.succeed( | |
| 62 | DesktopWindow.DesktopWindow, | |
| 63 | DesktopWindow.DesktopWindow.of({} as DesktopWindow.DesktopWindow["Service"]), | |
| 64 | ), | |
| 65 | Layer.succeed( | |
| 66 | ElectronApp.ElectronApp, | |
| 67 | ElectronApp.ElectronApp.of({} as ElectronApp.ElectronApp["Service"]), | |
| 68 | ), | |
| 69 | Layer.succeed( | |
| 70 | ElectronTheme.ElectronTheme, | |
| 71 | ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]), | |
| 72 | ), | |
| 73 | ); | |
| 74 | ||
| 75 | describe("WSL IPC", () => { | |
| 76 | it.effect("stages dual-backend preferences before enabling without relaunching", () => { | |
| 77 | const relaunchReasons: Array<string> = []; | |
| 78 | const layer = Layer.mergeAll( | |
| 79 | DesktopAppSettings.layerTest({ | |
| 80 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | |
| 81 | wslBackendEnabled: false, | |
| 82 | wslOnly: true, | |
| 83 | }), | |
| 84 | DesktopWslEnvironment.layerTest({ isAvailable: true }), | |
| 85 | makeWslBackendLayer(), | |
| 86 | makeLifecycleLayer(relaunchReasons), | |
| 87 | unusedLifecycleRuntimeLayer, | |
| 88 | ); | |
| 89 | ||
| 90 | return Effect.gen(function* () { | |
| 91 | yield* invokeSetWslOnly(false); | |
| 92 | yield* invokeSetWslDistro("Debian"); | |
| 93 | const state = yield* invokeSetWslBackendEnabled(true); | |
| 94 | ||
| 95 | assert.deepEqual(state, { | |
| 96 | enabled: true, | |
| 97 | distro: "Debian", | |
| 98 | available: true, | |
| 99 | wslOnly: false, | |
| 100 | distros: [], | |
| 101 | preflightError: null, | |
| 102 | }); | |
| 103 | assert.deepEqual(relaunchReasons, []); | |
| 104 | }).pipe(Effect.provide(layer)); | |
| 105 | }); | |
| 106 | ||
| 107 | it.effect("stages WSL-only preferences and relaunches only after enabling", () => { | |
| 108 | const relaunchReasons: Array<string> = []; | |
| 109 | const layer = Layer.mergeAll( | |
| 110 | DesktopAppSettings.layerTest({ | |
| 111 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | |
| 112 | wslBackendEnabled: false, | |
| 113 | wslOnly: false, | |
| 114 | }), | |
| 115 | DesktopWslEnvironment.layerTest({ isAvailable: true }), | |
| 116 | makeWslBackendLayer(), | |
| 117 | makeLifecycleLayer(relaunchReasons), | |
| 118 | unusedLifecycleRuntimeLayer, | |
| 119 | ); | |
| 120 | ||
| 121 | return Effect.gen(function* () { | |
| 122 | const stagedMode = yield* invokeSetWslOnly(true); | |
| 123 | assert.equal(stagedMode.enabled, false); | |
| 124 | assert.equal(stagedMode.wslOnly, true); | |
| 125 | assert.deepEqual(relaunchReasons, []); | |
| 126 | ||
| 127 | yield* invokeSetWslDistro("Debian"); | |
| 128 | assert.deepEqual(relaunchReasons, []); | |
| 129 | ||
| 130 | const state = yield* invokeSetWslBackendEnabled(true); | |
| 131 | assert.deepEqual(state, { | |
| 132 | enabled: true, | |
| 133 | distro: "Debian", | |
| 134 | available: true, | |
| 135 | wslOnly: true, | |
| 136 | distros: [], | |
| 137 | preflightError: null, | |
| 138 | }); | |
| 139 | assert.deepEqual(relaunchReasons, ["wslBackendEnabled=true"]); | |
| 140 | }).pipe(Effect.provide(layer)); | |
| 141 | }); | |
| 142 | ||
| 143 | it.effect("relaunches when enabling the WSL backend while wsl-only is already persisted", () => { | |
| 144 | const relaunchReasons: Array<string> = []; | |
| 145 | let reconcileCount = 0; | |
| 146 | const layer = Layer.mergeAll( | |
| 147 | DesktopAppSettings.layerTest({ | |
| 148 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | |
| 149 | wslBackendEnabled: false, | |
| 150 | wslOnly: true, | |
| 151 | }), | |
| 152 | DesktopWslEnvironment.layerTest({ isAvailable: true }), | |
| 153 | makeWslBackendLayer({ | |
| 154 | onReconcile: Effect.sync(() => { | |
| 155 | reconcileCount += 1; | |
| 156 | }), | |
| 157 | }), | |
| 158 | makeLifecycleLayer(relaunchReasons), | |
| 159 | unusedLifecycleRuntimeLayer, | |
| 160 | ); | |
| 161 | ||
| 162 | return Effect.gen(function* () { | |
| 163 | const state = yield* invokeSetWslBackendEnabled(true); | |
| 164 | ||
| 165 | assert.deepEqual(state, { | |
| 166 | enabled: true, | |
| 167 | distro: null, | |
| 168 | available: true, | |
| 169 | wslOnly: true, | |
| 170 | distros: [], | |
| 171 | preflightError: null, | |
| 172 | }); | |
| 173 | assert.equal(reconcileCount, 0); | |
| 174 | assert.deepEqual(relaunchReasons, ["wslBackendEnabled=true"]); | |
| 175 | }).pipe(Effect.provide(layer)); | |
| 176 | }); | |
| 177 | ||
| 178 | it.effect("reconciles in dual-backend mode without relaunching", () => { | |
| 179 | const relaunchReasons: Array<string> = []; | |
| 180 | let reconcileCount = 0; | |
| 181 | const layer = Layer.mergeAll( | |
| 182 | DesktopAppSettings.layerTest({ | |
| 183 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | |
| 184 | wslBackendEnabled: false, | |
| 185 | wslOnly: false, | |
| 186 | }), | |
| 187 | DesktopWslEnvironment.layerTest({ isAvailable: true }), | |
| 188 | makeWslBackendLayer({ | |
| 189 | onReconcile: Effect.sync(() => { | |
| 190 | reconcileCount += 1; | |
| 191 | }), | |
| 192 | }), | |
| 193 | makeLifecycleLayer(relaunchReasons), | |
| 194 | unusedLifecycleRuntimeLayer, | |
| 195 | ); | |
| 196 | ||
| 197 | return Effect.gen(function* () { | |
| 198 | const state = yield* invokeSetWslBackendEnabled(true); | |
| 199 | ||
| 200 | assert.equal(state.enabled, true); | |
| 201 | assert.equal(state.wslOnly, false); | |
| 202 | assert.equal(reconcileCount, 1); | |
| 203 | assert.deepEqual(relaunchReasons, []); | |
| 204 | }).pipe(Effect.provide(layer)); | |
| 205 | }); | |
| 206 | ||
| 207 | it.effect("clears wsl-only before relaunching when disabling a WSL-only backend", () => { | |
| 208 | const relaunchReasons: Array<string> = []; | |
| 209 | let reconcileCount = 0; | |
| 210 | const layer = Layer.mergeAll( | |
| 211 | DesktopAppSettings.layerTest({ | |
| 212 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | |
| 213 | wslBackendEnabled: true, | |
| 214 | wslOnly: true, | |
| 215 | }), | |
| 216 | DesktopWslEnvironment.layerTest({ isAvailable: true }), | |
| 217 | makeWslBackendLayer({ | |
| 218 | onReconcile: Effect.sync(() => { | |
| 219 | reconcileCount += 1; | |
| 220 | }), | |
| 221 | }), | |
| 222 | makeLifecycleLayer(relaunchReasons), | |
| 223 | unusedLifecycleRuntimeLayer, | |
| 224 | ); | |
| 225 | ||
| 226 | return Effect.gen(function* () { | |
| 227 | const state = yield* invokeSetWslBackendEnabled(false); | |
| 228 | const appSettings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 229 | const settings = yield* appSettings.get; | |
| 230 | ||
| 231 | assert.deepEqual(state, { | |
| 232 | enabled: false, | |
| 233 | distro: null, | |
| 234 | available: true, | |
| 235 | wslOnly: false, | |
| 236 | distros: [], | |
| 237 | preflightError: null, | |
| 238 | }); | |
| 239 | assert.equal(settings.wslBackendEnabled, false); | |
| 240 | assert.equal(settings.wslOnly, false); | |
| 241 | assert.equal(reconcileCount, 0); | |
| 242 | assert.deepEqual(relaunchReasons, ["wslBackendEnabled=false"]); | |
| 243 | }).pipe(Effect.provide(layer)); | |
| 244 | }); | |
| 245 | ||
| 246 | it.effect("clears dual-backend WSL without relaunching", () => { | |
| 247 | const relaunchReasons: Array<string> = []; | |
| 248 | let reconcileCount = 0; | |
| 249 | const layer = Layer.mergeAll( | |
| 250 | DesktopAppSettings.layerTest({ | |
| 251 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | |
| 252 | wslBackendEnabled: true, | |
| 253 | wslOnly: false, | |
| 254 | }), | |
| 255 | DesktopWslEnvironment.layerTest({ isAvailable: true }), | |
| 256 | makeWslBackendLayer({ | |
| 257 | onReconcile: Effect.sync(() => { | |
| 258 | reconcileCount += 1; | |
| 259 | }), | |
| 260 | }), | |
| 261 | makeLifecycleLayer(relaunchReasons), | |
| 262 | unusedLifecycleRuntimeLayer, | |
| 263 | ); | |
| 264 | ||
| 265 | return Effect.gen(function* () { | |
| 266 | const state = yield* invokeSetWslBackendEnabled(false); | |
| 267 | ||
| 268 | assert.equal(state.enabled, false); | |
| 269 | assert.equal(state.wslOnly, false); | |
| 270 | assert.equal(reconcileCount, 1); | |
| 271 | assert.deepEqual(relaunchReasons, []); | |
| 272 | }).pipe(Effect.provide(layer)); | |
| 273 | }); | |
| 274 | }); |
apps/desktop/src/ipc/methods/wsl.ts created+122| ... | ... | @@ -0,0 +1,122 @@ |
| 1 | import { DesktopWslStateSchema, type DesktopWslState } 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 DesktopLifecycle from "../../app/DesktopLifecycle.ts"; | |
| 7 | import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; | |
| 8 | import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; | |
| 9 | import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; | |
| 10 | import * as IpcChannels from "../channels.ts"; | |
| 11 | import { makeIpcMethod } from "../DesktopIpc.ts"; | |
| 12 | ||
| 13 | const readWslState: Effect.Effect< | |
| 14 | DesktopWslState, | |
| 15 | never, | |
| 16 | | DesktopAppSettings.DesktopAppSettings | |
| 17 | | DesktopWslEnvironment.DesktopWslEnvironment | |
| 18 | | DesktopWslBackend.DesktopWslBackend | |
| 19 | > = Effect.gen(function* () { | |
| 20 | const appSettings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 21 | const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; | |
| 22 | const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; | |
| 23 | const settings = yield* appSettings.get; | |
| 24 | const available = yield* wslEnvironment.isAvailable; | |
| 25 | // Only enumerate distros when WSL is actually available — listDistros on a | |
| 26 | // non-WSL host would spawn wsl.exe and hit the timeout for nothing. | |
| 27 | const distros = available ? yield* wslEnvironment.listDistros : []; | |
| 28 | const preflightError = yield* wslBackend.lastPreflightError; | |
| 29 | return { | |
| 30 | enabled: settings.wslBackendEnabled, | |
| 31 | distro: settings.wslDistro, | |
| 32 | available, | |
| 33 | wslOnly: settings.wslOnly, | |
| 34 | distros, | |
| 35 | // Only the dual-mode secondary records this; a wsl-only failure surfaces via | |
| 36 | // a dialog + Windows fallback, so it stays null there. | |
| 37 | preflightError: settings.wslOnly ? null : Option.getOrNull(preflightError), | |
| 38 | }; | |
| 39 | }); | |
| 40 | ||
| 41 | export const getWslState = makeIpcMethod({ | |
| 42 | channel: IpcChannels.GET_WSL_STATE_CHANNEL, | |
| 43 | payload: Schema.Void, | |
| 44 | result: DesktopWslStateSchema, | |
| 45 | handler: Effect.fn("desktop.ipc.wsl.getState")(function* () { | |
| 46 | return yield* readWslState; | |
| 47 | }), | |
| 48 | }); | |
| 49 | ||
| 50 | export const setWslBackendEnabled = makeIpcMethod({ | |
| 51 | channel: IpcChannels.SET_WSL_BACKEND_ENABLED_CHANNEL, | |
| 52 | payload: Schema.Boolean, | |
| 53 | result: DesktopWslStateSchema, | |
| 54 | handler: Effect.fn("desktop.ipc.wsl.setEnabled")(function* (enabled) { | |
| 55 | const appSettings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 56 | const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; | |
| 57 | const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; | |
| 58 | const previousSettings = yield* appSettings.get; | |
| 59 | const updateSettings = enabled | |
| 60 | ? appSettings.setWslBackendEnabled(true) | |
| 61 | : appSettings.applyWslWindowsFallback; | |
| 62 | const change = yield* updateSettings; | |
| 63 | const settings = yield* appSettings.get; | |
| 64 | const changedWslOnlyPrimary = enabled | |
| 65 | ? settings.wslOnly | |
| 66 | : previousSettings.wslBackendEnabled && previousSettings.wslOnly; | |
| 67 | if (changedWslOnlyPrimary && change.changed) { | |
| 68 | const state = yield* readWslState; | |
| 69 | yield* lifecycle.relaunch(`wslBackendEnabled=${enabled}`); | |
| 70 | return state; | |
| 71 | } | |
| 72 | // Reconcile is idempotent and never fails; no need for a swap-style | |
| 73 | // rollback when the WSL side has trouble coming up. With both | |
| 74 | // backends running side by side, "WSL didn't start" is a transient | |
| 75 | // state on one instance — the primary stays up either way. | |
| 76 | yield* wslBackend.reconcile; | |
| 77 | return yield* readWslState; | |
| 78 | }), | |
| 79 | }); | |
| 80 | ||
| 81 | export const setWslDistro = makeIpcMethod({ | |
| 82 | channel: IpcChannels.SET_WSL_DISTRO_CHANNEL, | |
| 83 | payload: Schema.NullOr(Schema.String), | |
| 84 | result: DesktopWslStateSchema, | |
| 85 | handler: Effect.fn("desktop.ipc.wsl.setDistro")(function* (distro) { | |
| 86 | const appSettings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 87 | const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; | |
| 88 | const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; | |
| 89 | const change = yield* appSettings.setWslDistro(distro); | |
| 90 | const settings = yield* appSettings.get; | |
| 91 | // In active wsl-only mode the pool's primary IS the WSL backend, and its | |
| 92 | // distro is captured when that backend starts, so relaunch to replace it. | |
| 93 | // When WSL is disabled, this only stages a preference for the next enable. | |
| 94 | if (settings.wslBackendEnabled && settings.wslOnly && change.changed) { | |
| 95 | const state = yield* readWslState; | |
| 96 | yield* lifecycle.relaunch(`wslDistro=${distro ?? "default"}`); | |
| 97 | return state; | |
| 98 | } | |
| 99 | yield* wslBackend.reconcile; | |
| 100 | return yield* readWslState; | |
| 101 | }), | |
| 102 | }); | |
| 103 | ||
| 104 | export const setWslOnly = makeIpcMethod({ | |
| 105 | channel: IpcChannels.SET_WSL_ONLY_CHANNEL, | |
| 106 | payload: Schema.Boolean, | |
| 107 | result: DesktopWslStateSchema, | |
| 108 | handler: Effect.fn("desktop.ipc.wsl.setOnly")(function* (enabled) { | |
| 109 | // wsl-only decides which backend the pool spins up as "primary", and that | |
| 110 | // decision is captured once at layer init. A disabled WSL backend always | |
| 111 | // leaves Windows primary active, so mode changes can be staged without a | |
| 112 | // relaunch and applied by the subsequent enable call. | |
| 113 | const appSettings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 114 | const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; | |
| 115 | const change = yield* appSettings.setWslOnly(enabled); | |
| 116 | const state = yield* readWslState; | |
| 117 | if (state.enabled && change.changed) { | |
| 118 | yield* lifecycle.relaunch(`wslOnly=${enabled}`); | |
| 119 | } | |
| 120 | return state; | |
| 121 | }), | |
| 122 | }); |
apps/desktop/src/main.ts+18-2| ... | ... | @@ -31,7 +31,7 @@ import * as DesktopClerk from "./app/DesktopClerk.ts"; |
| 31 | 31 | import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; |
| 32 | 32 | import * as DesktopAssets from "./app/DesktopAssets.ts"; |
| 33 | 33 | import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; |
| 34 | import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; | |
| 34 | import * as DesktopBackendPool from "./backend/DesktopBackendPool.ts"; | |
| 35 | 35 | import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts"; |
| 36 | 36 | import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; |
| 37 | 37 | import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; |
| ... | ... | @@ -50,6 +50,8 @@ import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 50 | 50 | import * as BrowserSession from "./preview/BrowserSession.ts"; |
| 51 | 51 | import * as PreviewManager from "./preview/Manager.ts"; |
| 52 | 52 | import * as DesktopWindow from "./window/DesktopWindow.ts"; |
| 53 | import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; | |
| 54 | import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; | |
| 53 | 55 | |
| 54 | 56 | const desktopEnvironmentLayer = Layer.unwrap( |
| 55 | 57 | Effect.gen(function* () { |
| ... | ... | @@ -143,12 +145,25 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( |
| 143 | 145 | Layer.provideMerge(desktopPreviewLayer), |
| 144 | 146 | ); |
| 145 | 147 | |
| 146 | const desktopBackendLayer = DesktopBackendManager.layer.pipe( | |
| 148 | // Pool layer instantiates the backend factory once for the Windows | |
| 149 | // primary instance and exposes it via pool.primary. Consumers go through | |
| 150 | // the pool now; the legacy DesktopBackendManager service is gone. The | |
| 151 | // WSL second instance gets registered later in the migration. See | |
| 152 | // DesktopBackendPool.ts header for the full rollout plan. | |
| 153 | const desktopBackendLayer = DesktopBackendPool.layer.pipe( | |
| 147 | 154 | Layer.provideMerge(DesktopAppIdentity.layer), |
| 148 | 155 | Layer.provideMerge(DesktopBackendConfiguration.layer), |
| 156 | Layer.provideMerge(DesktopWslEnvironment.layer), | |
| 149 | 157 | Layer.provideMerge(desktopWindowLayer), |
| 150 | 158 | ); |
| 151 | 159 | |
| 160 | // WSL orchestrator hangs off the backend layer because it needs the | |
| 161 | // pool + configuration + serverExposure; it pulls NetService and the | |
| 162 | // foundation services through the same provideMerge chain. | |
| 163 | const desktopWslBackendLayer = DesktopWslBackend.layer.pipe( | |
| 164 | Layer.provideMerge(desktopBackendLayer), | |
| 165 | ); | |
| 166 | ||
| 152 | 167 | const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( |
| 153 | 168 | Layer.provideMerge(desktopBackendLayer), |
| 154 | 169 | ); |
| ... | ... | @@ -160,6 +175,7 @@ const desktopApplicationLayer = Layer.mergeAll( |
| 160 | 175 | desktopSshLayer, |
| 161 | 176 | ).pipe( |
| 162 | 177 | Layer.provideMerge(DesktopUpdates.layer), |
| 178 | Layer.provideMerge(desktopWslBackendLayer), | |
| 163 | 179 | Layer.provideMerge(desktopLocalEnvironmentAuthLayer), |
| 164 | 180 | ); |
| 165 | 181 |
apps/desktop/src/preload.ts+10-5| ... | ... | @@ -35,12 +35,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 35 | 35 | } |
| 36 | 36 | return result as ReturnType<DesktopBridge["getAppBranding"]>; |
| 37 | 37 | }, |
| 38 | getLocalEnvironmentBootstrap: () => { | |
| 39 | const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); | |
| 40 | if (typeof result !== "object" || result === null) { | |
| 41 | return null; | |
| 38 | getLocalEnvironmentBootstraps: () => { | |
| 39 | const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); | |
| 40 | if (!Array.isArray(result)) { | |
| 41 | return []; | |
| 42 | 42 | } |
| 43 | return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>; | |
| 43 | return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstraps"]>; | |
| 44 | 44 | }, |
| 45 | 45 | getLocalEnvironmentBearerToken: () => |
| 46 | 46 | ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), |
| ... | ... | @@ -91,6 +91,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 91 | 91 | setTailscaleServeEnabled: (input) => |
| 92 | 92 | ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), |
| 93 | 93 | getAdvertisedEndpoints: () => ipcRenderer.invoke(IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL), |
| 94 | getWslState: () => ipcRenderer.invoke(IpcChannels.GET_WSL_STATE_CHANNEL), | |
| 95 | setWslBackendEnabled: (enabled) => | |
| 96 | ipcRenderer.invoke(IpcChannels.SET_WSL_BACKEND_ENABLED_CHANNEL, enabled), | |
| 97 | setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), | |
| 98 | setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), | |
| 94 | 99 | pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), |
| 95 | 100 | confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), |
| 96 | 101 | setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), |
apps/desktop/src/settings/DesktopAppSettings.test.ts+112| ... | ... | @@ -16,6 +16,10 @@ const DesktopSettingsPatch = Schema.Struct({ |
| 16 | 16 | tailscaleServePort: Schema.optionalKey(Schema.Number), |
| 17 | 17 | updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])), |
| 18 | 18 | updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), |
| 19 | wslBackendEnabled: Schema.optionalKey(Schema.Boolean), | |
| 20 | wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), | |
| 21 | wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), | |
| 22 | wslOnly: Schema.optionalKey(Schema.Boolean), | |
| 19 | 23 | }); |
| 20 | 24 | |
| 21 | 25 | const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); |
| ... | ... | @@ -92,6 +96,9 @@ describe("DesktopSettings", () => { |
| 92 | 96 | tailscaleServePort: 443, |
| 93 | 97 | updateChannel: "nightly", |
| 94 | 98 | updateChannelConfiguredByUser: false, |
| 99 | wslBackendEnabled: false, | |
| 100 | wslOnly: false, | |
| 101 | wslDistro: null, | |
| 95 | 102 | } satisfies DesktopAppSettings.DesktopSettings, |
| 96 | 103 | ); |
| 97 | 104 | }); |
| ... | ... | @@ -114,6 +121,9 @@ describe("DesktopSettings", () => { |
| 114 | 121 | tailscaleServePort: 8443, |
| 115 | 122 | updateChannel: "latest", |
| 116 | 123 | updateChannelConfiguredByUser: true, |
| 124 | wslBackendEnabled: false, | |
| 125 | wslOnly: false, | |
| 126 | wslDistro: null, | |
| 117 | 127 | } satisfies DesktopAppSettings.DesktopSettings); |
| 118 | 128 | |
| 119 | 129 | const exposure = yield* settings.setServerExposureMode("local-only"); |
| ... | ... | @@ -214,6 +224,9 @@ describe("DesktopSettings", () => { |
| 214 | 224 | tailscaleServePort: 8443, |
| 215 | 225 | updateChannel: "latest", |
| 216 | 226 | updateChannelConfiguredByUser: false, |
| 227 | wslBackendEnabled: false, | |
| 228 | wslOnly: false, | |
| 229 | wslDistro: null, | |
| 217 | 230 | } satisfies DesktopAppSettings.DesktopSettings); |
| 218 | 231 | }), |
| 219 | 232 | ), |
| ... | ... | @@ -253,6 +266,9 @@ describe("DesktopSettings", () => { |
| 253 | 266 | tailscaleServePort: 443, |
| 254 | 267 | updateChannel: "nightly", |
| 255 | 268 | updateChannelConfiguredByUser: false, |
| 269 | wslBackendEnabled: false, | |
| 270 | wslOnly: false, | |
| 271 | wslDistro: null, | |
| 256 | 272 | } satisfies DesktopAppSettings.DesktopSettings); |
| 257 | 273 | }), |
| 258 | 274 | { appVersion: "0.0.17-nightly.20260415.1" }, |
| ... | ... | @@ -275,6 +291,9 @@ describe("DesktopSettings", () => { |
| 275 | 291 | tailscaleServePort: 443, |
| 276 | 292 | updateChannel: "latest", |
| 277 | 293 | updateChannelConfiguredByUser: true, |
| 294 | wslBackendEnabled: false, | |
| 295 | wslOnly: false, | |
| 296 | wslDistro: null, | |
| 278 | 297 | } satisfies DesktopAppSettings.DesktopSettings); |
| 279 | 298 | }), |
| 280 | 299 | { appVersion: "0.0.17-nightly.20260415.1" }, |
| ... | ... | @@ -296,8 +315,101 @@ describe("DesktopSettings", () => { |
| 296 | 315 | tailscaleServePort: 443, |
| 297 | 316 | updateChannel: "latest", |
| 298 | 317 | updateChannelConfiguredByUser: false, |
| 318 | wslBackendEnabled: false, | |
| 319 | wslOnly: false, | |
| 320 | wslDistro: null, | |
| 299 | 321 | } satisfies DesktopAppSettings.DesktopSettings); |
| 300 | 322 | }), |
| 301 | 323 | ), |
| 302 | 324 | ); |
| 325 | ||
| 326 | it.effect("persists wsl backend toggle and normalizes invalid distro names", () => | |
| 327 | withSettings( | |
| 328 | Effect.gen(function* () { | |
| 329 | const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 330 | const enable = yield* settings.setWslBackendEnabled(true); | |
| 331 | assert.isTrue(enable.changed); | |
| 332 | assert.equal(enable.settings.wslBackendEnabled, true); | |
| 333 | ||
| 334 | const distro = yield* settings.setWslDistro("Ubuntu-22.04"); | |
| 335 | assert.isTrue(distro.changed); | |
| 336 | assert.equal(distro.settings.wslDistro, "Ubuntu-22.04"); | |
| 337 | ||
| 338 | const reloaded = yield* settings.load; | |
| 339 | assert.equal(reloaded.wslBackendEnabled, true); | |
| 340 | assert.equal(reloaded.wslDistro, "Ubuntu-22.04"); | |
| 341 | ||
| 342 | const reject = yield* settings.setWslDistro("bad name!"); | |
| 343 | assert.equal(reject.settings.wslDistro, null); | |
| 344 | ||
| 345 | const noop = yield* settings.setWslDistro(null); | |
| 346 | assert.isFalse(noop.changed); | |
| 347 | }), | |
| 348 | ), | |
| 349 | ); | |
| 350 | ||
| 351 | it.effect("applies WSL Windows fallback with persisted and volatile updates", () => | |
| 352 | withSettings( | |
| 353 | Effect.gen(function* () { | |
| 354 | const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 355 | yield* settings.setWslBackendEnabled(true); | |
| 356 | yield* settings.setWslOnly(true); | |
| 357 | ||
| 358 | const persistedFallback = yield* settings.applyWslWindowsFallback; | |
| 359 | assert.isTrue(persistedFallback.changed); | |
| 360 | assert.equal(persistedFallback.settings.wslBackendEnabled, false); | |
| 361 | assert.equal(persistedFallback.settings.wslOnly, false); | |
| 362 | ||
| 363 | const persistedReload = yield* settings.load; | |
| 364 | assert.equal(persistedReload.wslBackendEnabled, false); | |
| 365 | assert.equal(persistedReload.wslOnly, false); | |
| 366 | ||
| 367 | yield* settings.setWslBackendEnabled(true); | |
| 368 | yield* settings.setWslOnly(true); | |
| 369 | ||
| 370 | const volatileFallback = yield* settings.applyWslWindowsFallbackInMemory; | |
| 371 | assert.isTrue(volatileFallback.changed); | |
| 372 | assert.equal(volatileFallback.settings.wslBackendEnabled, false); | |
| 373 | assert.equal(volatileFallback.settings.wslOnly, false); | |
| 374 | ||
| 375 | const current = yield* settings.get; | |
| 376 | assert.equal(current.wslBackendEnabled, false); | |
| 377 | assert.equal(current.wslOnly, false); | |
| 378 | ||
| 379 | const diskReload = yield* settings.load; | |
| 380 | assert.equal(diskReload.wslBackendEnabled, true); | |
| 381 | assert.equal(diskReload.wslOnly, true); | |
| 382 | }), | |
| 383 | ), | |
| 384 | ); | |
| 385 | ||
| 386 | it.effect("migrates legacy wslMode=wsl to wslBackendEnabled on load", () => | |
| 387 | withSettings( | |
| 388 | Effect.gen(function* () { | |
| 389 | const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 390 | yield* writeSettingsPatch({ | |
| 391 | wslMode: "wsl", | |
| 392 | wslDistro: "Ubuntu-22.04", | |
| 393 | }); | |
| 394 | const loaded = yield* settings.load; | |
| 395 | assert.equal(loaded.wslBackendEnabled, true); | |
| 396 | assert.equal(loaded.wslDistro, "Ubuntu-22.04"); | |
| 397 | }), | |
| 398 | ), | |
| 399 | ); | |
| 400 | ||
| 401 | it.effect("drops invalid persisted wsl distro values on load", () => | |
| 402 | withSettings( | |
| 403 | Effect.gen(function* () { | |
| 404 | const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 405 | yield* writeSettingsPatch({ | |
| 406 | wslBackendEnabled: true, | |
| 407 | wslDistro: "bad/name", | |
| 408 | }); | |
| 409 | const loaded = yield* settings.load; | |
| 410 | assert.equal(loaded.wslBackendEnabled, true); | |
| 411 | assert.equal(loaded.wslDistro, null); | |
| 412 | }), | |
| 413 | ), | |
| 414 | ); | |
| 303 | 415 | }); |
apps/desktop/src/settings/DesktopAppSettings.ts+127| ... | ... | @@ -17,6 +17,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 17 | 17 | |
| 18 | 18 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 19 | 19 | import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; |
| 20 | import { isValidDistroName } from "../wsl/wslPathParsing.ts"; | |
| 20 | 21 | |
| 21 | 22 | export interface DesktopSettings { |
| 22 | 23 | readonly serverExposureMode: DesktopServerExposureMode; |
| ... | ... | @@ -24,6 +25,21 @@ export interface DesktopSettings { |
| 24 | 25 | readonly tailscaleServePort: number; |
| 25 | 26 | readonly updateChannel: DesktopUpdateChannel; |
| 26 | 27 | readonly updateChannelConfiguredByUser: boolean; |
| 28 | // Was a "local" | "wsl" swap mode in an earlier iteration of the WSL | |
| 29 | // integration. We now run Windows and WSL backends side by side, so the | |
| 30 | // setting is just whether the WSL backend should be running alongside the | |
| 31 | // primary. Persisted documents that still carry the legacy `wslMode: "wsl"` | |
| 32 | // value are migrated to `wslBackendEnabled: true` on load. | |
| 33 | readonly wslBackendEnabled: boolean; | |
| 34 | readonly wslDistro: string | null; | |
| 35 | // When true (and wslBackendEnabled is also true) the desktop runs only | |
| 36 | // the WSL backend as the primary, and the Windows-side Node backend is | |
| 37 | // not started. Designed for users who develop entirely inside WSL and | |
| 38 | // don't want a second backend process running. Defaults to false so | |
| 39 | // existing setups stay on the parallel-backends behavior. Changing | |
| 40 | // this requires a desktop restart because the pool's primary spec is | |
| 41 | // chosen once at layer init. | |
| 42 | readonly wslOnly: boolean; | |
| 27 | 43 | } |
| 28 | 44 | |
| 29 | 45 | export interface DesktopSettingsChange { |
| ... | ... | @@ -39,6 +55,9 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { |
| 39 | 55 | tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, |
| 40 | 56 | updateChannel: "latest", |
| 41 | 57 | updateChannelConfiguredByUser: false, |
| 58 | wslBackendEnabled: false, | |
| 59 | wslDistro: null, | |
| 60 | wslOnly: false, | |
| 42 | 61 | }; |
| 43 | 62 | |
| 44 | 63 | const DesktopSettingsDocument = Schema.Struct({ |
| ... | ... | @@ -47,6 +66,13 @@ const DesktopSettingsDocument = Schema.Struct({ |
| 47 | 66 | tailscaleServePort: Schema.optionalKey(Schema.Number), |
| 48 | 67 | updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), |
| 49 | 68 | updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), |
| 69 | // Newer form of the WSL toggle. `wslMode` is still accepted on load so | |
| 70 | // existing on-disk settings keep working; on the next persist we write the | |
| 71 | // new boolean and the legacy key drops out. | |
| 72 | wslBackendEnabled: Schema.optionalKey(Schema.Boolean), | |
| 73 | wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), | |
| 74 | wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), | |
| 75 | wslOnly: Schema.optionalKey(Schema.Boolean), | |
| 50 | 76 | }); |
| 51 | 77 | |
| 52 | 78 | type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; |
| ... | ... | @@ -98,6 +124,20 @@ export class DesktopAppSettings extends Context.Service< |
| 98 | 124 | readonly setUpdateChannel: ( |
| 99 | 125 | channel: DesktopUpdateChannel, |
| 100 | 126 | ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; |
| 127 | readonly setWslBackendEnabled: ( | |
| 128 | enabled: boolean, | |
| 129 | ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; | |
| 130 | readonly setWslDistro: ( | |
| 131 | distro: string | null, | |
| 132 | ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; | |
| 133 | readonly setWslOnly: ( | |
| 134 | enabled: boolean, | |
| 135 | ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; | |
| 136 | readonly applyWslWindowsFallback: Effect.Effect< | |
| 137 | DesktopSettingsChange, | |
| 138 | DesktopSettingsWriteError | |
| 139 | >; | |
| 140 | readonly applyWslWindowsFallbackInMemory: Effect.Effect<DesktopSettingsChange>; | |
| 101 | 141 | } |
| 102 | 142 | >()("@t3tools/desktop/settings/DesktopAppSettings") {} |
| 103 | 143 | |
| ... | ... | @@ -114,6 +154,10 @@ function normalizeTailscaleServePort(value: unknown): number { |
| 114 | 154 | : DEFAULT_TAILSCALE_SERVE_PORT; |
| 115 | 155 | } |
| 116 | 156 | |
| 157 | function normalizeWslDistro(value: unknown): string | null { | |
| 158 | return typeof value === "string" && isValidDistroName(value) ? value : null; | |
| 159 | } | |
| 160 | ||
| 117 | 161 | function normalizeDesktopSettingsDocument( |
| 118 | 162 | parsed: DesktopSettingsDocument, |
| 119 | 163 | appVersion: string, |
| ... | ... | @@ -125,6 +169,13 @@ function normalizeDesktopSettingsDocument( |
| 125 | 169 | parsed.updateChannelConfiguredByUser === true || |
| 126 | 170 | (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly")); |
| 127 | 171 | |
| 172 | // Newer form wins when both are present; otherwise fall back to the legacy | |
| 173 | // `wslMode === "wsl"` signal so users coming off the swap-mode build keep | |
| 174 | // their WSL backend enabled. | |
| 175 | const wslBackendEnabled = | |
| 176 | parsed.wslBackendEnabled === true || | |
| 177 | (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); | |
| 178 | ||
| 128 | 179 | return { |
| 129 | 180 | serverExposureMode: |
| 130 | 181 | parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", |
| ... | ... | @@ -134,6 +185,9 @@ function normalizeDesktopSettingsDocument( |
| 134 | 185 | ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) |
| 135 | 186 | : defaultSettings.updateChannel, |
| 136 | 187 | updateChannelConfiguredByUser, |
| 188 | wslBackendEnabled, | |
| 189 | wslDistro: normalizeWslDistro(parsed.wslDistro), | |
| 190 | wslOnly: parsed.wslOnly === true, | |
| 137 | 191 | }; |
| 138 | 192 | } |
| 139 | 193 | |
| ... | ... | @@ -158,6 +212,15 @@ function toDesktopSettingsDocument( |
| 158 | 212 | if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) { |
| 159 | 213 | document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser; |
| 160 | 214 | } |
| 215 | if (settings.wslBackendEnabled !== defaults.wslBackendEnabled) { | |
| 216 | document.wslBackendEnabled = settings.wslBackendEnabled; | |
| 217 | } | |
| 218 | if (settings.wslDistro !== defaults.wslDistro) { | |
| 219 | document.wslDistro = settings.wslDistro; | |
| 220 | } | |
| 221 | if (settings.wslOnly !== defaults.wslOnly) { | |
| 222 | document.wslOnly = settings.wslOnly; | |
| 223 | } | |
| 161 | 224 | |
| 162 | 225 | return document; |
| 163 | 226 | } |
| ... | ... | @@ -204,6 +267,38 @@ function setUpdateChannel( |
| 204 | 267 | }; |
| 205 | 268 | } |
| 206 | 269 | |
| 270 | function setWslBackendEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings { | |
| 271 | return settings.wslBackendEnabled === enabled | |
| 272 | ? settings | |
| 273 | : { | |
| 274 | ...settings, | |
| 275 | wslBackendEnabled: enabled, | |
| 276 | }; | |
| 277 | } | |
| 278 | ||
| 279 | function setWslDistro(settings: DesktopSettings, distro: string | null): DesktopSettings { | |
| 280 | const normalized = normalizeWslDistro(distro); | |
| 281 | return settings.wslDistro === normalized | |
| 282 | ? settings | |
| 283 | : { | |
| 284 | ...settings, | |
| 285 | wslDistro: normalized, | |
| 286 | }; | |
| 287 | } | |
| 288 | ||
| 289 | function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSettings { | |
| 290 | return settings.wslOnly === enabled | |
| 291 | ? settings | |
| 292 | : { | |
| 293 | ...settings, | |
| 294 | wslOnly: enabled, | |
| 295 | }; | |
| 296 | } | |
| 297 | ||
| 298 | function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { | |
| 299 | return setWslOnly(setWslBackendEnabled(settings, false), false); | |
| 300 | } | |
| 301 | ||
| 207 | 302 | function readSettings( |
| 208 | 303 | fileSystem: FileSystem.FileSystem, |
| 209 | 304 | settingsPath: string, |
| ... | ... | @@ -287,6 +382,12 @@ export const make = Effect.gen(function* () { |
| 287 | 382 | const crypto = yield* Crypto.Crypto; |
| 288 | 383 | const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); |
| 289 | 384 | |
| 385 | const updateInMemory = (update: (settings: DesktopSettings) => DesktopSettings) => | |
| 386 | SynchronizedRef.modify(settingsRef, (settings) => { | |
| 387 | const nextSettings = update(settings); | |
| 388 | return [settingsChange(nextSettings, nextSettings !== settings), nextSettings] as const; | |
| 389 | }); | |
| 390 | ||
| 290 | 391 | const persist = ( |
| 291 | 392 | update: (settings: DesktopSettings) => DesktopSettings, |
| 292 | 393 | ): Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError> => |
| ... | ... | @@ -342,6 +443,26 @@ export const make = Effect.gen(function* () { |
| 342 | 443 | persist((settings) => setUpdateChannel(settings, channel)).pipe( |
| 343 | 444 | Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), |
| 344 | 445 | ), |
| 446 | setWslBackendEnabled: (enabled) => | |
| 447 | persist((settings) => setWslBackendEnabled(settings, enabled)).pipe( | |
| 448 | Effect.withSpan("desktop.settings.setWslBackendEnabled", { attributes: { enabled } }), | |
| 449 | ), | |
| 450 | setWslDistro: (distro) => | |
| 451 | persist((settings) => setWslDistro(settings, distro)).pipe( | |
| 452 | Effect.withSpan("desktop.settings.setWslDistro", { | |
| 453 | attributes: { distro: distro ?? null }, | |
| 454 | }), | |
| 455 | ), | |
| 456 | setWslOnly: (enabled) => | |
| 457 | persist((settings) => setWslOnly(settings, enabled)).pipe( | |
| 458 | Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }), | |
| 459 | ), | |
| 460 | applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( | |
| 461 | Effect.withSpan("desktop.settings.applyWslWindowsFallback"), | |
| 462 | ), | |
| 463 | applyWslWindowsFallbackInMemory: updateInMemory(applyWslWindowsFallback).pipe( | |
| 464 | Effect.withSpan("desktop.settings.applyWslWindowsFallbackInMemory"), | |
| 465 | ), | |
| 345 | 466 | }); |
| 346 | 467 | }); |
| 347 | 468 | |
| ... | ... | @@ -371,6 +492,12 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET |
| 371 | 492 | update((settings) => setServerExposureMode(settings, mode)), |
| 372 | 493 | setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), |
| 373 | 494 | setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), |
| 495 | setWslBackendEnabled: (enabled) => | |
| 496 | update((settings) => setWslBackendEnabled(settings, enabled)), | |
| 497 | setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), | |
| 498 | setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), | |
| 499 | applyWslWindowsFallback: update(applyWslWindowsFallback), | |
| 500 | applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), | |
| 374 | 501 | }); |
| 375 | 502 | }), |
| 376 | 503 | ); |
apps/desktop/src/updates/DesktopUpdates.test.ts+12-3| ... | ... | @@ -13,7 +13,7 @@ import * as References from "effect/References"; |
| 13 | 13 | import * as Ref from "effect/Ref"; |
| 14 | 14 | import * as TestClock from "effect/testing/TestClock"; |
| 15 | 15 | |
| 16 | import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; | |
| 16 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; | |
| 17 | 17 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 18 | 18 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 19 | 19 | import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; |
| ... | ... | @@ -107,7 +107,9 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 107 | 107 | syncAllAppearance: () => Effect.void, |
| 108 | 108 | } satisfies ElectronWindow.ElectronWindow["Service"]); |
| 109 | 109 | |
| 110 | const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { | |
| 110 | const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = { | |
| 111 | id: DesktopBackendPool.PRIMARY_INSTANCE_ID, | |
| 112 | label: Effect.succeed("Windows"), | |
| 111 | 113 | start: Effect.void, |
| 112 | 114 | stop: () => options.stopBackend ?? Effect.void, |
| 113 | 115 | currentConfig: Effect.succeed(Option.none()), |
| ... | ... | @@ -118,7 +120,9 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 118 | 120 | restartAttempt: 0, |
| 119 | 121 | restartScheduled: false, |
| 120 | 122 | }), |
| 121 | }); | |
| 123 | waitForReady: () => Effect.succeed(true), | |
| 124 | }; | |
| 125 | const backendLayer = DesktopBackendPool.layerTest([stubBackendInstance]); | |
| 122 | 126 | |
| 123 | 127 | const environmentLayer = DesktopEnvironment.layer({ |
| 124 | 128 | dirname: "/repo/apps/desktop/src", |
| ... | ... | @@ -152,6 +156,11 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 152 | 156 | setServerExposureMode: () => Effect.die("unexpected server exposure update"), |
| 153 | 157 | setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), |
| 154 | 158 | setUpdateChannel: () => Effect.fail(setUpdateChannelError), |
| 159 | setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), | |
| 160 | setWslDistro: () => Effect.die("unexpected WSL distro change"), | |
| 161 | setWslOnly: () => Effect.die("unexpected WSL-only toggle"), | |
| 162 | applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), | |
| 163 | applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), | |
| 155 | 164 | } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) |
| 156 | 165 | : DesktopAppSettings.layer; |
| 157 | 166 |
apps/desktop/src/updates/DesktopUpdates.ts+15-3| ... | ... | @@ -18,7 +18,7 @@ import * as Ref from "effect/Ref"; |
| 18 | 18 | import * as Schema from "effect/Schema"; |
| 19 | 19 | import * as Scope from "effect/Scope"; |
| 20 | 20 | |
| 21 | import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; | |
| 21 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; | |
| 22 | 22 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 23 | 23 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 24 | 24 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| ... | ... | @@ -241,7 +241,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean |
| 241 | 241 | |
| 242 | 242 | export const make = Effect.gen(function* () { |
| 243 | 243 | const config = yield* DesktopConfig.DesktopConfig; |
| 244 | const backendManager = yield* DesktopBackendManager.DesktopBackendManager; | |
| 244 | const pool = yield* DesktopBackendPool.DesktopBackendPool; | |
| 245 | 245 | const desktopState = yield* DesktopState.DesktopState; |
| 246 | 246 | const electronUpdater = yield* ElectronUpdater.ElectronUpdater; |
| 247 | 247 | const electronWindow = yield* ElectronWindow.ElectronWindow; |
| ... | ... | @@ -458,7 +458,19 @@ export const make = Effect.gen(function* () { |
| 458 | 458 | yield* Ref.set(updateInstallInFlightRef, true); |
| 459 | 459 | |
| 460 | 460 | return yield* Effect.gen(function* () { |
| 461 | yield* backendManager.stop({ timeout: Duration.seconds(5) }); | |
| 461 | // Stop every backend in the pool, not just the primary. With | |
| 462 | // parallel WSL + Windows backends, leaving the WSL instance up | |
| 463 | // means quitAndInstall's app.quit() exits before the pool's | |
| 464 | // scope cascade has a chance to run its stop finalizer, so the | |
| 465 | // WSL child gets hard-killed by the OS instead of receiving | |
| 466 | // SIGTERM + grace. Stops run concurrently with the same 5s | |
| 467 | // budget the primary had on its own. | |
| 468 | const instances = yield* pool.list; | |
| 469 | yield* Effect.forEach( | |
| 470 | instances, | |
| 471 | (instance) => instance.stop({ timeout: Duration.seconds(5) }), | |
| 472 | { concurrency: "unbounded" }, | |
| 473 | ); | |
| 462 | 474 | yield* electronWindow.destroyAll; |
| 463 | 475 | yield* electronUpdater.quitAndInstall({ |
| 464 | 476 | isSilent: true, |
apps/desktop/src/window/DesktopApplicationMenu.test.ts+3-1| ... | ... | @@ -73,7 +73,9 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) => |
| 73 | 73 | revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), |
| 74 | 74 | activate: Effect.void, |
| 75 | 75 | createMainIfBackendReady: Effect.void, |
| 76 | handleBackendReady: Effect.void, | |
| 76 | showConnectingSplash: Effect.void, | |
| 77 | handleBackendReady: () => Effect.void, | |
| 78 | handleBackendNotReady: Effect.void, | |
| 77 | 79 | dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), |
| 78 | 80 | syncAppearance: Effect.void, |
| 79 | 81 | } satisfies DesktopWindow.DesktopWindow["Service"]); |
apps/desktop/src/window/DesktopWindow.test.ts+204-3| ... | ... | @@ -28,6 +28,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; |
| 28 | 28 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 29 | 29 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| 30 | 30 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 31 | import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; | |
| 31 | 32 | import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; |
| 32 | 33 | import * as DesktopWindow from "./DesktopWindow.ts"; |
| 33 | 34 | import * as PreviewManager from "../preview/Manager.ts"; |
| ... | ... | @@ -62,6 +63,7 @@ function makeFakeBrowserWindow() { |
| 62 | 63 | }; |
| 63 | 64 | |
| 64 | 65 | const window = { |
| 66 | close: vi.fn(), | |
| 65 | 67 | focus: vi.fn(), |
| 66 | 68 | isDestroyed: vi.fn(() => false), |
| 67 | 69 | isMinimized: vi.fn(() => false), |
| ... | ... | @@ -83,6 +85,7 @@ function makeFakeBrowserWindow() { |
| 83 | 85 | loadURL: window.loadURL, |
| 84 | 86 | openDevTools: webContents.openDevTools, |
| 85 | 87 | reload: webContents.reload, |
| 88 | send: webContents.send, | |
| 86 | 89 | setAutoHideCursor: window.setAutoHideCursor, |
| 87 | 90 | webContentsListeners, |
| 88 | 91 | }; |
| ... | ... | @@ -191,6 +194,100 @@ function makeTestLayer(input: { |
| 191 | 194 | ); |
| 192 | 195 | } |
| 193 | 196 | |
| 197 | // Builds a DesktopWindow over a fake ElectronWindow whose `create` returns the | |
| 198 | // given outcomes in order (null => simulated open failure), and whose | |
| 199 | // currentMainOrFirst mirrors the real fallback to the first live window (the | |
| 200 | // splash, before any main is registered). Reveal targets are recorded so tests | |
| 201 | // can assert what activation actually surfaced. | |
| 202 | const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | null)[]) => | |
| 203 | Effect.gen(function* () { | |
| 204 | const createdWindows = yield* Ref.make<Electron.BrowserWindow[]>([]); | |
| 205 | const createCalls = yield* Ref.make(0); | |
| 206 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | |
| 207 | const revealedWindows = yield* Ref.make<Electron.BrowserWindow[]>([]); | |
| 208 | const fallbackWindow = createOutcomes.find( | |
| 209 | (window): window is Electron.BrowserWindow => window !== null, | |
| 210 | ); | |
| 211 | ||
| 212 | const currentMainOrFirst = Effect.gen(function* () { | |
| 213 | const registered = yield* Ref.get(mainWindow); | |
| 214 | if (Option.isSome(registered)) { | |
| 215 | return registered; | |
| 216 | } | |
| 217 | const created = yield* Ref.get(createdWindows); | |
| 218 | return Option.fromNullishOr(created[0] ?? null); | |
| 219 | }); | |
| 220 | ||
| 221 | const electronWindowShape = { | |
| 222 | create: () => | |
| 223 | Effect.gen(function* () { | |
| 224 | const index = yield* Ref.getAndUpdate(createCalls, (count) => count + 1); | |
| 225 | const outcome = createOutcomes[index] ?? null; | |
| 226 | if (outcome === null) { | |
| 227 | return yield* new ElectronWindow.ElectronWindowCreateError({ | |
| 228 | options: { | |
| 229 | title: null, | |
| 230 | width: null, | |
| 231 | height: null, | |
| 232 | minWidth: null, | |
| 233 | minHeight: null, | |
| 234 | show: null, | |
| 235 | modal: null, | |
| 236 | frame: null, | |
| 237 | transparent: null, | |
| 238 | backgroundColor: null, | |
| 239 | webPreferences: { | |
| 240 | preload: null, | |
| 241 | partition: null, | |
| 242 | sandbox: null, | |
| 243 | contextIsolation: null, | |
| 244 | nodeIntegration: null, | |
| 245 | webviewTag: null, | |
| 246 | }, | |
| 247 | }, | |
| 248 | cause: new Error("simulated window-open failure"), | |
| 249 | }); | |
| 250 | } | |
| 251 | yield* Ref.update(createdWindows, (windows) => [...windows, outcome]); | |
| 252 | return outcome; | |
| 253 | }), | |
| 254 | main: Ref.get(mainWindow), | |
| 255 | currentMainOrFirst, | |
| 256 | focusedMainOrFirst: currentMainOrFirst, | |
| 257 | setMain: (window) => Ref.set(mainWindow, Option.some(window)), | |
| 258 | clearMain: () => Ref.set(mainWindow, Option.none()), | |
| 259 | reveal: (window) => Ref.update(revealedWindows, (windows) => [...windows, window]), | |
| 260 | sendAll: () => Effect.void, | |
| 261 | destroyAll: Effect.void, | |
| 262 | syncAllAppearance: (sync) => (fallbackWindow ? sync(fallbackWindow) : Effect.void), | |
| 263 | } satisfies ElectronWindow.ElectronWindow["Service"]; | |
| 264 | ||
| 265 | const layer = DesktopWindow.layer.pipe( | |
| 266 | Layer.provide( | |
| 267 | Layer.mergeAll( | |
| 268 | desktopAssetsLayer, | |
| 269 | desktopEnvironmentLayer, | |
| 270 | desktopServerExposureLayer, | |
| 271 | electronMenuLayer, | |
| 272 | Layer.succeed(ElectronShell.ElectronShell, { | |
| 273 | openExternal: () => Effect.succeed(true), | |
| 274 | copyText: () => Effect.void, | |
| 275 | } satisfies ElectronShell.ElectronShell["Service"]), | |
| 276 | electronThemeLayer, | |
| 277 | Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape), | |
| 278 | Layer.mock(PreviewManager.PreviewManager)({ | |
| 279 | getBrowserSession: () => Effect.succeed({} as Electron.Session), | |
| 280 | setMainWindow: () => Effect.void, | |
| 281 | isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), | |
| 282 | getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), | |
| 283 | }), | |
| 284 | ), | |
| 285 | ), | |
| 286 | ); | |
| 287 | ||
| 288 | return { layer, createCalls, mainWindow, revealedWindows } as const; | |
| 289 | }); | |
| 290 | ||
| 194 | 291 | describe("DesktopWindow", () => { |
| 195 | 292 | it("recognizes only same-origin renderer navigations", () => { |
| 196 | 293 | assert.isTrue( |
| ... | ... | @@ -231,7 +328,7 @@ describe("DesktopWindow", () => { |
| 231 | 328 | yield* desktopWindow.activate; |
| 232 | 329 | assert.equal(yield* Ref.get(createCount), 0); |
| 233 | 330 | |
| 234 | yield* desktopWindow.handleBackendReady; | |
| 331 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | |
| 235 | 332 | assert.equal(yield* Ref.get(createCount), 1); |
| 236 | 333 | assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); |
| 237 | 334 | assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); |
| ... | ... | @@ -254,7 +351,7 @@ describe("DesktopWindow", () => { |
| 254 | 351 | |
| 255 | 352 | yield* Effect.gen(function* () { |
| 256 | 353 | const desktopWindow = yield* DesktopWindow.DesktopWindow; |
| 257 | yield* desktopWindow.handleBackendReady; | |
| 354 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | |
| 258 | 355 | |
| 259 | 356 | const didFailLoad = fakeWindow.webContentsListeners.get("did-fail-load"); |
| 260 | 357 | const didFinishLoad = fakeWindow.webContentsListeners.get("did-finish-load"); |
| ... | ... | @@ -323,7 +420,7 @@ describe("DesktopWindow", () => { |
| 323 | 420 | |
| 324 | 421 | yield* Effect.gen(function* () { |
| 325 | 422 | const desktopWindow = yield* DesktopWindow.DesktopWindow; |
| 326 | yield* desktopWindow.handleBackendReady; | |
| 423 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | |
| 327 | 424 | |
| 328 | 425 | const willNavigate = fakeWindow.webContentsListeners.get("will-navigate"); |
| 329 | 426 | if (!willNavigate) { |
| ... | ... | @@ -345,4 +442,108 @@ describe("DesktopWindow", () => { |
| 345 | 442 | }).pipe(Effect.provide(layer)); |
| 346 | 443 | }), |
| 347 | 444 | ); |
| 445 | ||
| 446 | it.effect( | |
| 447 | "retries opening the real main on activate when a failed post-readiness open left only the splash", | |
| 448 | () => | |
| 449 | Effect.gen(function* () { | |
| 450 | const splash = makeFakeBrowserWindow(); | |
| 451 | const main = makeFakeBrowserWindow(); | |
| 452 | // create #1 -> splash, #2 -> fails (the pool swallows this post-readiness | |
| 453 | // window-open error), #3 -> the real main on activate's retry. | |
| 454 | const scenario = yield* makeSplashScenario([splash.window, null, main.window]); | |
| 455 | ||
| 456 | yield* Effect.gen(function* () { | |
| 457 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | |
| 458 | ||
| 459 | // 1. WSL-only boot shows the connecting splash. | |
| 460 | yield* desktopWindow.showConnectingSplash; | |
| 461 | assert.equal(yield* Ref.get(scenario.createCalls), 1); | |
| 462 | ||
| 463 | // 2. Backend reports ready, but opening the real main fails. The pool | |
| 464 | // swallows that error in production, so handleBackendReady fails | |
| 465 | // here without a registered main window -- only the splash is open. | |
| 466 | const readyExit = yield* Effect.exit( | |
| 467 | desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")), | |
| 468 | ); | |
| 469 | assert.equal(readyExit._tag, "Failure"); | |
| 470 | assert.equal(yield* Ref.get(scenario.createCalls), 2); | |
| 471 | assert.isTrue(Option.isNone(yield* Ref.get(scenario.mainWindow))); | |
| 472 | ||
| 473 | // 3. Activating must not mistake the splash for the main window: it | |
| 474 | // retries the open and brings up the real main instead of leaving | |
| 475 | // the user stranded on "Connecting to WSL". | |
| 476 | yield* desktopWindow.activate; | |
| 477 | assert.equal(yield* Ref.get(scenario.createCalls), 3); | |
| 478 | const registeredMain = yield* Ref.get(scenario.mainWindow); | |
| 479 | assert.isTrue(Option.isSome(registeredMain)); | |
| 480 | assert.equal(Option.getOrThrow(registeredMain), main.window); | |
| 481 | }).pipe(Effect.provide(scenario.layer)); | |
| 482 | }), | |
| 483 | ); | |
| 484 | ||
| 485 | it.effect( | |
| 486 | "re-reveals the connecting splash on activate while the backend is still cold-booting", | |
| 487 | () => | |
| 488 | Effect.gen(function* () { | |
| 489 | const splash = makeFakeBrowserWindow(); | |
| 490 | // Only the splash is ever created; the backend never reports ready. | |
| 491 | const scenario = yield* makeSplashScenario([splash.window]); | |
| 492 | ||
| 493 | yield* Effect.gen(function* () { | |
| 494 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | |
| 495 | ||
| 496 | yield* desktopWindow.showConnectingSplash; | |
| 497 | assert.equal(yield* Ref.get(scenario.createCalls), 1); | |
| 498 | ||
| 499 | // Taskbar/dock activation during cold boot must bring the splash back | |
| 500 | // rather than no-op and leave it hidden until the backend finishes. | |
| 501 | yield* desktopWindow.activate; | |
| 502 | assert.equal(yield* Ref.get(scenario.createCalls), 1); | |
| 503 | assert.deepEqual(yield* Ref.get(scenario.revealedWindows), [splash.window]); | |
| 504 | }).pipe(Effect.provide(scenario.layer)); | |
| 505 | }), | |
| 506 | ); | |
| 507 | ||
| 508 | it.effect("does not dispatch menu actions to the splash before the backend is ready", () => | |
| 509 | Effect.gen(function* () { | |
| 510 | const splash = makeFakeBrowserWindow(); | |
| 511 | const main = makeFakeBrowserWindow(); | |
| 512 | const scenario = yield* makeSplashScenario([splash.window, main.window]); | |
| 513 | ||
| 514 | yield* Effect.gen(function* () { | |
| 515 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | |
| 516 | ||
| 517 | yield* desktopWindow.showConnectingSplash; | |
| 518 | yield* desktopWindow.dispatchMenuAction("open-settings"); | |
| 519 | ||
| 520 | assert.equal(yield* Ref.get(scenario.createCalls), 1); | |
| 521 | assert.equal(splash.send.mock.calls.length, 0); | |
| 522 | assert.equal(main.send.mock.calls.length, 0); | |
| 523 | }).pipe(Effect.provide(scenario.layer)); | |
| 524 | }), | |
| 525 | ); | |
| 526 | ||
| 527 | it.effect("dispatches menu actions after backend readiness when no main window exists", () => | |
| 528 | Effect.gen(function* () { | |
| 529 | const splash = makeFakeBrowserWindow(); | |
| 530 | const main = makeFakeBrowserWindow(); | |
| 531 | const scenario = yield* makeSplashScenario([splash.window, null, main.window]); | |
| 532 | ||
| 533 | yield* Effect.gen(function* () { | |
| 534 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | |
| 535 | ||
| 536 | yield* desktopWindow.showConnectingSplash; | |
| 537 | const readyExit = yield* Effect.exit( | |
| 538 | desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")), | |
| 539 | ); | |
| 540 | assert.equal(readyExit._tag, "Failure"); | |
| 541 | ||
| 542 | yield* desktopWindow.dispatchMenuAction("open-settings"); | |
| 543 | ||
| 544 | assert.equal(yield* Ref.get(scenario.createCalls), 3); | |
| 545 | assert.deepEqual(main.send.mock.calls, [[MENU_ACTION_CHANNEL, "open-settings"]]); | |
| 546 | }).pipe(Effect.provide(scenario.layer)); | |
| 547 | }), | |
| 548 | ); | |
| 348 | 549 | }); |
apps/desktop/src/window/DesktopWindow.ts+144-17| ... | ... | @@ -10,7 +10,6 @@ import type * as Electron from "electron"; |
| 10 | 10 | import * as DesktopAssets from "../app/DesktopAssets.ts"; |
| 11 | 11 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 12 | 12 | import { makeComponentLogger } from "../app/DesktopObservability.ts"; |
| 13 | import * as DesktopState from "../app/DesktopState.ts"; | |
| 14 | 13 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; |
| 15 | 14 | import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; |
| 16 | 15 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| ... | ... | @@ -42,7 +41,6 @@ type WindowTitleBarOptions = Pick< |
| 42 | 41 | type DesktopWindowRuntimeServices = |
| 43 | 42 | | DesktopEnvironment.DesktopEnvironment |
| 44 | 43 | | DesktopAssets.DesktopAssets |
| 45 | | DesktopState.DesktopState | |
| 46 | 44 | | ElectronMenu.ElectronMenu |
| 47 | 45 | | ElectronShell.ElectronShell |
| 48 | 46 | | ElectronTheme.ElectronTheme |
| ... | ... | @@ -61,7 +59,22 @@ export class DesktopWindow extends Context.Service< |
| 61 | 59 | readonly revealOrCreateMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>; |
| 62 | 60 | readonly activate: Effect.Effect<void, DesktopWindowError>; |
| 63 | 61 | readonly createMainIfBackendReady: Effect.Effect<void, DesktopWindowError>; |
| 64 | readonly handleBackendReady: Effect.Effect<void, DesktopWindowError>; | |
| 62 | // Show a lightweight "Connecting to WSL" splash window immediately (wsl-only | |
| 63 | // mode), before the WSL backend that serves the renderer is ready. It is | |
| 64 | // dismissed automatically once the real main window reveals. | |
| 65 | readonly showConnectingSplash: Effect.Effect<void>; | |
| 66 | // Marks the primary backend as ready so `createMainIfBackendReady` and the | |
| 67 | // macOS "activate without windows" path may open the real main window. The | |
| 68 | // renderer now always loads the local client URL (getDesktopUrl) and connects | |
| 69 | // to the backend through the connection layer, so the reported httpBaseUrl is | |
| 70 | // no longer used to point the window at the backend — it is kept only for the | |
| 71 | // readiness log and to preserve the callback contract the backend pool drives. | |
| 72 | readonly handleBackendReady: (httpBaseUrl: URL) => Effect.Effect<void, DesktopWindowError>; | |
| 73 | // Called when the backend transitions back to "not ready" (clean stop, | |
| 74 | // restart, crash). Clears the latch that lets `activate` auto-create a | |
| 75 | // window so a "macOS dock click" while the backend is down doesn't | |
| 76 | // produce a stranded window pointing at nothing. | |
| 77 | readonly handleBackendNotReady: Effect.Effect<void>; | |
| 65 | 78 | readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>; |
| 66 | 79 | readonly syncAppearance: Effect.Effect<void>; |
| 67 | 80 | } |
| ... | ... | @@ -86,6 +99,18 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { |
| 86 | 99 | return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; |
| 87 | 100 | } |
| 88 | 101 | |
| 102 | // A self-contained "Connecting to WSL" splash, shown immediately in wsl-only | |
| 103 | // mode while the WSL backend (which serves the renderer) cold-boots. Inlined as | |
| 104 | // a data URL so it needs no bundled asset and no backend — pure CSS, no JS. | |
| 105 | function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { | |
| 106 | const background = getInitialWindowBackgroundColor(shouldUseDarkColors); | |
| 107 | const label = shouldUseDarkColors ? "#9ca3af" : "#6b7280"; | |
| 108 | const accent = shouldUseDarkColors ? "#f8fafc" : "#1f2937"; | |
| 109 | const track = shouldUseDarkColors ? "rgba(248,250,252,0.18)" : "rgba(31,41,55,0.18)"; | |
| 110 | const html = `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><style>html,body{margin:0;height:100%}body{background:${background};color:${label};font-family:system-ui,-apple-system,'Segoe UI',sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;-webkit-user-select:none;user-select:none;-webkit-app-region:drag}.spinner{width:26px;height:26px;border:3px solid ${track};border-top-color:${accent};border-radius:50%;animation:spin .8s linear infinite}.label{font-size:13px}@keyframes spin{to{transform:rotate(360deg)}}</style></head><body><div class="spinner"></div><div class="label">Connecting to WSL…</div></body></html>`; | |
| 111 | return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; | |
| 112 | } | |
| 113 | ||
| 89 | 114 | export function isSameOriginRendererNavigation(input: { |
| 90 | 115 | readonly applicationUrl: string; |
| 91 | 116 | readonly navigationUrl: string; |
| ... | ... | @@ -177,11 +202,45 @@ export const make = Effect.gen(function* () { |
| 177 | 202 | const electronTheme = yield* ElectronTheme.ElectronTheme; |
| 178 | 203 | const electronWindow = yield* ElectronWindow.ElectronWindow; |
| 179 | 204 | const previewManager = yield* PreviewManager.PreviewManager; |
| 180 | const state = yield* DesktopState.DesktopState; | |
| 205 | // Window-side latch for the primary backend's readiness. Set by | |
| 206 | // handleBackendReady (driven by the pool's onReady callback), cleared | |
| 207 | // by handleBackendNotReady (driven by onShutdown). Only consumed by | |
| 208 | // createMainIfBackendReady, which gates the post-readiness window | |
| 209 | // open in development and the macOS "activate without windows" path. | |
| 210 | const backendReadyRef = yield* Ref.make(false); | |
| 211 | // The transient "Connecting to WSL" splash window, tracked separately so it | |
| 212 | // is never mistaken for the real main window. | |
| 213 | const splashWindowRef = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | |
| 181 | 214 | const context = yield* Effect.context<DesktopWindowRuntimeServices>(); |
| 182 | 215 | const runFork = Effect.runForkWith(context); |
| 183 | 216 | const runPromise = Effect.runPromiseWith(context); |
| 184 | 217 | |
| 218 | const dismissConnectingSplash = Effect.gen(function* () { | |
| 219 | const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); | |
| 220 | if (Option.isSome(splash) && !splash.value.isDestroyed()) { | |
| 221 | splash.value.close(); | |
| 222 | } | |
| 223 | }); | |
| 224 | ||
| 225 | // currentMainOrFirst / focusedMainOrFirst fall back to "any first window", | |
| 226 | // which during WSL-only boot is the connecting splash. The splash is never | |
| 227 | // registered via setMain, so it must be treated as "no real main window" -- | |
| 228 | // otherwise ensureMain/activate/dispatchMenuAction latch onto it and never | |
| 229 | // open (or retry) the real main. That is the failure the pool's swallowed | |
| 230 | // post-readiness window-open error would otherwise strand the user in: | |
| 231 | // splash up, backend ready, no main, and activation only re-reveals splash. | |
| 232 | const withoutSplash = (window: Option.Option<Electron.BrowserWindow>) => | |
| 233 | Ref.get(splashWindowRef).pipe( | |
| 234 | Effect.map((splash) => | |
| 235 | Option.isSome(splash) && Option.isSome(window) && window.value === splash.value | |
| 236 | ? Option.none<Electron.BrowserWindow>() | |
| 237 | : window, | |
| 238 | ), | |
| 239 | ); | |
| 240 | ||
| 241 | const currentMainWindow = electronWindow.currentMainOrFirst.pipe(Effect.flatMap(withoutSplash)); | |
| 242 | const focusedMainWindow = electronWindow.focusedMainOrFirst.pipe(Effect.flatMap(withoutSplash)); | |
| 243 | ||
| 185 | 244 | const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< |
| 186 | 245 | Electron.BrowserWindow, |
| 187 | 246 | DesktopWindowError |
| ... | ... | @@ -402,7 +461,9 @@ export const make = Effect.gen(function* () { |
| 402 | 461 | revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); |
| 403 | 462 | } |
| 404 | 463 | bindFirstRevealTrigger(revealSubscribers, () => { |
| 405 | void runPromise(electronWindow.reveal(window)); | |
| 464 | // Reveal the real window, then close the connecting splash (if any) so the | |
| 465 | // two don't overlap and there's no blank gap between them. | |
| 466 | void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash)); | |
| 406 | 467 | }); |
| 407 | 468 | |
| 408 | 469 | loadApplication(); |
| ... | ... | @@ -426,7 +487,7 @@ export const make = Effect.gen(function* () { |
| 426 | 487 | }).pipe(Effect.withSpan("desktop.window.createMain")); |
| 427 | 488 | |
| 428 | 489 | const ensureMain = Effect.gen(function* () { |
| 429 | const existingWindow = yield* electronWindow.currentMainOrFirst; | |
| 490 | const existingWindow = yield* currentMainWindow; | |
| 430 | 491 | if (Option.isSome(existingWindow)) { |
| 431 | 492 | return existingWindow.value; |
| 432 | 493 | } |
| ... | ... | @@ -440,35 +501,101 @@ export const make = Effect.gen(function* () { |
| 440 | 501 | }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); |
| 441 | 502 | |
| 442 | 503 | const createMainIfBackendReady = Effect.gen(function* () { |
| 443 | const backendReady = yield* Ref.get(state.backendReady); | |
| 504 | const backendReady = yield* Ref.get(backendReadyRef); | |
| 444 | 505 | if (!backendReady) return; |
| 445 | const existingWindow = yield* electronWindow.currentMainOrFirst; | |
| 506 | const existingWindow = yield* currentMainWindow; | |
| 446 | 507 | if (Option.isSome(existingWindow)) return; |
| 447 | 508 | yield* createMain; |
| 448 | 509 | }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); |
| 449 | 510 | |
| 511 | const showConnectingSplash = Effect.gen(function* () { | |
| 512 | // Only when nothing is shown yet: no real window, no existing splash. | |
| 513 | const existingSplash = yield* Ref.get(splashWindowRef); | |
| 514 | if (Option.isSome(existingSplash)) return; | |
| 515 | const existingWindow = yield* electronWindow.currentMainOrFirst; | |
| 516 | if (Option.isSome(existingWindow)) return; | |
| 517 | ||
| 518 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; | |
| 519 | const splash = yield* electronWindow.create({ | |
| 520 | width: 360, | |
| 521 | height: 220, | |
| 522 | resizable: false, | |
| 523 | minimizable: false, | |
| 524 | maximizable: false, | |
| 525 | fullscreenable: false, | |
| 526 | frame: false, | |
| 527 | center: true, | |
| 528 | show: false, | |
| 529 | skipTaskbar: false, | |
| 530 | backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), | |
| 531 | title: environment.displayName, | |
| 532 | webPreferences: { | |
| 533 | contextIsolation: true, | |
| 534 | nodeIntegration: false, | |
| 535 | sandbox: true, | |
| 536 | }, | |
| 537 | }); | |
| 538 | yield* Ref.set(splashWindowRef, Option.some(splash)); | |
| 539 | splash.once("closed", () => { | |
| 540 | void runPromise(Ref.set(splashWindowRef, Option.none())); | |
| 541 | }); | |
| 542 | splash.once("ready-to-show", () => { | |
| 543 | if (!splash.isDestroyed()) { | |
| 544 | splash.show(); | |
| 545 | } | |
| 546 | }); | |
| 547 | void splash.loadURL(buildConnectingSplashDataUrl(shouldUseDarkColors)); | |
| 548 | yield* logWindowInfo("connecting splash shown"); | |
| 549 | }).pipe( | |
| 550 | // The splash is best-effort UX — never let it fail startup. | |
| 551 | Effect.catch((error) => | |
| 552 | logWindowWarning("failed to show connecting splash", { message: error.message }), | |
| 553 | ), | |
| 554 | Effect.withSpan("desktop.window.showConnectingSplash"), | |
| 555 | ); | |
| 556 | ||
| 450 | 557 | return DesktopWindow.of({ |
| 451 | 558 | createMain, |
| 452 | 559 | ensureMain, |
| 453 | 560 | revealOrCreateMain, |
| 454 | 561 | activate: Effect.gen(function* () { |
| 455 | const existingWindow = yield* electronWindow.currentMainOrFirst; | |
| 562 | const existingWindow = yield* currentMainWindow; | |
| 456 | 563 | if (Option.isSome(existingWindow)) { |
| 457 | 564 | yield* electronWindow.reveal(existingWindow.value); |
| 458 | } else { | |
| 459 | yield* createMainIfBackendReady; | |
| 565 | return; | |
| 566 | } | |
| 567 | // No real main window yet. While the backend is still cold-booting, | |
| 568 | // re-reveal the connecting splash so taskbar/dock activation brings it | |
| 569 | // back instead of doing nothing. Once the backend is ready we fall | |
| 570 | // through to (re)create the real main -- including retrying a previously | |
| 571 | // failed open the pool swallowed -- rather than latching onto the splash. | |
| 572 | const backendReady = yield* Ref.get(backendReadyRef); | |
| 573 | if (!backendReady) { | |
| 574 | const splash = yield* Ref.get(splashWindowRef); | |
| 575 | if (Option.isSome(splash)) { | |
| 576 | yield* electronWindow.reveal(splash.value); | |
| 577 | return; | |
| 578 | } | |
| 460 | 579 | } |
| 580 | yield* createMainIfBackendReady; | |
| 461 | 581 | }).pipe(Effect.withSpan("desktop.window.activate")), |
| 462 | 582 | createMainIfBackendReady, |
| 463 | handleBackendReady: Effect.gen(function* () { | |
| 464 | yield* Ref.set(state.backendReady, true); | |
| 465 | yield* logWindowInfo("backend ready", { source: "http" }); | |
| 583 | showConnectingSplash, | |
| 584 | handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { | |
| 585 | yield* Ref.set(backendReadyRef, true); | |
| 586 | yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); | |
| 466 | 587 | yield* createMainIfBackendReady; |
| 467 | }).pipe(Effect.withSpan("desktop.window.handleBackendReady")), | |
| 588 | }), | |
| 589 | handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( | |
| 590 | Effect.withSpan("desktop.window.handleBackendNotReady"), | |
| 591 | ), | |
| 468 | 592 | dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { |
| 469 | 593 | yield* Effect.annotateCurrentSpan({ action }); |
| 470 | const existingWindow = yield* electronWindow.focusedMainOrFirst; | |
| 471 | const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* createMain; | |
| 594 | const existingWindow = yield* focusedMainWindow; | |
| 595 | if (Option.isNone(existingWindow) && !(yield* Ref.get(backendReadyRef))) { | |
| 596 | return; | |
| 597 | } | |
| 598 | const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain; | |
| 472 | 599 | |
| 473 | 600 | const send = () => { |
| 474 | 601 | if (targetWindow.isDestroyed()) return; |
apps/desktop/src/wsl/DesktopWslBackend.test.ts created+199| ... | ... | @@ -0,0 +1,199 @@ |
| 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 Layer from "effect/Layer"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | ||
| 7 | import * as NetService from "@t3tools/shared/Net"; | |
| 8 | ||
| 9 | import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; | |
| 10 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; | |
| 11 | import type { | |
| 12 | DesktopBackendSnapshot, | |
| 13 | DesktopBackendStartConfig, | |
| 14 | } from "../backend/DesktopBackendManager.ts"; | |
| 15 | import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; | |
| 16 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | |
| 17 | import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; | |
| 18 | import * as DesktopWslBackend from "./DesktopWslBackend.ts"; | |
| 19 | ||
| 20 | function makeStubInstance(input: { | |
| 21 | readonly id: DesktopBackendPool.BackendInstanceId; | |
| 22 | readonly label: string; | |
| 23 | readonly snapshot: DesktopBackendSnapshot; | |
| 24 | readonly start?: Effect.Effect<void>; | |
| 25 | }): DesktopBackendPool.DesktopBackendInstance { | |
| 26 | return { | |
| 27 | id: input.id, | |
| 28 | label: Effect.succeed(input.label), | |
| 29 | start: input.start ?? Effect.void, | |
| 30 | stop: () => Effect.void, | |
| 31 | currentConfig: Effect.succeed(Option.none<DesktopBackendStartConfig>()), | |
| 32 | snapshot: Effect.succeed(input.snapshot), | |
| 33 | waitForReady: (_timeout: Duration.Duration) => Effect.succeed(false), | |
| 34 | }; | |
| 35 | } | |
| 36 | ||
| 37 | const idleSnapshot: DesktopBackendSnapshot = { | |
| 38 | desiredRunning: false, | |
| 39 | ready: false, | |
| 40 | activePid: Option.none(), | |
| 41 | restartAttempt: 5, | |
| 42 | restartScheduled: false, | |
| 43 | }; | |
| 44 | ||
| 45 | const primarySnapshot: DesktopBackendSnapshot = { | |
| 46 | desiredRunning: true, | |
| 47 | ready: true, | |
| 48 | activePid: Option.some(123), | |
| 49 | restartAttempt: 0, | |
| 50 | restartScheduled: false, | |
| 51 | }; | |
| 52 | ||
| 53 | const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { | |
| 54 | getState: Effect.die("unexpected getState"), | |
| 55 | backendConfig: Effect.succeed({ | |
| 56 | port: 3773, | |
| 57 | bindHost: "127.0.0.1", | |
| 58 | httpBaseUrl: new URL("http://127.0.0.1:3773"), | |
| 59 | tailscaleServeEnabled: false, | |
| 60 | tailscaleServePort: 443, | |
| 61 | }), | |
| 62 | configureFromSettings: () => Effect.die("unexpected configureFromSettings"), | |
| 63 | setMode: () => Effect.die("unexpected setMode"), | |
| 64 | setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), | |
| 65 | getAdvertisedEndpoints: Effect.succeed([]), | |
| 66 | } satisfies DesktopServerExposure.DesktopServerExposure["Service"]); | |
| 67 | ||
| 68 | const backendConfigurationLayer = Layer.succeed( | |
| 69 | DesktopBackendConfiguration.DesktopBackendConfiguration, | |
| 70 | { | |
| 71 | resolvePrimary: Effect.die("unexpected resolvePrimary"), | |
| 72 | resolvePrimaryLabel: Effect.succeed("Windows"), | |
| 73 | resolveWsl: () => Effect.die("unexpected resolveWsl"), | |
| 74 | } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"], | |
| 75 | ); | |
| 76 | ||
| 77 | const netLayer = Layer.succeed(NetService.NetService, { | |
| 78 | canListenOnHost: () => Effect.succeed(true), | |
| 79 | isPortAvailableOnLoopback: () => Effect.succeed(true), | |
| 80 | reserveLoopbackPort: () => Effect.succeed(41773), | |
| 81 | findAvailablePort: (preferred) => Effect.succeed(preferred), | |
| 82 | } satisfies NetService.NetService["Service"]); | |
| 83 | ||
| 84 | describe("DesktopWslBackend", () => { | |
| 85 | it.effect("clears the stored preflight error when a registered WSL backend becomes ready", () => { | |
| 86 | let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined; | |
| 87 | const primary = makeStubInstance({ | |
| 88 | id: DesktopBackendPool.PRIMARY_INSTANCE_ID, | |
| 89 | label: "Windows", | |
| 90 | snapshot: primarySnapshot, | |
| 91 | }); | |
| 92 | const wsl = makeStubInstance({ | |
| 93 | id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"), | |
| 94 | label: "WSL (Ubuntu)", | |
| 95 | snapshot: primarySnapshot, | |
| 96 | }); | |
| 97 | const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { | |
| 98 | get: (id) => | |
| 99 | Effect.succeed( | |
| 100 | id === DesktopBackendPool.PRIMARY_INSTANCE_ID | |
| 101 | ? Option.some(primary) | |
| 102 | : Option.none<DesktopBackendPool.DesktopBackendInstance>(), | |
| 103 | ), | |
| 104 | list: Effect.succeed([primary]), | |
| 105 | primary: Effect.succeed(primary), | |
| 106 | register: (spec) => | |
| 107 | Effect.sync(() => { | |
| 108 | registeredSpec = spec; | |
| 109 | return wsl; | |
| 110 | }), | |
| 111 | unregister: () => Effect.die("unexpected unregister"), | |
| 112 | } satisfies DesktopBackendPool.DesktopBackendPool["Service"]); | |
| 113 | ||
| 114 | return Effect.gen(function* () { | |
| 115 | const backend = yield* DesktopWslBackend.DesktopWslBackend; | |
| 116 | ||
| 117 | yield* backend.reconcile; | |
| 118 | const spec = registeredSpec; | |
| 119 | assert.isDefined(spec); | |
| 120 | if (spec === undefined) { | |
| 121 | throw new Error("Expected WSL backend registration"); | |
| 122 | } | |
| 123 | const recordFailure = spec.onPreflightFailed; | |
| 124 | const clearFailure = spec.onReady; | |
| 125 | assert.isDefined(recordFailure); | |
| 126 | assert.isDefined(clearFailure); | |
| 127 | if (recordFailure === undefined || clearFailure === undefined) { | |
| 128 | throw new Error("Expected WSL backend callbacks"); | |
| 129 | } | |
| 130 | ||
| 131 | assert.isFalse(yield* recordFailure({ reason: "Node.js not found", fatal: true })); | |
| 132 | assert.deepEqual(yield* backend.lastPreflightError, Option.some("Node.js not found")); | |
| 133 | ||
| 134 | yield* clearFailure(new URL("http://127.0.0.1:41773")); | |
| 135 | assert.deepEqual(yield* backend.lastPreflightError, Option.none()); | |
| 136 | }).pipe( | |
| 137 | Effect.provide( | |
| 138 | DesktopWslBackend.layer.pipe( | |
| 139 | Layer.provideMerge(poolLayer), | |
| 140 | Layer.provideMerge(backendConfigurationLayer), | |
| 141 | Layer.provideMerge(serverExposureLayer), | |
| 142 | Layer.provideMerge(netLayer), | |
| 143 | Layer.provideMerge( | |
| 144 | DesktopAppSettings.layerTest({ | |
| 145 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | |
| 146 | wslBackendEnabled: true, | |
| 147 | wslDistro: "Ubuntu", | |
| 148 | wslOnly: false, | |
| 149 | }), | |
| 150 | ), | |
| 151 | Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), | |
| 152 | ), | |
| 153 | ), | |
| 154 | ); | |
| 155 | }); | |
| 156 | ||
| 157 | it.effect("retries an unchanged WSL instance when it is idle after failed preflight", () => { | |
| 158 | let startCount = 0; | |
| 159 | const primary = makeStubInstance({ | |
| 160 | id: DesktopBackendPool.PRIMARY_INSTANCE_ID, | |
| 161 | label: "Windows", | |
| 162 | snapshot: primarySnapshot, | |
| 163 | }); | |
| 164 | const wsl = makeStubInstance({ | |
| 165 | id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"), | |
| 166 | label: "WSL (Ubuntu)", | |
| 167 | snapshot: idleSnapshot, | |
| 168 | start: Effect.sync(() => { | |
| 169 | startCount += 1; | |
| 170 | }), | |
| 171 | }); | |
| 172 | ||
| 173 | return Effect.gen(function* () { | |
| 174 | const backend = yield* DesktopWslBackend.DesktopWslBackend; | |
| 175 | ||
| 176 | yield* backend.reconcile; | |
| 177 | ||
| 178 | assert.equal(startCount, 1); | |
| 179 | }).pipe( | |
| 180 | Effect.provide( | |
| 181 | DesktopWslBackend.layer.pipe( | |
| 182 | Layer.provideMerge(DesktopBackendPool.layerTest([primary, wsl])), | |
| 183 | Layer.provideMerge(backendConfigurationLayer), | |
| 184 | Layer.provideMerge(serverExposureLayer), | |
| 185 | Layer.provideMerge(netLayer), | |
| 186 | Layer.provideMerge( | |
| 187 | DesktopAppSettings.layerTest({ | |
| 188 | ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, | |
| 189 | wslBackendEnabled: true, | |
| 190 | wslDistro: "Ubuntu", | |
| 191 | wslOnly: false, | |
| 192 | }), | |
| 193 | ), | |
| 194 | Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), | |
| 195 | ), | |
| 196 | ), | |
| 197 | ); | |
| 198 | }); | |
| 199 | }); |
apps/desktop/src/wsl/DesktopWslBackend.ts created+268| ... | ... | @@ -0,0 +1,268 @@ |
| 1 | // Orchestrator that keeps the WSL pool instance in sync with the user's | |
| 2 | // settings. `reconcile` is the single entry point — bootstrap calls it | |
| 3 | // once after the primary backend starts, and the wsl.ts IPC calls it | |
| 4 | // after persisting a `wslBackendEnabled` or `wslDistro` change. The | |
| 5 | // effect is idempotent and never fails: errors (WSL not available, port | |
| 6 | // allocation failed, register failed) get logged and reconcile returns | |
| 7 | // having left the pool in a consistent state (either the previous WSL | |
| 8 | // instance is still running, or none is). | |
| 9 | // | |
| 10 | // The instance id encodes the desired distro selection — `wsl:default` | |
| 11 | // when the user picked "track the WSL default" (settings.wslDistro is | |
| 12 | // null) and `wsl:<distro>` otherwise. Changing the distro setting | |
| 13 | // changes the id, so reconcile unregisters the old instance before | |
| 14 | // registering the new one. The label that the frontend env switcher | |
| 15 | // renders is derived from the same field. | |
| 16 | // | |
| 17 | // Port allocation: each WSL instance gets a freshly scanned port to | |
| 18 | // avoid colliding with the primary or with a previously-registered WSL | |
| 19 | // instance that's still tearing down. The scan only checks loopback | |
| 20 | // (127.0.0.1) since the WSL backend is loopback-only — the primary | |
| 21 | // owns LAN exposure when the user opts in. | |
| 22 | ||
| 23 | import * as Cause from "effect/Cause"; | |
| 24 | import * as Context from "effect/Context"; | |
| 25 | import * as Effect from "effect/Effect"; | |
| 26 | import * as Layer from "effect/Layer"; | |
| 27 | import * as Option from "effect/Option"; | |
| 28 | import * as Ref from "effect/Ref"; | |
| 29 | import * as Semaphore from "effect/Semaphore"; | |
| 30 | ||
| 31 | import * as NetService from "@t3tools/shared/Net"; | |
| 32 | ||
| 33 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | |
| 34 | import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; | |
| 35 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; | |
| 36 | import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; | |
| 37 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | |
| 38 | import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; | |
| 39 | ||
| 40 | // Exported so callers that parse pool ids (e.g. the pickFolder IPC | |
| 41 | // handler in ipc/methods/window.ts) reference the same prefix this | |
| 42 | // module produces. Keeping it inline in two places risks silent | |
| 43 | // divergence if one ever gets renamed. | |
| 44 | export const WSL_INSTANCE_ID_PREFIX = "wsl:"; | |
| 45 | const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`; | |
| 46 | const MAX_TCP_PORT = 65_535; | |
| 47 | ||
| 48 | export class DesktopWslBackend extends Context.Service< | |
| 49 | DesktopWslBackend, | |
| 50 | { | |
| 51 | // Bring the pool in line with the current persisted WSL settings. | |
| 52 | // Idempotent. Never fails (errors are logged); callers can chain it | |
| 53 | // after persisting settings without an error-handling dance. | |
| 54 | readonly reconcile: Effect.Effect<void>; | |
| 55 | // Reason the dual-mode WSL secondary last failed preflight (no node, wrong | |
| 56 | // version, missing build tools), or None. Read by the getWslState IPC so | |
| 57 | // Connections settings can show it inline. None in wsl-only mode (that path | |
| 58 | // surfaces via a dialog + Windows fallback). | |
| 59 | readonly lastPreflightError: Effect.Effect<Option.Option<string>>; | |
| 60 | } | |
| 61 | >()("@t3tools/desktop/wsl/DesktopWslBackend") {} | |
| 62 | ||
| 63 | const { logInfo: logWslBackendInfo, logWarning: logWslBackendWarning } = | |
| 64 | DesktopObservability.makeComponentLogger("desktop-wsl-backend"); | |
| 65 | ||
| 66 | const resolveTargetInstanceId = (distro: string | null): DesktopBackendPool.BackendInstanceId => | |
| 67 | DesktopBackendPool.BackendInstanceId( | |
| 68 | distro === null ? WSL_DEFAULT_DISTRO_ID : `${WSL_INSTANCE_ID_PREFIX}${distro}`, | |
| 69 | ); | |
| 70 | ||
| 71 | const isWslInstanceId = (id: DesktopBackendPool.BackendInstanceId): boolean => | |
| 72 | id.startsWith(WSL_INSTANCE_ID_PREFIX); | |
| 73 | ||
| 74 | const buildLabel = (distro: string | null): string => | |
| 75 | distro === null ? "WSL (default distro)" : `WSL (${distro})`; | |
| 76 | ||
| 77 | // Loopback-only port scan starting one above the primary's port. The | |
| 78 | // WSL backend is reachable via 127.0.0.1 from Windows (wslhost | |
| 79 | // auto-forwards), so we only need to verify the IPv4 loopback can bind. | |
| 80 | const scanForWslPort = Effect.fn("desktop.wslBackend.scanForWslPort")(function* ( | |
| 81 | startPort: number, | |
| 82 | ): Effect.fn.Return<number, NetService.NetError, NetService.NetService> { | |
| 83 | const net = yield* NetService.NetService; | |
| 84 | for (let port = startPort; port <= MAX_TCP_PORT; port += 1) { | |
| 85 | if (yield* net.canListenOnHost(port, "127.0.0.1")) { | |
| 86 | return port; | |
| 87 | } | |
| 88 | } | |
| 89 | return yield* new NetService.NetError({ | |
| 90 | message: `No loopback port available for WSL backend between ${startPort} and ${MAX_TCP_PORT}.`, | |
| 91 | }); | |
| 92 | }); | |
| 93 | ||
| 94 | export const layer = Layer.effect( | |
| 95 | DesktopWslBackend, | |
| 96 | Effect.gen(function* () { | |
| 97 | const pool = yield* DesktopBackendPool.DesktopBackendPool; | |
| 98 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | |
| 99 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | |
| 100 | const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; | |
| 101 | const appSettings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 102 | const net = yield* NetService.NetService; | |
| 103 | // Serialize reconcile so the bootstrap fork and the IPC handlers | |
| 104 | // (setWslBackendEnabled, setWslDistro) can't interleave. Without | |
| 105 | // this, two reconciles could both observe "no WSL instance | |
| 106 | // registered" between their pool reads and both call startNew | |
| 107 | // with different distros, leaving the loser stranded. | |
| 108 | const reconcileMutex = yield* Semaphore.make(1); | |
| 109 | ||
| 110 | // Last fatal preflight failure from the dual-mode WSL *secondary*, surfaced | |
| 111 | // inline in Connections settings. The primary's failure is handled by the | |
| 112 | // pool (dialog + Windows fallback) instead; here the app stays usable on | |
| 113 | // Windows, so we record the reason rather than interrupting. Cleared on any | |
| 114 | // reconcile state change so it reflects the current attempt. | |
| 115 | const preflightErrorRef = yield* Ref.make(Option.none<string>()); | |
| 116 | ||
| 117 | const findExistingWslInstance = pool.list.pipe( | |
| 118 | Effect.map((instances) => instances.find((instance) => isWslInstanceId(instance.id))), | |
| 119 | Effect.map(Option.fromNullishOr), | |
| 120 | ); | |
| 121 | ||
| 122 | const stopExisting = (id: DesktopBackendPool.BackendInstanceId) => | |
| 123 | pool.unregister(id).pipe( | |
| 124 | Effect.catchTags({ | |
| 125 | DesktopBackendPoolCannotUnregisterPrimaryError: (cause) => | |
| 126 | // Should never happen — wsl: ids are not the primary id — but | |
| 127 | // log loudly if the logic ever drifts. | |
| 128 | logWslBackendWarning("refusing to unregister primary as wsl instance", { | |
| 129 | id, | |
| 130 | error: cause.message, | |
| 131 | }), | |
| 132 | }), | |
| 133 | ); | |
| 134 | ||
| 135 | const startNew = Effect.fn("desktop.wslBackend.startNew")(function* (input: { | |
| 136 | readonly distro: string | null; | |
| 137 | }) { | |
| 138 | const primaryConfig = yield* serverExposure.backendConfig; | |
| 139 | const port = yield* scanForWslPort(primaryConfig.port + 1).pipe( | |
| 140 | Effect.provideService(NetService.NetService, net), | |
| 141 | Effect.map((value) => Option.some(value)), | |
| 142 | Effect.catch((error) => | |
| 143 | logWslBackendWarning("could not allocate port for WSL backend", { | |
| 144 | error: error.message, | |
| 145 | }).pipe(Effect.as(Option.none<number>())), | |
| 146 | ), | |
| 147 | ); | |
| 148 | ||
| 149 | if (Option.isNone(port)) { | |
| 150 | return; | |
| 151 | } | |
| 152 | const allocatedPort = port.value; | |
| 153 | ||
| 154 | const targetId = resolveTargetInstanceId(input.distro); | |
| 155 | yield* logWslBackendInfo("registering WSL backend with pool", { | |
| 156 | id: targetId, | |
| 157 | port: allocatedPort, | |
| 158 | distro: input.distro ?? null, | |
| 159 | }); | |
| 160 | ||
| 161 | const instance = yield* pool | |
| 162 | .register({ | |
| 163 | id: targetId, | |
| 164 | label: Effect.succeed(buildLabel(input.distro)), | |
| 165 | configResolve: configuration.resolveWsl({ port: allocatedPort, distro: input.distro }), | |
| 166 | // Dual-mode secondary: record a fatal preflight failure so Connections | |
| 167 | // settings can show why the WSL backend never appeared. No dialog or | |
| 168 | // fallback — Windows is the primary and keeps working. | |
| 169 | onPreflightFailed: (failure) => | |
| 170 | Ref.set(preflightErrorRef, Option.some(failure.reason)).pipe(Effect.as(false)), | |
| 171 | onReady: () => Ref.set(preflightErrorRef, Option.none()), | |
| 172 | }) | |
| 173 | .pipe( | |
| 174 | Effect.map((registered) => Option.some(registered)), | |
| 175 | Effect.catch((error) => | |
| 176 | logWslBackendWarning("WSL backend already registered, skipping start", { | |
| 177 | id: targetId, | |
| 178 | error: error.message, | |
| 179 | }).pipe(Effect.as(Option.none<DesktopBackendPool.DesktopBackendInstance>())), | |
| 180 | ), | |
| 181 | ); | |
| 182 | ||
| 183 | yield* Option.match(instance, { | |
| 184 | onNone: () => Effect.void, | |
| 185 | onSome: (registered) => registered.start, | |
| 186 | }); | |
| 187 | }); | |
| 188 | ||
| 189 | const reconcileBody = Effect.gen(function* () { | |
| 190 | const settings = yield* appSettings.get; | |
| 191 | const available = yield* wslEnvironment.isAvailable; | |
| 192 | const existing = yield* findExistingWslInstance; | |
| 193 | const existingId = Option.map(existing, (instance) => instance.id); | |
| 194 | ||
| 195 | // In wsl-only mode the pool's primary IS the WSL backend (see | |
| 196 | // DesktopBackendConfiguration.resolvePrimary), so the | |
| 197 | // orchestrator skips registering a parallel "wsl:<distro>" | |
| 198 | // secondary. Without this skip we'd spin up two WSL processes | |
| 199 | // on the same distro for users who explicitly asked for one. | |
| 200 | const shouldRun = settings.wslBackendEnabled && available && !settings.wslOnly; | |
| 201 | const targetId = shouldRun | |
| 202 | ? Option.some(resolveTargetInstanceId(settings.wslDistro)) | |
| 203 | : Option.none<DesktopBackendPool.BackendInstanceId>(); | |
| 204 | ||
| 205 | // No-op if the desired state already matches what's registered. | |
| 206 | if (Option.isNone(targetId) && Option.isNone(existingId)) { | |
| 207 | return; | |
| 208 | } | |
| 209 | if ( | |
| 210 | Option.isSome(targetId) && | |
| 211 | Option.isSome(existing) && | |
| 212 | targetId.value === existing.value.id | |
| 213 | ) { | |
| 214 | const existingInstance = existing.value; | |
| 215 | const snapshot = yield* existingInstance.snapshot; | |
| 216 | const isIdle = | |
| 217 | !snapshot.ready && Option.isNone(snapshot.activePid) && !snapshot.restartScheduled; | |
| 218 | if (isIdle) { | |
| 219 | yield* logWslBackendInfo("retrying idle WSL backend", { id: existingInstance.id }); | |
| 220 | yield* Ref.set(preflightErrorRef, Option.none()); | |
| 221 | yield* existingInstance.start; | |
| 222 | } | |
| 223 | return; | |
| 224 | } | |
| 225 | ||
| 226 | // A real state change is happening (start, stop, or distro swap). Clear | |
| 227 | // any stale secondary preflight error so it reflects this fresh attempt; | |
| 228 | // onPreflightFailed re-sets it only if the new secondary exhausts retries. | |
| 229 | yield* Ref.set(preflightErrorRef, Option.none()); | |
| 230 | ||
| 231 | if (Option.isSome(existingId)) { | |
| 232 | yield* logWslBackendInfo("tearing down WSL backend", { id: existingId.value }); | |
| 233 | yield* stopExisting(existingId.value); | |
| 234 | } | |
| 235 | ||
| 236 | if (Option.isSome(targetId)) { | |
| 237 | // Pre-warm the WSL VM before registering so the readiness probe | |
| 238 | // doesn't race wsl.exe's first-spawn cold start. preWarm tolerates | |
| 239 | // distro=null (uses the WSL default) and is bounded by its own | |
| 240 | // timeout, so it's safe to await unconditionally here. | |
| 241 | yield* wslEnvironment.preWarm(settings.wslDistro); | |
| 242 | yield* startNew({ distro: settings.wslDistro }); | |
| 243 | } | |
| 244 | }); | |
| 245 | ||
| 246 | // Top-level safety net. Every internal step today already catches | |
| 247 | // its own failures (port allocation, register, preWarm), so the | |
| 248 | // inferred error type is `never` and this catch is a no-op in | |
| 249 | // steady state. It's here to enforce the file-header contract | |
| 250 | // ("reconcile never fails; errors are logged") if a future change | |
| 251 | // introduces an unhandled failure path — otherwise IPC callers | |
| 252 | // like setWslBackendEnabled would surface it to the renderer as | |
| 253 | // an opaque error. | |
| 254 | const reconcile = reconcileMutex | |
| 255 | .withPermits(1)(reconcileBody) | |
| 256 | .pipe( | |
| 257 | Effect.catchCause((cause) => | |
| 258 | logWslBackendWarning("reconcile failed", { cause: Cause.pretty(cause) }), | |
| 259 | ), | |
| 260 | Effect.withSpan("desktop.wslBackend.reconcile"), | |
| 261 | ); | |
| 262 | ||
| 263 | return DesktopWslBackend.of({ | |
| 264 | reconcile, | |
| 265 | lastPreflightError: Ref.get(preflightErrorRef), | |
| 266 | }); | |
| 267 | }), | |
| 268 | ); |
apps/desktop/src/wsl/DesktopWslEnvironment.test.ts created+237| ... | ... | @@ -0,0 +1,237 @@ |
| 1 | import { describe, it } from "@effect/vitest"; | |
| 2 | import { expect } from "vite-plus/test"; | |
| 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 Sink from "effect/Sink"; | |
| 8 | import * as Stream from "effect/Stream"; | |
| 9 | import * as TestClock from "effect/testing/TestClock"; | |
| 10 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 11 | ||
| 12 | import { | |
| 13 | buildWslNodeEnvPreamble, | |
| 14 | DesktopWslDistroListError, | |
| 15 | formatMissingToolsReason, | |
| 16 | formatNodePtyProbeFailureReason, | |
| 17 | formatWslShellTransportFailureReason, | |
| 18 | parseNodePath, | |
| 19 | parseResolvedPath, | |
| 20 | parseToolchainReport, | |
| 21 | probeWslDistros, | |
| 22 | } from "./DesktopWslEnvironment.ts"; | |
| 23 | ||
| 24 | const encoder = new TextEncoder(); | |
| 25 | ||
| 26 | const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => | |
| 27 | ChildProcessSpawner.make(() => | |
| 28 | Effect.succeed( | |
| 29 | ChildProcessSpawner.makeHandle({ | |
| 30 | pid: ChildProcessSpawner.ProcessId(1), | |
| 31 | exitCode: | |
| 32 | result.exitCode === undefined | |
| 33 | ? Effect.never | |
| 34 | : Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), | |
| 35 | isRunning: Effect.succeed(result.exitCode === undefined), | |
| 36 | kill: () => Effect.void, | |
| 37 | unref: Effect.succeed(Effect.void), | |
| 38 | stdin: Sink.drain, | |
| 39 | stdout: Stream.make(encoder.encode(result.stdout ?? "")), | |
| 40 | stderr: Stream.empty, | |
| 41 | all: Stream.empty, | |
| 42 | getInputFd: () => Sink.drain, | |
| 43 | getOutputFd: () => Stream.empty, | |
| 44 | }), | |
| 45 | ), | |
| 46 | ); | |
| 47 | ||
| 48 | describe("probeWslDistros", () => { | |
| 49 | it.effect("preserves a successful empty distro list", () => | |
| 50 | Effect.gen(function* () { | |
| 51 | const distros = yield* probeWslDistros; | |
| 52 | expect(distros).toEqual([]); | |
| 53 | }).pipe( | |
| 54 | Effect.provideService( | |
| 55 | ChildProcessSpawner.ChildProcessSpawner, | |
| 56 | makeDistroListSpawner({ stdout: "", exitCode: 0 }), | |
| 57 | ), | |
| 58 | ), | |
| 59 | ); | |
| 60 | ||
| 61 | it.effect("fails when the distro-list command exits unsuccessfully", () => | |
| 62 | Effect.gen(function* () { | |
| 63 | const error = yield* probeWslDistros.pipe(Effect.flip); | |
| 64 | expect(error).toBeInstanceOf(DesktopWslDistroListError); | |
| 65 | expect(error.message).toContain("exited with code 1"); | |
| 66 | }).pipe( | |
| 67 | Effect.provideService( | |
| 68 | ChildProcessSpawner.ChildProcessSpawner, | |
| 69 | makeDistroListSpawner({ exitCode: 1 }), | |
| 70 | ), | |
| 71 | ), | |
| 72 | ); | |
| 73 | ||
| 74 | it.effect("fails when the distro-list command times out", () => { | |
| 75 | const layer = Layer.merge( | |
| 76 | TestClock.layer(), | |
| 77 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, makeDistroListSpawner({})), | |
| 78 | ); | |
| 79 | return Effect.gen(function* () { | |
| 80 | const fiber = yield* probeWslDistros.pipe(Effect.flip, Effect.forkScoped); | |
| 81 | yield* Effect.yieldNow; | |
| 82 | yield* TestClock.adjust(Duration.seconds(8)); | |
| 83 | const error = yield* Fiber.join(fiber); | |
| 84 | expect(error).toBeInstanceOf(DesktopWslDistroListError); | |
| 85 | expect(error.message).toContain("timed out"); | |
| 86 | }).pipe(Effect.provide(layer)); | |
| 87 | }); | |
| 88 | }); | |
| 89 | ||
| 90 | describe("formatNodePtyProbeFailureReason", () => { | |
| 91 | it("identifies a packaged build that omitted the Linux node-pty prebuild", () => { | |
| 92 | const reason = formatNodePtyProbeFailureReason(4); | |
| 93 | ||
| 94 | expect(reason).toContain("packaged Linux node-pty binary was not included"); | |
| 95 | expect(reason).toContain("--wsl-prebuild"); | |
| 96 | }); | |
| 97 | ||
| 98 | it("leaves other node-pty load failures to the compatibility diagnostic", () => { | |
| 99 | expect(formatNodePtyProbeFailureReason(1)).toBeNull(); | |
| 100 | }); | |
| 101 | }); | |
| 102 | ||
| 103 | describe("formatWslShellTransportFailureReason", () => { | |
| 104 | it("distinguishes timeouts and spawn failures from normal shell exit codes", () => { | |
| 105 | expect(formatWslShellTransportFailureReason("timeout")).toContain("timed out"); | |
| 106 | expect(formatWslShellTransportFailureReason("spawn")).toContain("could not start wsl.exe"); | |
| 107 | expect(formatWslShellTransportFailureReason("process")).toContain("lost communication"); | |
| 108 | expect(formatWslShellTransportFailureReason(null)).toBeNull(); | |
| 109 | }); | |
| 110 | }); | |
| 111 | ||
| 112 | describe("buildWslNodeEnvPreamble", () => { | |
| 113 | it("passes the required Node engine range into the shared resolver", () => { | |
| 114 | const preamble = buildWslNodeEnvPreamble("^22.16 || ^23.11 || >=24.10"); | |
| 115 | ||
| 116 | expect(preamble).toContain("T3_NODE_ENGINE_RANGE='^22.16 || ^23.11 || >=24.10'"); | |
| 117 | expect(preamble.indexOf("T3_NODE_ENGINE_RANGE=")).toBeLessThan( | |
| 118 | preamble.lastIndexOf("ensure_remote_node_path || true"), | |
| 119 | ); | |
| 120 | }); | |
| 121 | ||
| 122 | it("keeps the shared resolver permissive when no Node engine range is provided", () => { | |
| 123 | expect(buildWslNodeEnvPreamble()).toContain("T3_NODE_ENGINE_RANGE=''"); | |
| 124 | }); | |
| 125 | }); | |
| 126 | ||
| 127 | describe("parseToolchainReport", () => { | |
| 128 | it("returns no missing tools and no node version on empty output", () => { | |
| 129 | expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null }); | |
| 130 | }); | |
| 131 | ||
| 132 | it("collects all missing: lines", () => { | |
| 133 | const stdout = ["missing:make", "missing:g++", "nodeVersion:24.10.0"].join("\n"); | |
| 134 | expect(parseToolchainReport(stdout)).toEqual({ | |
| 135 | missingTools: ["make", "g++"], | |
| 136 | nodeVersion: "24.10.0", | |
| 137 | }); | |
| 138 | }); | |
| 139 | ||
| 140 | it("ignores blank lines and trims whitespace", () => { | |
| 141 | const stdout = [" missing:python3 ", "", " nodeVersion:v22.16.0 "].join("\n"); | |
| 142 | expect(parseToolchainReport(stdout)).toEqual({ | |
| 143 | missingTools: ["python3"], | |
| 144 | nodeVersion: "v22.16.0", | |
| 145 | }); | |
| 146 | }); | |
| 147 | ||
| 148 | it("returns null node version when value after prefix is empty", () => { | |
| 149 | expect(parseToolchainReport("nodeVersion:")).toEqual({ | |
| 150 | missingTools: [], | |
| 151 | nodeVersion: null, | |
| 152 | }); | |
| 153 | }); | |
| 154 | }); | |
| 155 | ||
| 156 | describe("parseNodePath", () => { | |
| 157 | it("extracts the absolute node path from a nodePath: line", () => { | |
| 158 | const stdout = "nodePath:/home/josh/.nvm/versions/node/v22.16.0/bin/node"; | |
| 159 | expect(parseNodePath(stdout)).toBe("/home/josh/.nvm/versions/node/v22.16.0/bin/node"); | |
| 160 | }); | |
| 161 | ||
| 162 | it("returns null when node was not found (empty value after prefix)", () => { | |
| 163 | expect(parseNodePath("nodePath:")).toBeNull(); | |
| 164 | }); | |
| 165 | ||
| 166 | it("returns null when there is no nodePath line at all", () => { | |
| 167 | expect(parseNodePath("missing:node\nnodeVersion:")).toBeNull(); | |
| 168 | }); | |
| 169 | ||
| 170 | it("ignores surrounding noise and trims whitespace", () => { | |
| 171 | const stdout = ["some preamble noise", " nodePath:/usr/bin/node ", "trailing"].join("\n"); | |
| 172 | expect(parseNodePath(stdout)).toBe("/usr/bin/node"); | |
| 173 | }); | |
| 174 | }); | |
| 175 | ||
| 176 | describe("parseResolvedPath", () => { | |
| 177 | it("preserves spaces and apostrophes in the resolved login-shell PATH", () => { | |
| 178 | const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin"; | |
| 179 | expect(parseResolvedPath(`nodePath:/usr/bin/node\nresolvedPath:${resolvedPath}\n`)).toBe( | |
| 180 | resolvedPath, | |
| 181 | ); | |
| 182 | }); | |
| 183 | ||
| 184 | it("accepts CRLF output without retaining the carriage return", () => { | |
| 185 | expect(parseResolvedPath("resolvedPath:/usr/local/bin:/usr/bin\r\n")).toBe( | |
| 186 | "/usr/local/bin:/usr/bin", | |
| 187 | ); | |
| 188 | }); | |
| 189 | ||
| 190 | it("returns null when the resolved PATH is absent or empty", () => { | |
| 191 | expect(parseResolvedPath("nodePath:/usr/bin/node\n")).toBeNull(); | |
| 192 | expect(parseResolvedPath("resolvedPath:\n")).toBeNull(); | |
| 193 | }); | |
| 194 | }); | |
| 195 | ||
| 196 | describe("formatMissingToolsReason", () => { | |
| 197 | it("returns null when everything is present and node is in range", () => { | |
| 198 | expect( | |
| 199 | formatMissingToolsReason({ missingTools: [], nodeVersion: "24.10.0" }, "^24.10"), | |
| 200 | ).toBeNull(); | |
| 201 | }); | |
| 202 | ||
| 203 | it("returns null when range is not specified and tools are present", () => { | |
| 204 | expect(formatMissingToolsReason({ missingTools: [], nodeVersion: "18.0.0" }, null)).toBeNull(); | |
| 205 | }); | |
| 206 | ||
| 207 | it("flags missing node first", () => { | |
| 208 | const reason = formatMissingToolsReason( | |
| 209 | { missingTools: ["node", "make"], nodeVersion: null }, | |
| 210 | "^24.10", | |
| 211 | ); | |
| 212 | expect(reason).toContain("node"); | |
| 213 | expect(reason).toContain("^24.10"); | |
| 214 | expect(reason).toContain("make"); | |
| 215 | expect(reason).toContain("nvm"); | |
| 216 | }); | |
| 217 | ||
| 218 | it("flags an out-of-range node version with the actual version surfaced", () => { | |
| 219 | const reason = formatMissingToolsReason( | |
| 220 | { missingTools: [], nodeVersion: "20.0.0" }, | |
| 221 | "^24.10 || ^22.16", | |
| 222 | ); | |
| 223 | expect(reason).toContain("node 20.0.0"); | |
| 224 | expect(reason).toContain("requires ^24.10 || ^22.16"); | |
| 225 | }); | |
| 226 | ||
| 227 | it("flags missing build tools without node when node is fine", () => { | |
| 228 | const reason = formatMissingToolsReason( | |
| 229 | { missingTools: ["g++", "python3"], nodeVersion: "24.10.0" }, | |
| 230 | "^24.10", | |
| 231 | ); | |
| 232 | expect(reason).toContain("g++"); | |
| 233 | expect(reason).toContain("python3"); | |
| 234 | expect(reason).toContain("build-essential"); | |
| 235 | expect(reason).not.toContain("nvm"); | |
| 236 | }); | |
| 237 | }); |
apps/desktop/src/wsl/DesktopWslEnvironment.ts created+849| ... | ... | @@ -0,0 +1,849 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Duration from "effect/Duration"; | |
| 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 Path from "effect/Path"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 11 | ||
| 12 | import { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel"; | |
| 13 | import { satisfiesSemverRange } from "@t3tools/shared/semver"; | |
| 14 | ||
| 15 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 16 | import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts"; | |
| 17 | ||
| 18 | const PROCESS_TERMINATE_GRACE = Duration.seconds(1); | |
| 19 | const LIST_TIMEOUT = Duration.seconds(8); | |
| 20 | const PRE_WARM_TIMEOUT = Duration.seconds(10); | |
| 21 | const WSLPATH_TIMEOUT = Duration.seconds(10); | |
| 22 | const PROBE_TIMEOUT = Duration.seconds(10); | |
| 23 | const TOOLCHAIN_TIMEOUT = Duration.seconds(10); | |
| 24 | const BUILD_TIMEOUT = Duration.minutes(5); | |
| 25 | const USER_HOME_TIMEOUT = Duration.seconds(5); | |
| 26 | const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12; | |
| 27 | const BUILD_TRANSPORT_RETRY_LIMIT = 2; | |
| 28 | ||
| 29 | export interface EnsureWslNodePtyOptions { | |
| 30 | readonly allowBuild?: boolean; | |
| 31 | readonly nodeEngineRange?: string | null; | |
| 32 | } | |
| 33 | ||
| 34 | export type EnsureWslNodePtyResult = | |
| 35 | | { readonly ok: true; readonly nodePath: string; readonly resolvedPath: string } | |
| 36 | | { | |
| 37 | readonly ok: false; | |
| 38 | readonly reason: string; | |
| 39 | readonly fatal: boolean; | |
| 40 | readonly retryLimit?: number; | |
| 41 | }; | |
| 42 | ||
| 43 | export class DesktopWslDistroListError extends Schema.TaggedErrorClass<DesktopWslDistroListError>()( | |
| 44 | "DesktopWslDistroListError", | |
| 45 | { reason: Schema.String }, | |
| 46 | ) { | |
| 47 | override get message(): string { | |
| 48 | return this.reason; | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | const isDesktopWslDistroListError = Schema.is(DesktopWslDistroListError); | |
| 53 | ||
| 54 | export class DesktopWslEnvironment extends Context.Service< | |
| 55 | DesktopWslEnvironment, | |
| 56 | { | |
| 57 | readonly isAvailable: Effect.Effect<boolean>; | |
| 58 | // Best-effort enumeration for renderer UX. Backend health checks must use | |
| 59 | // probeDistros so a transient command failure is not mistaken for a | |
| 60 | // successful empty installation. | |
| 61 | readonly listDistros: Effect.Effect<readonly WslDistro[]>; | |
| 62 | readonly probeDistros: Effect.Effect<readonly WslDistro[], DesktopWslDistroListError>; | |
| 63 | readonly preWarm: (distro: string | null) => Effect.Effect<void>; | |
| 64 | readonly windowsToWslPath: ( | |
| 65 | distro: string | null, | |
| 66 | windowsPath: string, | |
| 67 | ) => Effect.Effect<Option.Option<string>>; | |
| 68 | // Resolves the user's Linux home dir inside the chosen distro (e.g. | |
| 69 | // "/home/josh"). Used by the folder picker to expand `~` correctly. | |
| 70 | readonly getUserHome: (distro: string | null) => Effect.Effect<Option.Option<string>>; | |
| 71 | // Resolves the WSL distro's IPv4 address on the WSL vEthernet adapter | |
| 72 | // (e.g. "172.x.x.x"). The orchestrator uses this for the WSL backend's | |
| 73 | // httpBaseUrl so the renderer can reach it without relying on wslhost's | |
| 74 | // localhost→WSL automatic forwarding, which is flaky in practice | |
| 75 | // (the backend can be listening for 30+ seconds before wslhost starts | |
| 76 | // forwarding 127.0.0.1:port to WSL-side localhost). | |
| 77 | readonly getDistroIp: (distro: string | null) => Effect.Effect<Option.Option<string>>; | |
| 78 | readonly ensureNodePty: ( | |
| 79 | distro: string | null, | |
| 80 | windowsRepoRoot: string, | |
| 81 | options?: EnsureWslNodePtyOptions, | |
| 82 | ) => Effect.Effect<EnsureWslNodePtyResult>; | |
| 83 | } | |
| 84 | >()("@t3tools/desktop/wsl/DesktopWslEnvironment") {} | |
| 85 | ||
| 86 | const buildDistroArgs = (distro: string | null): ReadonlyArray<string> => | |
| 87 | distro ? ["-d", distro] : []; | |
| 88 | ||
| 89 | const concatChunks = (arrays: ReadonlyArray<Uint8Array>): Uint8Array => { | |
| 90 | let totalLength = 0; | |
| 91 | for (const arr of arrays) totalLength += arr.byteLength; | |
| 92 | const out = new Uint8Array(totalLength); | |
| 93 | let offset = 0; | |
| 94 | for (const arr of arrays) { | |
| 95 | out.set(arr, offset); | |
| 96 | offset += arr.byteLength; | |
| 97 | } | |
| 98 | return out; | |
| 99 | }; | |
| 100 | ||
| 101 | const decodeUtf8 = (bytes: Uint8Array): string => new TextDecoder("utf-8").decode(bytes); | |
| 102 | ||
| 103 | interface ShellResult { | |
| 104 | readonly exitCode: number; | |
| 105 | readonly stdout: string; | |
| 106 | readonly stderr: string; | |
| 107 | readonly transportFailure: "timeout" | "spawn" | "process" | null; | |
| 108 | } | |
| 109 | ||
| 110 | const TIMEOUT_RESULT: ShellResult = { | |
| 111 | exitCode: 124, | |
| 112 | stdout: "", | |
| 113 | stderr: "\n[timeout]", | |
| 114 | transportFailure: "timeout", | |
| 115 | }; | |
| 116 | ||
| 117 | export const formatWslShellTransportFailureReason = ( | |
| 118 | failure: ShellResult["transportFailure"], | |
| 119 | ): string | null => { | |
| 120 | switch (failure) { | |
| 121 | case "timeout": | |
| 122 | return "WSL backend preflight timed out while probing for Node.js. WSL may be slow to start; retry, or check that the distro is healthy."; | |
| 123 | case "spawn": | |
| 124 | return "WSL backend preflight could not start wsl.exe to probe for Node.js. Check that WSL is installed and the distro is accessible."; | |
| 125 | case "process": | |
| 126 | return "WSL backend preflight lost communication with wsl.exe while probing for Node.js. Retry, or check that the distro is healthy."; | |
| 127 | case null: | |
| 128 | return null; | |
| 129 | } | |
| 130 | }; | |
| 131 | ||
| 132 | // Reuse the SSH remote resolver so WSL and SSH discover version-managed Node | |
| 133 | // the same way. Passing the engine range lets the resolver fall through to | |
| 134 | // version managers like nvm when a system node exists but is too old. | |
| 135 | export const buildWslNodeEnvPreamble = ( | |
| 136 | nodeEngineRange?: string | null, | |
| 137 | ): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })} | |
| 138 | ensure_remote_node_path || true | |
| 139 | `; | |
| 140 | ||
| 141 | // wsl.exe re-escapes args before forwarding them to the Linux side, which | |
| 142 | // mangles quotes inside `bash -lc "<script>"`. Pipe the script via stdin to | |
| 143 | // avoid passing it on the command line at all. | |
| 144 | const runWslShell = ( | |
| 145 | distro: string | null, | |
| 146 | bashScript: string, | |
| 147 | timeout: Duration.Duration, | |
| 148 | options: EnsureWslNodePtyOptions = {}, | |
| 149 | ): Effect.Effect<ShellResult, never, ChildProcessSpawner.ChildProcessSpawner> => { | |
| 150 | const spawner = ChildProcessSpawner.ChildProcessSpawner; | |
| 151 | // -l picks up profile-managed PATH; the shared resolver covers supported | |
| 152 | // version managers that non-interactive login shells can miss. -s so bash | |
| 153 | // reads the script from stdin. | |
| 154 | const command = ChildProcess.make( | |
| 155 | "wsl.exe", | |
| 156 | [...buildDistroArgs(distro), "--", "bash", "-l", "-s"], | |
| 157 | { | |
| 158 | stdin: Stream.encodeText( | |
| 159 | Stream.make(`${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}`), | |
| 160 | ), | |
| 161 | stdout: "pipe", | |
| 162 | stderr: "pipe", | |
| 163 | killSignal: "SIGTERM", | |
| 164 | forceKillAfter: PROCESS_TERMINATE_GRACE, | |
| 165 | }, | |
| 166 | ); | |
| 167 | ||
| 168 | return Effect.scoped( | |
| 169 | Effect.gen(function* () { | |
| 170 | const spawnerService = yield* spawner; | |
| 171 | const spawnResult = yield* spawnerService.spawn(command).pipe( | |
| 172 | Effect.match({ | |
| 173 | onFailure: (error) => ({ _tag: "Failure", error }) as const, | |
| 174 | onSuccess: (handle) => ({ _tag: "Success", handle }) as const, | |
| 175 | }), | |
| 176 | ); | |
| 177 | if (spawnResult._tag === "Failure") { | |
| 178 | return { | |
| 179 | exitCode: 127, | |
| 180 | stdout: "", | |
| 181 | stderr: `\n${spawnResult.error.message}`, | |
| 182 | transportFailure: "spawn", | |
| 183 | } satisfies ShellResult; | |
| 184 | } | |
| 185 | const handle = spawnResult.handle; | |
| 186 | // Drain stdout and stderr concurrently so neither pipe buffer can fill | |
| 187 | // and stall the child (node-gyp rebuild emits large output on both). | |
| 188 | const [stdoutBytes, stderrBytes, exitCode] = yield* Effect.all( | |
| 189 | [Stream.runCollect(handle.stdout), Stream.runCollect(handle.stderr), handle.exitCode], | |
| 190 | { concurrency: "unbounded" }, | |
| 191 | ); | |
| 192 | return { | |
| 193 | exitCode: exitCode as unknown as number, | |
| 194 | stdout: decodeUtf8(concatChunks(stdoutBytes)), | |
| 195 | stderr: decodeUtf8(concatChunks(stderrBytes)), | |
| 196 | transportFailure: null, | |
| 197 | } satisfies ShellResult; | |
| 198 | }), | |
| 199 | ).pipe( | |
| 200 | Effect.timeoutOption(timeout), | |
| 201 | Effect.map(Option.getOrElse((): ShellResult => TIMEOUT_RESULT)), | |
| 202 | Effect.catch((error) => | |
| 203 | Effect.succeed<ShellResult>({ | |
| 204 | exitCode: 127, | |
| 205 | stdout: "", | |
| 206 | stderr: `\n${error.message}`, | |
| 207 | transportFailure: "process", | |
| 208 | }), | |
| 209 | ), | |
| 210 | ); | |
| 211 | }; | |
| 212 | ||
| 213 | const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; | |
| 214 | ||
| 215 | const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; | |
| 216 | ||
| 217 | export const formatNodePtyProbeFailureReason = (exitCode: number): string | null => | |
| 218 | exitCode === NODE_PTY_PREBUILD_MISSING_EXIT_CODE | |
| 219 | ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Rebuild the Windows artifact with `--wsl-prebuild <path-to-linux-pty.node>` or install a build that includes WSL support." | |
| 220 | : null; | |
| 221 | ||
| 222 | const NODE_PTY_PROBE_SCRIPT = ( | |
| 223 | linuxServerDir: string, | |
| 224 | ) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)" | |
| 225 | printf 'resolvedPath:%s\\n' "$PATH" | |
| 226 | cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 | |
| 227 | // The server bundle externalizes its deps to node_modules, and the WSL Node | |
| 228 | // can't read inside app.asar, so confirm those deps are unpacked on the real | |
| 229 | // filesystem before reporting the backend healthy. "effect" is the framework | |
| 230 | // every server module imports; resolving it validates the whole node_modules | |
| 231 | // tree. Exit 3 marks this distinct from a node-pty problem so the caller can | |
| 232 | // report it accurately instead of letting the server crash on | |
| 233 | // ERR_MODULE_NOT_FOUND at launch (which, in wsl-only mode, would just fail to | |
| 234 | // launch with no fallback). | |
| 235 | try { require.resolve("effect"); } catch (_e) { process.exit(3); } | |
| 236 | const fs = require("node:fs"); | |
| 237 | const path = require("node:path"); | |
| 238 | const pkgDir = path.dirname(require.resolve("node-pty/package.json")); | |
| 239 | // node-pty 1.x is N-API based, so a single Linux pty.node is ABI-stable across | |
| 240 | // Node versions — require() succeeding IS the real compatibility test. Compare | |
| 241 | // only arch and node-pty version (a stale binary from a different node-pty), | |
| 242 | // NOT process.versions.modules: that would reject a perfectly loadable prebuilt | |
| 243 | // whenever the user's WSL Node ABI differs from the build's, defeating the | |
| 244 | // whole point of shipping one prebuilt for all Node versions. | |
| 245 | const expected = { | |
| 246 | arch: process.arch, | |
| 247 | nodePtyVersion: require("node-pty/package.json").version, | |
| 248 | }; | |
| 249 | const prebuildDir = path.join(pkgDir, "prebuilds", "linux-" + process.arch); | |
| 250 | const marker = path.join(prebuildDir, "t3code-wsl-node-pty.json"); | |
| 251 | const binary = path.join(prebuildDir, "pty.node"); | |
| 252 | if (!fs.existsSync(marker) || !fs.existsSync(binary)) process.exit(${NODE_PTY_PREBUILD_MISSING_EXIT_CODE}); | |
| 253 | require("node-pty"); | |
| 254 | const actual = JSON.parse(fs.readFileSync(marker, "utf8")); | |
| 255 | for (const key of Object.keys(expected)) { | |
| 256 | if (actual[key] !== expected[key]) process.exit(2); | |
| 257 | } | |
| 258 | NODE`; | |
| 259 | ||
| 260 | const TOOLCHAIN_CHECK_SCRIPT = [ | |
| 261 | "for tool in node make g++ python3; do", | |
| 262 | ' command -v "$tool" >/dev/null 2>&1 || echo "missing:$tool"', | |
| 263 | "done", | |
| 264 | "if command -v node >/dev/null 2>&1; then", | |
| 265 | ` ver="$(node -p 'process.versions.node' 2>/dev/null)"`, | |
| 266 | ' if [ -n "$ver" ]; then printf "nodeVersion:%s\\n" "$ver"; fi', | |
| 267 | "fi", | |
| 268 | ].join("\n"); | |
| 269 | ||
| 270 | const NODE_PTY_BUILD_SCRIPT = (linuxServerDir: string) => | |
| 271 | [ | |
| 272 | "set -e", | |
| 273 | `cd ${shellQuote(linuxServerDir)}`, | |
| 274 | `pkg_dir=$(node -p "require('node:path').dirname(require.resolve('node-pty/package.json'))")`, | |
| 275 | `arch=$(node -p "process.arch")`, | |
| 276 | `modules=$(node -p "process.versions.modules")`, | |
| 277 | `node_pty_version=$(node -p "require('node-pty/package.json').version")`, | |
| 278 | `cd "$pkg_dir"`, | |
| 279 | "npx --yes node-gyp rebuild", | |
| 280 | `prebuild_dir="prebuilds/linux-$arch"`, | |
| 281 | `mkdir -p "$prebuild_dir"`, | |
| 282 | `cp build/Release/pty.node "$prebuild_dir/pty.node"`, | |
| 283 | `printf '{"arch":"%s","modules":"%s","nodePtyVersion":"%s"}\\n' "$arch" "$modules" "$node_pty_version" > "$prebuild_dir/t3code-wsl-node-pty.json"`, | |
| 284 | `node -e 'require("node-pty")'`, | |
| 285 | ].join("\n"); | |
| 286 | ||
| 287 | export interface ToolchainReport { | |
| 288 | readonly missingTools: ReadonlyArray<string>; | |
| 289 | readonly nodeVersion: string | null; | |
| 290 | } | |
| 291 | ||
| 292 | export const parseToolchainReport = (stdout: string): ToolchainReport => { | |
| 293 | const lines = stdout | |
| 294 | .split("\n") | |
| 295 | .map((line) => line.trim()) | |
| 296 | .filter((line) => line.length > 0); | |
| 297 | const missingTools = lines | |
| 298 | .filter((line) => line.startsWith("missing:")) | |
| 299 | .map((line) => line.slice("missing:".length)); | |
| 300 | const nodeVersionLine = lines.find((line) => line.startsWith("nodeVersion:")); | |
| 301 | const nodeVersion = nodeVersionLine | |
| 302 | ? nodeVersionLine.slice("nodeVersion:".length).trim() || null | |
| 303 | : null; | |
| 304 | return { missingTools, nodeVersion }; | |
| 305 | }; | |
| 306 | ||
| 307 | // Pulls the absolute node path the WSL distro resolved after the shared remote | |
| 308 | // resolver repaired PATH. Returns null when no node was found, which the caller | |
| 309 | // turns into an actionable "install Node" message instead of a confusing | |
| 310 | // node-pty error. | |
| 311 | export const parseNodePath = (stdout: string): string | null => { | |
| 312 | const path = stdout | |
| 313 | .split("\n") | |
| 314 | .map((line) => line.trim()) | |
| 315 | .filter((line) => line.startsWith("nodePath:")) | |
| 316 | .map((line) => line.slice("nodePath:".length).trim()) | |
| 317 | .find((value) => value.length > 0); | |
| 318 | return path ?? null; | |
| 319 | }; | |
| 320 | ||
| 321 | // Captures the login-shell PATH after the shared resolver has loaded version | |
| 322 | // managers. Preserve the value byte-for-byte apart from a Windows-style CR so | |
| 323 | // paths containing spaces or apostrophes can be forwarded as one env argv. | |
| 324 | export const parseResolvedPath = (stdout: string): string | null => { | |
| 325 | const prefix = "resolvedPath:"; | |
| 326 | const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix)); | |
| 327 | if (line === undefined) return null; | |
| 328 | const resolvedPath = line.slice(prefix.length).replace(/\r$/, ""); | |
| 329 | return resolvedPath.length > 0 ? resolvedPath : null; | |
| 330 | }; | |
| 331 | ||
| 332 | export const formatMissingToolsReason = ( | |
| 333 | report: ToolchainReport, | |
| 334 | requiredRange: string | null, | |
| 335 | ): string | null => { | |
| 336 | const nodeMissing = report.missingTools.includes("node"); | |
| 337 | const nodeOutOfRange = | |
| 338 | !nodeMissing && | |
| 339 | requiredRange !== null && | |
| 340 | report.nodeVersion !== null && | |
| 341 | !satisfiesSemverRange(report.nodeVersion, requiredRange); | |
| 342 | const buildToolsMissing = report.missingTools.filter((tool) => tool !== "node"); | |
| 343 | ||
| 344 | if (!nodeMissing && !nodeOutOfRange && buildToolsMissing.length === 0) { | |
| 345 | return null; | |
| 346 | } | |
| 347 | ||
| 348 | const issues: string[] = []; | |
| 349 | const remediations: string[] = []; | |
| 350 | ||
| 351 | if (nodeMissing) { | |
| 352 | issues.push("node"); | |
| 353 | remediations.push( | |
| 354 | `Node.js${requiredRange ? ` satisfying \`${requiredRange}\`` : " 18+"} (e.g. via nvm)`, | |
| 355 | ); | |
| 356 | } else if (nodeOutOfRange) { | |
| 357 | issues.push(`node ${report.nodeVersion} (requires ${requiredRange})`); | |
| 358 | remediations.push( | |
| 359 | `a newer Node.js satisfying \`${requiredRange}\` (e.g. \`nvm install 24 && nvm alias default 24\`)`, | |
| 360 | ); | |
| 361 | } | |
| 362 | ||
| 363 | if (buildToolsMissing.length > 0) { | |
| 364 | issues.push(...buildToolsMissing); | |
| 365 | remediations.push( | |
| 366 | "the build toolchain (e.g. `sudo apt install -y build-essential python3` on Ubuntu/Debian)", | |
| 367 | ); | |
| 368 | } | |
| 369 | ||
| 370 | return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`; | |
| 371 | }; | |
| 372 | ||
| 373 | const ensureNodePtyImpl = ( | |
| 374 | distro: string | null, | |
| 375 | windowsRepoRoot: string, | |
| 376 | windowsToWslPath: ( | |
| 377 | distro: string | null, | |
| 378 | windowsPath: string, | |
| 379 | ) => Effect.Effect<Option.Option<string>>, | |
| 380 | options: EnsureWslNodePtyOptions = {}, | |
| 381 | ): Effect.Effect<EnsureWslNodePtyResult, never, ChildProcessSpawner.ChildProcessSpawner> => | |
| 382 | Effect.gen(function* () { | |
| 383 | const linuxRepoRootOption = yield* windowsToWslPath(distro, windowsRepoRoot); | |
| 384 | if (Option.isNone(linuxRepoRootOption)) { | |
| 385 | return { | |
| 386 | ok: false, | |
| 387 | reason: `wslpath conversion failed for ${windowsRepoRoot}`, | |
| 388 | fatal: false, | |
| 389 | } as const; | |
| 390 | } | |
| 391 | const linuxRepoRoot = linuxRepoRootOption.value; | |
| 392 | // node-pty lives in the apps/server workspace's node_modules; resolve from | |
| 393 | // there rather than the monorepo root, where Bun's hoist layout omits it. | |
| 394 | const linuxServerDir = `${linuxRepoRoot}/apps/server`; | |
| 395 | ||
| 396 | const probe = yield* runWslShell( | |
| 397 | distro, | |
| 398 | NODE_PTY_PROBE_SCRIPT(linuxServerDir), | |
| 399 | PROBE_TIMEOUT, | |
| 400 | options, | |
| 401 | ); | |
| 402 | const nodePath = parseNodePath(probe.stdout); | |
| 403 | const resolvedPath = parseResolvedPath(probe.stdout); | |
| 404 | ||
| 405 | const transportFailureReason = formatWslShellTransportFailureReason(probe.transportFailure); | |
| 406 | if (transportFailureReason !== null) { | |
| 407 | return { ok: false, reason: transportFailureReason, fatal: false } as const; | |
| 408 | } | |
| 409 | ||
| 410 | // No node at all, even after the shared resolver repaired PATH. Surface | |
| 411 | // the specific, actionable toolchain message rather than a confusing | |
| 412 | // node-pty error, and don't try to build. | |
| 413 | if (nodePath === null) { | |
| 414 | const toolchainCheck = yield* runWslShell( | |
| 415 | distro, | |
| 416 | TOOLCHAIN_CHECK_SCRIPT, | |
| 417 | TOOLCHAIN_TIMEOUT, | |
| 418 | options, | |
| 419 | ); | |
| 420 | const toolchainTransportFailure = formatWslShellTransportFailureReason( | |
| 421 | toolchainCheck.transportFailure, | |
| 422 | ); | |
| 423 | if (toolchainTransportFailure !== null) { | |
| 424 | return { | |
| 425 | ok: false, | |
| 426 | reason: toolchainTransportFailure, | |
| 427 | fatal: false, | |
| 428 | retryLimit: TOOLCHAIN_TRANSPORT_RETRY_LIMIT, | |
| 429 | } as const; | |
| 430 | } | |
| 431 | const report = parseToolchainReport(toolchainCheck.stdout); | |
| 432 | const reason = | |
| 433 | formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null) ?? | |
| 434 | "Node.js was not found in the WSL distro. Install it (e.g. via nvm) and restart the desktop app."; | |
| 435 | return { ok: false, reason, fatal: true } as const; | |
| 436 | } | |
| 437 | ||
| 438 | if (resolvedPath === null) { | |
| 439 | return { | |
| 440 | ok: false, | |
| 441 | reason: "WSL login-shell PATH could not be resolved during backend preflight.", | |
| 442 | fatal: true, | |
| 443 | } as const; | |
| 444 | } | |
| 445 | ||
| 446 | // Server dependencies (e.g. "effect") couldn't be resolved on the WSL | |
| 447 | // filesystem — a packaging regression, since the server bundle needs its | |
| 448 | // node_modules unpacked from the asar. Fatal so wsl-only mode falls back to | |
| 449 | // Windows and dual mode surfaces the reason inline, instead of the server | |
| 450 | // crash-looping on ERR_MODULE_NOT_FOUND once it actually launches. | |
| 451 | if (probe.exitCode === 3) { | |
| 452 | return { | |
| 453 | ok: false, | |
| 454 | reason: | |
| 455 | "WSL server dependencies could not be loaded (for example \"effect\"). The server's bundled node_modules is not readable by the WSL distro's Node — this is a packaging problem with this build. Please report it.", | |
| 456 | fatal: true, | |
| 457 | } as const; | |
| 458 | } | |
| 459 | ||
| 460 | if (probe.exitCode === 0) return { ok: true, nodePath, resolvedPath } as const; | |
| 461 | ||
| 462 | if (options.allowBuild !== true) { | |
| 463 | const packagedProbeFailure = formatNodePtyProbeFailureReason(probe.exitCode); | |
| 464 | if (packagedProbeFailure !== null) { | |
| 465 | return { ok: false, reason: packagedProbeFailure, fatal: true } as const; | |
| 466 | } | |
| 467 | } | |
| 468 | ||
| 469 | // node is present but node-pty's native module didn't load. | |
| 470 | const toolchainCheck = yield* runWslShell( | |
| 471 | distro, | |
| 472 | TOOLCHAIN_CHECK_SCRIPT, | |
| 473 | TOOLCHAIN_TIMEOUT, | |
| 474 | options, | |
| 475 | ); | |
| 476 | const toolchainTransportFailure = formatWslShellTransportFailureReason( | |
| 477 | toolchainCheck.transportFailure, | |
| 478 | ); | |
| 479 | if (toolchainTransportFailure !== null) { | |
| 480 | return { | |
| 481 | ok: false, | |
| 482 | reason: toolchainTransportFailure, | |
| 483 | fatal: false, | |
| 484 | retryLimit: TOOLCHAIN_TRANSPORT_RETRY_LIMIT, | |
| 485 | } as const; | |
| 486 | } | |
| 487 | const report = parseToolchainReport(toolchainCheck.stdout); | |
| 488 | ||
| 489 | if (options.allowBuild !== true) { | |
| 490 | // Packaged builds ship a prebuilt Linux node-pty, so no compiler, node-gyp, | |
| 491 | // or network is needed — and we must not nag the user to install build | |
| 492 | // tools they don't need. Still surface a missing/too-old Node (both the | |
| 493 | // prebuilt and the server require a compatible Node); otherwise reaching | |
| 494 | // here means the bundled binary itself couldn't load, which is almost | |
| 495 | // always an unsupported CPU architecture or incompatible system libraries. | |
| 496 | const nodeOnlyReason = formatMissingToolsReason( | |
| 497 | { | |
| 498 | missingTools: report.missingTools.filter((tool) => tool === "node"), | |
| 499 | nodeVersion: report.nodeVersion, | |
| 500 | }, | |
| 501 | options.nodeEngineRange?.trim() || null, | |
| 502 | ); | |
| 503 | return { | |
| 504 | ok: false, | |
| 505 | reason: | |
| 506 | nodeOnlyReason ?? | |
| 507 | "The bundled WSL backend binary (node-pty) could not be loaded in this distro. This usually means an unsupported CPU architecture or incompatible system libraries (glibc). Use a glibc-based x64/arm64 WSL distro such as Ubuntu; if you already are, please report this with your distro and the output of `uname -m`.", | |
| 508 | fatal: true, | |
| 509 | } as const; | |
| 510 | } | |
| 511 | ||
| 512 | // Dev only: no prebuilt is bundled in a checkout, so compile node-pty from | |
| 513 | // source. Run the toolchain check first so a missing compiler or out-of-range | |
| 514 | // Node surfaces a specific, actionable message instead of an opaque node-gyp | |
| 515 | // failure. Developers have the toolchain; end users never reach this path. | |
| 516 | const missingReason = formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null); | |
| 517 | if (missingReason !== null) { | |
| 518 | return { ok: false, reason: missingReason, fatal: true } as const; | |
| 519 | } | |
| 520 | ||
| 521 | const build = yield* runWslShell( | |
| 522 | distro, | |
| 523 | NODE_PTY_BUILD_SCRIPT(linuxServerDir), | |
| 524 | BUILD_TIMEOUT, | |
| 525 | options, | |
| 526 | ); | |
| 527 | const buildTransportFailure = formatWslShellTransportFailureReason(build.transportFailure); | |
| 528 | if (buildTransportFailure !== null) { | |
| 529 | return { | |
| 530 | ok: false, | |
| 531 | reason: buildTransportFailure, | |
| 532 | fatal: false, | |
| 533 | retryLimit: BUILD_TRANSPORT_RETRY_LIMIT, | |
| 534 | } as const; | |
| 535 | } | |
| 536 | if (build.exitCode === 0) return { ok: true, nodePath, resolvedPath } as const; | |
| 537 | const trimmedTail = `${build.stdout}${build.stderr}`.trim().slice(-500); | |
| 538 | return { | |
| 539 | ok: false, | |
| 540 | reason: `node-pty Linux build failed (exit ${build.exitCode}): ${trimmedTail || "no stderr captured"}`, | |
| 541 | fatal: true, | |
| 542 | } as const; | |
| 543 | }); | |
| 544 | ||
| 545 | export const probeWslDistros: Effect.Effect< | |
| 546 | readonly WslDistro[], | |
| 547 | DesktopWslDistroListError, | |
| 548 | ChildProcessSpawner.ChildProcessSpawner | |
| 549 | > = Effect.scoped( | |
| 550 | Effect.gen(function* () { | |
| 551 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 552 | const command = ChildProcess.make("wsl.exe", ["--list", "--verbose"], { | |
| 553 | stdin: "ignore", | |
| 554 | stdout: "pipe", | |
| 555 | stderr: "ignore", | |
| 556 | killSignal: "SIGTERM", | |
| 557 | forceKillAfter: PROCESS_TERMINATE_GRACE, | |
| 558 | }); | |
| 559 | const handle = yield* spawner.spawn(command); | |
| 560 | const stdoutBytes = yield* Stream.runCollect(handle.stdout); | |
| 561 | const exitCode = yield* handle.exitCode; | |
| 562 | if ((exitCode as unknown as number) !== 0) { | |
| 563 | return yield* new DesktopWslDistroListError({ | |
| 564 | reason: `wsl.exe --list --verbose exited with code ${String(exitCode)}`, | |
| 565 | }); | |
| 566 | } | |
| 567 | return parseWslDistroList(Buffer.from(concatChunks(stdoutBytes))); | |
| 568 | }), | |
| 569 | ).pipe( | |
| 570 | Effect.mapError((error) => | |
| 571 | isDesktopWslDistroListError(error) | |
| 572 | ? error | |
| 573 | : new DesktopWslDistroListError({ | |
| 574 | reason: `Failed to run wsl.exe --list --verbose: ${error.message}`, | |
| 575 | }), | |
| 576 | ), | |
| 577 | Effect.timeoutOption(LIST_TIMEOUT), | |
| 578 | Effect.flatMap( | |
| 579 | Option.match({ | |
| 580 | onNone: () => | |
| 581 | new DesktopWslDistroListError({ | |
| 582 | reason: "wsl.exe --list --verbose timed out", | |
| 583 | }), | |
| 584 | onSome: Effect.succeed, | |
| 585 | }), | |
| 586 | ), | |
| 587 | ); | |
| 588 | ||
| 589 | const preWarmImpl = ( | |
| 590 | distro: string | null, | |
| 591 | ): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => | |
| 592 | Effect.scoped( | |
| 593 | Effect.gen(function* () { | |
| 594 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 595 | const command = ChildProcess.make("wsl.exe", [...buildDistroArgs(distro), "--", "true"], { | |
| 596 | stdin: "ignore", | |
| 597 | stdout: "ignore", | |
| 598 | stderr: "ignore", | |
| 599 | killSignal: "SIGTERM", | |
| 600 | forceKillAfter: PROCESS_TERMINATE_GRACE, | |
| 601 | }); | |
| 602 | const handle = yield* spawner.spawn(command); | |
| 603 | yield* handle.exitCode; | |
| 604 | }), | |
| 605 | ).pipe( | |
| 606 | Effect.timeoutOption(PRE_WARM_TIMEOUT), | |
| 607 | Effect.asVoid, | |
| 608 | Effect.catch(() => Effect.void), | |
| 609 | ); | |
| 610 | ||
| 611 | const windowsToWslPathImpl = ( | |
| 612 | distro: string | null, | |
| 613 | windowsPath: string, | |
| 614 | ): Effect.Effect<Option.Option<string>, never, ChildProcessSpawner.ChildProcessSpawner> => { | |
| 615 | // wsl.exe interprets backslashes as escape chars; normalize to forward slashes. | |
| 616 | const normalized = windowsPath.replaceAll("\\", "/"); | |
| 617 | return Effect.scoped( | |
| 618 | Effect.gen(function* () { | |
| 619 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 620 | const command = ChildProcess.make( | |
| 621 | "wsl.exe", | |
| 622 | [...buildDistroArgs(distro), "--", "wslpath", "-u", normalized], | |
| 623 | { | |
| 624 | stdin: "ignore", | |
| 625 | stdout: "pipe", | |
| 626 | stderr: "ignore", | |
| 627 | killSignal: "SIGTERM", | |
| 628 | forceKillAfter: PROCESS_TERMINATE_GRACE, | |
| 629 | }, | |
| 630 | ); | |
| 631 | const handle = yield* spawner.spawn(command); | |
| 632 | const stdoutBytes = yield* Stream.runCollect(handle.stdout); | |
| 633 | const exitCode = yield* handle.exitCode; | |
| 634 | if ((exitCode as unknown as number) !== 0) return Option.none<string>(); | |
| 635 | const converted = decodeUtf8(concatChunks(stdoutBytes)).trim(); | |
| 636 | return converted.length > 0 ? Option.some(converted) : Option.none<string>(); | |
| 637 | }), | |
| 638 | ).pipe( | |
| 639 | Effect.timeoutOption(WSLPATH_TIMEOUT), | |
| 640 | Effect.map(Option.flatten), | |
| 641 | Effect.orElseSucceed(() => Option.none<string>()), | |
| 642 | ); | |
| 643 | }; | |
| 644 | ||
| 645 | const IPV4_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; | |
| 646 | ||
| 647 | const getDistroIpImpl = ( | |
| 648 | distro: string | null, | |
| 649 | ): Effect.Effect<Option.Option<string>, never, ChildProcessSpawner.ChildProcessSpawner> => | |
| 650 | Effect.scoped( | |
| 651 | Effect.gen(function* () { | |
| 652 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 653 | // `hostname -I` prints a space-separated list of all non-loopback | |
| 654 | // IPs the distro has bound. The first entry on the WSL2 default | |
| 655 | // network is always the eth0 vEthernet address Windows can reach | |
| 656 | // directly (no wslhost forwarding required). | |
| 657 | const command = ChildProcess.make( | |
| 658 | "wsl.exe", | |
| 659 | [...buildDistroArgs(distro), "--", "sh", "-c", "hostname -I"], | |
| 660 | { | |
| 661 | stdin: "ignore", | |
| 662 | stdout: "pipe", | |
| 663 | stderr: "ignore", | |
| 664 | killSignal: "SIGTERM", | |
| 665 | forceKillAfter: PROCESS_TERMINATE_GRACE, | |
| 666 | }, | |
| 667 | ); | |
| 668 | const handle = yield* spawner.spawn(command); | |
| 669 | const stdoutBytes = yield* Stream.runCollect(handle.stdout); | |
| 670 | const exitCode = yield* handle.exitCode; | |
| 671 | if ((exitCode as unknown as number) !== 0) return Option.none<string>(); | |
| 672 | const raw = decodeUtf8(concatChunks(stdoutBytes)).trim(); | |
| 673 | const candidate = raw.split(/\s+/).find((part) => IPV4_PATTERN.test(part)); | |
| 674 | return candidate ? Option.some(candidate) : Option.none<string>(); | |
| 675 | }), | |
| 676 | ).pipe( | |
| 677 | Effect.timeoutOption(USER_HOME_TIMEOUT), | |
| 678 | Effect.map(Option.flatten), | |
| 679 | Effect.orElseSucceed(() => Option.none<string>()), | |
| 680 | ); | |
| 681 | ||
| 682 | const getUserHomeImpl = ( | |
| 683 | distro: string | null, | |
| 684 | ): Effect.Effect<Option.Option<string>, never, ChildProcessSpawner.ChildProcessSpawner> => | |
| 685 | Effect.scoped( | |
| 686 | Effect.gen(function* () { | |
| 687 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 688 | const command = ChildProcess.make( | |
| 689 | "wsl.exe", | |
| 690 | // printf so there's no trailing newline noise; getent so we get the | |
| 691 | // real home from /etc/passwd even if $HOME is unset for some reason. | |
| 692 | [ | |
| 693 | ...buildDistroArgs(distro), | |
| 694 | "--", | |
| 695 | "sh", | |
| 696 | "-c", | |
| 697 | 'printf "%s" "$(getent passwd "$(id -un)" | cut -d: -f6)"', | |
| 698 | ], | |
| 699 | { | |
| 700 | stdin: "ignore", | |
| 701 | stdout: "pipe", | |
| 702 | stderr: "ignore", | |
| 703 | killSignal: "SIGTERM", | |
| 704 | forceKillAfter: PROCESS_TERMINATE_GRACE, | |
| 705 | }, | |
| 706 | ); | |
| 707 | const handle = yield* spawner.spawn(command); | |
| 708 | const stdoutBytes = yield* Stream.runCollect(handle.stdout); | |
| 709 | const exitCode = yield* handle.exitCode; | |
| 710 | if ((exitCode as unknown as number) !== 0) return Option.none<string>(); | |
| 711 | const home = decodeUtf8(concatChunks(stdoutBytes)).trim(); | |
| 712 | return home.startsWith("/") ? Option.some(home) : Option.none<string>(); | |
| 713 | }), | |
| 714 | ).pipe( | |
| 715 | Effect.timeoutOption(USER_HOME_TIMEOUT), | |
| 716 | Effect.map(Option.flatten), | |
| 717 | Effect.orElseSucceed(() => Option.none<string>()), | |
| 718 | ); | |
| 719 | ||
| 720 | const makeIsAvailable = ( | |
| 721 | platform: NodeJS.Platform, | |
| 722 | windir: string, | |
| 723 | ): Effect.Effect<boolean, never, FileSystem.FileSystem | Path.Path> => | |
| 724 | Effect.gen(function* () { | |
| 725 | if (platform !== "win32") return false; | |
| 726 | const path = yield* Path.Path; | |
| 727 | const fileSystem = yield* FileSystem.FileSystem; | |
| 728 | const wslExePath = path.join(windir, "System32", "wsl.exe"); | |
| 729 | return yield* fileSystem.exists(wslExePath).pipe(Effect.orElseSucceed(() => false)); | |
| 730 | }); | |
| 731 | ||
| 732 | export interface DesktopWslEnvironmentTestStub { | |
| 733 | readonly isAvailable?: boolean; | |
| 734 | readonly distros?: ReadonlyArray<WslDistro>; | |
| 735 | readonly distroListError?: DesktopWslDistroListError; | |
| 736 | readonly windowsToWslPath?: (distro: string | null, windowsPath: string) => Option.Option<string>; | |
| 737 | readonly getUserHome?: (distro: string | null) => Option.Option<string>; | |
| 738 | readonly getDistroIp?: (distro: string | null) => Option.Option<string>; | |
| 739 | readonly ensureNodePty?: ( | |
| 740 | distro: string | null, | |
| 741 | windowsRepoRoot: string, | |
| 742 | options?: EnsureWslNodePtyOptions, | |
| 743 | ) => EnsureWslNodePtyResult; | |
| 744 | } | |
| 745 | ||
| 746 | export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => { | |
| 747 | const probeDistros = stub.distroListError | |
| 748 | ? Effect.fail(stub.distroListError) | |
| 749 | : Effect.succeed(stub.distros ?? []); | |
| 750 | return Layer.succeed( | |
| 751 | DesktopWslEnvironment, | |
| 752 | DesktopWslEnvironment.of({ | |
| 753 | isAvailable: Effect.succeed(stub.isAvailable ?? false), | |
| 754 | listDistros: probeDistros.pipe(Effect.orElseSucceed(() => [])), | |
| 755 | probeDistros, | |
| 756 | preWarm: () => Effect.void, | |
| 757 | windowsToWslPath: (distro, windowsPath) => | |
| 758 | Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()), | |
| 759 | getUserHome: (distro) => Effect.succeed(stub.getUserHome?.(distro) ?? Option.none<string>()), | |
| 760 | getDistroIp: (distro) => Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none<string>()), | |
| 761 | ensureNodePty: (distro, windowsRepoRoot, options) => | |
| 762 | Effect.succeed( | |
| 763 | stub.ensureNodePty?.(distro, windowsRepoRoot, options) ?? { | |
| 764 | ok: false, | |
| 765 | reason: "ensureNodePty stub not configured", | |
| 766 | fatal: true, | |
| 767 | }, | |
| 768 | ), | |
| 769 | }), | |
| 770 | ); | |
| 771 | }; | |
| 772 | ||
| 773 | export const layer = Layer.effect( | |
| 774 | DesktopWslEnvironment, | |
| 775 | Effect.gen(function* () { | |
| 776 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 777 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 778 | const fileSystem = yield* FileSystem.FileSystem; | |
| 779 | const windir = process.env.WINDIR ?? "C:\\Windows"; | |
| 780 | ||
| 781 | const provideSpawner = <A, E>( | |
| 782 | effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>, | |
| 783 | ): Effect.Effect<A, E> => | |
| 784 | effect.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); | |
| 785 | ||
| 786 | // Probe wsl.exe once at layer init and cache the result, exposing | |
| 787 | // `isAvailable` as a resolved value rather than a re-running effect. | |
| 788 | // WSL availability is effectively static for the process lifetime — the | |
| 789 | // Windows feature isn't added/removed mid-session, and backend mode | |
| 790 | // changes already require an app restart — so the cached boolean stays | |
| 791 | // accurate. Crucially this keeps `isAvailable` synchronously resolvable: | |
| 792 | // it's read inside the sync IPC handler getLocalEnvironmentBootstraps | |
| 793 | // (via the primary instance's lazy label -> resolvePrimaryLabel -> | |
| 794 | // describePrimary). The underlying probe does a filesystem `exists` | |
| 795 | // check, so leaving it as a live effect would make Effect.runSync throw | |
| 796 | // there and break the renderer's synchronous bootstrap path. | |
| 797 | const wslAvailable = yield* makeIsAvailable(environment.platform, windir).pipe( | |
| 798 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 799 | Effect.provideService(Path.Path, environment.path), | |
| 800 | Effect.withSpan("desktop.wsl.isAvailable"), | |
| 801 | ); | |
| 802 | const isAvailable = Effect.succeed(wslAvailable); | |
| 803 | ||
| 804 | const windowsToWslPath = (distro: string | null, windowsPath: string) => | |
| 805 | provideSpawner(windowsToWslPathImpl(distro, windowsPath)).pipe( | |
| 806 | Effect.withSpan("desktop.wsl.windowsToWslPath"), | |
| 807 | ); | |
| 808 | ||
| 809 | // Cache user-home results per distro key — folder picker can be opened | |
| 810 | // many times in a session and the value is stable for the life of the | |
| 811 | // distro. Negative results aren't cached so a transient wsl.exe failure | |
| 812 | // doesn't permanently disable tilde expansion. | |
| 813 | const userHomeCache = new Map<string, string>(); | |
| 814 | const getUserHome = (distro: string | null) => | |
| 815 | Effect.gen(function* () { | |
| 816 | const key = distro ?? "__default__"; | |
| 817 | const cached = userHomeCache.get(key); | |
| 818 | if (cached !== undefined) return Option.some(cached); | |
| 819 | const resolved = yield* provideSpawner(getUserHomeImpl(distro)); | |
| 820 | if (Option.isSome(resolved)) userHomeCache.set(key, resolved.value); | |
| 821 | return resolved; | |
| 822 | }).pipe(Effect.withSpan("desktop.wsl.getUserHome")); | |
| 823 | ||
| 824 | const getDistroIp = (distro: string | null) => | |
| 825 | provideSpawner(getDistroIpImpl(distro)).pipe(Effect.withSpan("desktop.wsl.getDistroIp")); | |
| 826 | ||
| 827 | const probeDistros = provideSpawner(probeWslDistros).pipe( | |
| 828 | Effect.withSpan("desktop.wsl.probeDistros"), | |
| 829 | ); | |
| 830 | ||
| 831 | return DesktopWslEnvironment.of({ | |
| 832 | isAvailable, | |
| 833 | listDistros: probeDistros.pipe( | |
| 834 | Effect.orElseSucceed(() => []), | |
| 835 | Effect.withSpan("desktop.wsl.listDistros"), | |
| 836 | ), | |
| 837 | probeDistros, | |
| 838 | preWarm: (distro) => | |
| 839 | provideSpawner(preWarmImpl(distro)).pipe(Effect.withSpan("desktop.wsl.preWarm")), | |
| 840 | windowsToWslPath, | |
| 841 | getUserHome, | |
| 842 | getDistroIp, | |
| 843 | ensureNodePty: (distro, windowsRepoRoot, options) => | |
| 844 | provideSpawner(ensureNodePtyImpl(distro, windowsRepoRoot, windowsToWslPath, options)).pipe( | |
| 845 | Effect.withSpan("desktop.wsl.ensureNodePty"), | |
| 846 | ), | |
| 847 | }); | |
| 848 | }), | |
| 849 | ); |
apps/desktop/src/wsl/wslPathParsing.test.ts created+206| ... | ... | @@ -0,0 +1,206 @@ |
| 1 | import { describe, it, expect } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | DISTRO_NAME_PATTERN, | |
| 5 | extractDistroFromUncPath, | |
| 6 | isValidDistroName, | |
| 7 | parseWslDistroList, | |
| 8 | resolveWslHomeUncPath, | |
| 9 | resolveWslPickFolderDefaultPath, | |
| 10 | wslUncPathToLinuxPath, | |
| 11 | } from "./wslPathParsing.ts"; | |
| 12 | ||
| 13 | function makeUtf16LeBuffer(text: string): Buffer { | |
| 14 | return Buffer.from("" + text, "utf16le"); | |
| 15 | } | |
| 16 | ||
| 17 | describe("parseWslDistroList", () => { | |
| 18 | it("parses standard output with default distro marked", () => { | |
| 19 | const output = makeUtf16LeBuffer( | |
| 20 | [ | |
| 21 | " NAME STATE VERSION", | |
| 22 | "* Ubuntu Running 2", | |
| 23 | " Debian Stopped 2", | |
| 24 | " Ubuntu-22.04 Running 1", | |
| 25 | ].join("\r\n"), | |
| 26 | ); | |
| 27 | const distros = parseWslDistroList(output); | |
| 28 | expect(distros).toEqual([ | |
| 29 | { name: "Ubuntu", isDefault: true, version: 2 }, | |
| 30 | { name: "Debian", isDefault: false, version: 2 }, | |
| 31 | { name: "Ubuntu-22.04", isDefault: false, version: 1 }, | |
| 32 | ]); | |
| 33 | }); | |
| 34 | ||
| 35 | it("returns empty array for empty buffer", () => { | |
| 36 | expect(parseWslDistroList(Buffer.alloc(0))).toEqual([]); | |
| 37 | }); | |
| 38 | ||
| 39 | it("returns empty array for header-only output", () => { | |
| 40 | const output = makeUtf16LeBuffer(" NAME STATE VERSION\r\n"); | |
| 41 | expect(parseWslDistroList(output)).toEqual([]); | |
| 42 | }); | |
| 43 | ||
| 44 | it("skips malformed lines", () => { | |
| 45 | const output = makeUtf16LeBuffer( | |
| 46 | [ | |
| 47 | " NAME STATE VERSION", | |
| 48 | "* Ubuntu Running 2", | |
| 49 | " bad line", | |
| 50 | "", | |
| 51 | " Debian Stopped 2", | |
| 52 | ].join("\r\n"), | |
| 53 | ); | |
| 54 | const distros = parseWslDistroList(output); | |
| 55 | expect(distros).toEqual([ | |
| 56 | { name: "Ubuntu", isDefault: true, version: 2 }, | |
| 57 | { name: "Debian", isDefault: false, version: 2 }, | |
| 58 | ]); | |
| 59 | }); | |
| 60 | ||
| 61 | it("handles output without BOM", () => { | |
| 62 | const text = [ | |
| 63 | " NAME STATE VERSION", | |
| 64 | "* Ubuntu Running 2", | |
| 65 | ].join("\r\n"); | |
| 66 | const output = Buffer.from(text, "utf16le"); | |
| 67 | const distros = parseWslDistroList(output); | |
| 68 | expect(distros).toEqual([{ name: "Ubuntu", isDefault: true, version: 2 }]); | |
| 69 | }); | |
| 70 | ||
| 71 | it("handles UTF-8 output", () => { | |
| 72 | const text = [ | |
| 73 | " NAME STATE VERSION", | |
| 74 | "* Debian Running 2", | |
| 75 | ].join("\r\n"); | |
| 76 | const distros = parseWslDistroList(Buffer.from(text, "utf8")); | |
| 77 | expect(distros).toEqual([{ name: "Debian", isDefault: true, version: 2 }]); | |
| 78 | }); | |
| 79 | }); | |
| 80 | ||
| 81 | describe("extractDistroFromUncPath", () => { | |
| 82 | it("extracts the distro from \\\\wsl.localhost UNC paths", () => { | |
| 83 | expect(extractDistroFromUncPath("\\\\wsl.localhost\\Ubuntu-22.04\\home\\josh")).toBe( | |
| 84 | "Ubuntu-22.04", | |
| 85 | ); | |
| 86 | }); | |
| 87 | ||
| 88 | it("extracts the distro from the legacy \\\\wsl$ UNC paths", () => { | |
| 89 | expect(extractDistroFromUncPath("\\\\wsl$\\Debian\\home\\josh")).toBe("Debian"); | |
| 90 | }); | |
| 91 | ||
| 92 | it("returns null for non-UNC Windows paths", () => { | |
| 93 | expect(extractDistroFromUncPath("C:\\Users\\Josh\\project")).toBeNull(); | |
| 94 | }); | |
| 95 | ||
| 96 | it("returns null when the segment is not a valid distro name", () => { | |
| 97 | expect(extractDistroFromUncPath("\\\\wsl.localhost\\bad name!\\home")).toBeNull(); | |
| 98 | }); | |
| 99 | }); | |
| 100 | ||
| 101 | describe("wslUncPathToLinuxPath", () => { | |
| 102 | it("maps WSL UNC paths back to Linux absolute paths", () => { | |
| 103 | expect(wslUncPathToLinuxPath("\\\\wsl.localhost\\Ubuntu-22.04\\home\\josh\\repo")).toBe( | |
| 104 | "/home/josh/repo", | |
| 105 | ); | |
| 106 | }); | |
| 107 | ||
| 108 | it("maps a distro UNC root to Linux root", () => { | |
| 109 | expect(wslUncPathToLinuxPath("\\\\wsl$\\Debian")).toBe("/"); | |
| 110 | expect(wslUncPathToLinuxPath("\\\\wsl.localhost\\Debian\\")).toBe("/"); | |
| 111 | }); | |
| 112 | ||
| 113 | it("rejects invalid distro names and non-WSL paths", () => { | |
| 114 | expect(wslUncPathToLinuxPath("\\\\wsl.localhost\\bad!name\\home")).toBeNull(); | |
| 115 | expect(wslUncPathToLinuxPath("C:\\Users\\Josh\\repo")).toBeNull(); | |
| 116 | }); | |
| 117 | }); | |
| 118 | ||
| 119 | describe("resolveWslHomeUncPath", () => { | |
| 120 | const distros = [ | |
| 121 | { name: "Debian", isDefault: true, version: 2 as const }, | |
| 122 | { name: "Ubuntu", isDefault: false, version: 2 as const }, | |
| 123 | ]; | |
| 124 | ||
| 125 | it("uses the configured distro when one is selected", () => { | |
| 126 | expect(resolveWslHomeUncPath({ distro: "Ubuntu" }, distros)).toBe( | |
| 127 | "\\\\wsl.localhost\\Ubuntu\\home", | |
| 128 | ); | |
| 129 | }); | |
| 130 | ||
| 131 | it("uses the actual default distro when config uses the WSL default", () => { | |
| 132 | expect(resolveWslHomeUncPath({ distro: null }, distros)).toBe( | |
| 133 | "\\\\wsl.localhost\\Debian\\home", | |
| 134 | ); | |
| 135 | }); | |
| 136 | ||
| 137 | it("omits the default path when no default distro is known", () => { | |
| 138 | expect(resolveWslHomeUncPath({ distro: null }, [])).toBeNull(); | |
| 139 | }); | |
| 140 | }); | |
| 141 | ||
| 142 | describe("resolveWslPickFolderDefaultPath", () => { | |
| 143 | const config = { distro: null }; | |
| 144 | const distros = [{ name: "Debian", isDefault: true, version: 2 as const }]; | |
| 145 | ||
| 146 | it("uses WSL home when no initial path is provided", () => { | |
| 147 | expect(resolveWslPickFolderDefaultPath(undefined, config, distros)).toBe( | |
| 148 | "\\\\wsl.localhost\\Debian\\home", | |
| 149 | ); | |
| 150 | }); | |
| 151 | ||
| 152 | it("maps Linux initial paths to WSL UNC paths", () => { | |
| 153 | expect( | |
| 154 | resolveWslPickFolderDefaultPath({ initialPath: "/home/josh/project" }, config, distros), | |
| 155 | ).toBe("\\\\wsl.localhost\\Debian\\home\\josh\\project"); | |
| 156 | }); | |
| 157 | ||
| 158 | it("expands ~/path against the user's home dir when known", () => { | |
| 159 | expect( | |
| 160 | resolveWslPickFolderDefaultPath({ initialPath: "~/project" }, config, distros, "/home/josh"), | |
| 161 | ).toBe("\\\\wsl.localhost\\Debian\\home\\josh\\project"); | |
| 162 | }); | |
| 163 | ||
| 164 | it("resolves bare ~ to the user's home dir when known", () => { | |
| 165 | expect( | |
| 166 | resolveWslPickFolderDefaultPath({ initialPath: "~" }, config, distros, "/home/josh"), | |
| 167 | ).toBe("\\\\wsl.localhost\\Debian\\home\\josh"); | |
| 168 | }); | |
| 169 | ||
| 170 | it("falls back to /home parent when the user's home dir isn't known", () => { | |
| 171 | expect(resolveWslPickFolderDefaultPath({ initialPath: "~/project" }, config, distros)).toBe( | |
| 172 | "\\\\wsl.localhost\\Debian\\home\\project", | |
| 173 | ); | |
| 174 | }); | |
| 175 | ||
| 176 | it("preserves existing UNC initial paths", () => { | |
| 177 | expect( | |
| 178 | resolveWslPickFolderDefaultPath( | |
| 179 | { initialPath: "\\\\wsl.localhost\\Ubuntu\\home\\josh" }, | |
| 180 | config, | |
| 181 | distros, | |
| 182 | ), | |
| 183 | ).toBe("\\\\wsl.localhost\\Ubuntu\\home\\josh"); | |
| 184 | }); | |
| 185 | }); | |
| 186 | ||
| 187 | describe("DISTRO_NAME_PATTERN / isValidDistroName", () => { | |
| 188 | it("accepts common distro names", () => { | |
| 189 | for (const name of ["Ubuntu", "Ubuntu-22.04", "kali-linux", "Debian", "Ubuntu 22.04"]) { | |
| 190 | expect(DISTRO_NAME_PATTERN.test(name)).toBe(true); | |
| 191 | expect(isValidDistroName(name)).toBe(true); | |
| 192 | } | |
| 193 | }); | |
| 194 | ||
| 195 | it("rejects names with trailing whitespace, hyphen, or dot", () => { | |
| 196 | for (const name of ["Ubuntu ", "Ubuntu-", "Ubuntu."]) { | |
| 197 | expect(DISTRO_NAME_PATTERN.test(name)).toBe(false); | |
| 198 | } | |
| 199 | }); | |
| 200 | ||
| 201 | it("rejects names containing control or shell-meta characters", () => { | |
| 202 | for (const name of ["bad\nname", "bad\tname", "bad/name", "bad!name", "bad;name"]) { | |
| 203 | expect(DISTRO_NAME_PATTERN.test(name)).toBe(false); | |
| 204 | } | |
| 205 | }); | |
| 206 | }); |
apps/desktop/src/wsl/wslPathParsing.ts created+129| ... | ... | @@ -0,0 +1,129 @@ |
| 1 | export interface WslDistro { | |
| 2 | readonly name: string; | |
| 3 | readonly isDefault: boolean; | |
| 4 | readonly version: 1 | 2; | |
| 5 | } | |
| 6 | ||
| 7 | export interface WslConfig { | |
| 8 | readonly distro: string | null; | |
| 9 | } | |
| 10 | ||
| 11 | // Literal space — \s would also match \n/\t/\r and corrupt UNC paths like \\wsl.localhost\<distro>\... | |
| 12 | // Trailing char must also be \w so hand-edited config like "Ubuntu " / "Ubuntu-" / "Ubuntu." rejects. | |
| 13 | export const DISTRO_NAME_PATTERN = /^\w(?:[\w \-.]*\w)?$/; | |
| 14 | ||
| 15 | export function parseWslDistroList(stdout: Buffer): readonly WslDistro[] { | |
| 16 | const hasUtf16Bom = stdout.length >= 2 && stdout[0] === 0xff && stdout[1] === 0xfe; | |
| 17 | const likelyUtf16Le = | |
| 18 | hasUtf16Bom || | |
| 19 | Array.from(stdout.subarray(0, Math.min(stdout.length, 80))).filter( | |
| 20 | (byte, index) => index % 2 === 1 && byte === 0, | |
| 21 | ).length > 10; | |
| 22 | let text = stdout.toString(likelyUtf16Le ? "utf16le" : "utf8"); | |
| 23 | if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); | |
| 24 | ||
| 25 | const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0); | |
| 26 | const distros: WslDistro[] = []; | |
| 27 | for (let i = 1; i < lines.length; i++) { | |
| 28 | const line = lines[i]!; | |
| 29 | const isDefault = line.startsWith("*"); | |
| 30 | const cleaned = isDefault ? line.slice(1).trim() : line.trim(); | |
| 31 | const fields = cleaned.split(/\s{2,}/); | |
| 32 | if (fields.length < 3) continue; | |
| 33 | const name = fields[0]!.trim(); | |
| 34 | const versionNum = parseInt(fields[2]!, 10); | |
| 35 | if (!name || (versionNum !== 1 && versionNum !== 2)) continue; | |
| 36 | distros.push({ name, isDefault, version: versionNum as 1 | 2 }); | |
| 37 | } | |
| 38 | return distros; | |
| 39 | } | |
| 40 | ||
| 41 | // Recognizes \\wsl.localhost\<distro>\... and the legacy \\wsl$\<distro>\... so | |
| 42 | // `wslpath` can be invoked inside the distro that actually owns the path, | |
| 43 | // rather than whichever distro is configured for the desktop backend. | |
| 44 | export function extractDistroFromUncPath(windowsPath: string): string | null { | |
| 45 | const match = /^\\\\(?:wsl\.localhost|wsl\$)\\([^\\]+)/i.exec(windowsPath); | |
| 46 | if (!match) return null; | |
| 47 | const candidate = match[1]!; | |
| 48 | return DISTRO_NAME_PATTERN.test(candidate) ? candidate : null; | |
| 49 | } | |
| 50 | ||
| 51 | export function wslUncPathToLinuxPath(windowsPath: string): string | null { | |
| 52 | const match = /^\\\\(?:wsl\.localhost|wsl\$)\\([^\\]+)(?:\\(.*))?$/i.exec(windowsPath.trim()); | |
| 53 | if (!match) return null; | |
| 54 | ||
| 55 | const distro = match[1]!; | |
| 56 | if (!DISTRO_NAME_PATTERN.test(distro)) return null; | |
| 57 | ||
| 58 | const rest = match[2] ?? ""; | |
| 59 | if (rest.length === 0) return "/"; | |
| 60 | ||
| 61 | return `/${rest.split("\\").filter(Boolean).join("/")}`; | |
| 62 | } | |
| 63 | ||
| 64 | export function resolveWslHomeUncPath( | |
| 65 | config: WslConfig, | |
| 66 | distros: readonly WslDistro[], | |
| 67 | ): string | null { | |
| 68 | const distroName = config.distro ?? distros.find((distro) => distro.isDefault)?.name ?? null; | |
| 69 | return distroName ? `\\\\wsl.localhost\\${distroName}\\home` : null; | |
| 70 | } | |
| 71 | ||
| 72 | export function resolveWslPickFolderDefaultPath( | |
| 73 | rawOptions: unknown, | |
| 74 | config: WslConfig, | |
| 75 | distros: readonly WslDistro[], | |
| 76 | // Absolute Linux path of the user's home dir inside the chosen distro | |
| 77 | // (e.g. "/home/josh"). When known, `~` and `~/...` expand against this so | |
| 78 | // we don't open the picker at a non-existent `/home/<rest>`. When null we | |
| 79 | // fall back to the `/home` parent — wrong directory but at least it exists. | |
| 80 | userHome: string | null = null, | |
| 81 | ): string | null { | |
| 82 | const homePath = resolveWslHomeUncPath(config, distros); | |
| 83 | if (typeof rawOptions !== "object" || rawOptions === null) { | |
| 84 | return homePath; | |
| 85 | } | |
| 86 | ||
| 87 | const { initialPath } = rawOptions as { initialPath?: unknown }; | |
| 88 | if (typeof initialPath !== "string") { | |
| 89 | return homePath; | |
| 90 | } | |
| 91 | ||
| 92 | const trimmedPath = initialPath.trim(); | |
| 93 | if (trimmedPath.length === 0) { | |
| 94 | return homePath; | |
| 95 | } | |
| 96 | if (trimmedPath.startsWith("\\\\")) { | |
| 97 | return trimmedPath; | |
| 98 | } | |
| 99 | ||
| 100 | const distroName = config.distro ?? distros.find((distro) => distro.isDefault)?.name ?? null; | |
| 101 | if (!distroName) { | |
| 102 | return homePath; | |
| 103 | } | |
| 104 | ||
| 105 | const toUncPath = (linuxPath: string) => | |
| 106 | `\\\\wsl.localhost\\${distroName}${linuxPath.replaceAll("/", "\\")}`; | |
| 107 | ||
| 108 | const normalizedUserHome = userHome && userHome.startsWith("/") ? userHome : null; | |
| 109 | ||
| 110 | if (trimmedPath === "~") { | |
| 111 | return normalizedUserHome ? toUncPath(normalizedUserHome) : homePath; | |
| 112 | } | |
| 113 | if (trimmedPath.startsWith("~/")) { | |
| 114 | const remainder = trimmedPath.slice(2); | |
| 115 | if (normalizedUserHome) { | |
| 116 | return toUncPath(`${normalizedUserHome}/${remainder}`); | |
| 117 | } | |
| 118 | return homePath ? `${homePath}\\${remainder.replaceAll("/", "\\")}` : null; | |
| 119 | } | |
| 120 | if (trimmedPath.startsWith("/")) { | |
| 121 | return toUncPath(trimmedPath); | |
| 122 | } | |
| 123 | ||
| 124 | return homePath; | |
| 125 | } | |
| 126 | ||
| 127 | export function isValidDistroName(value: string): boolean { | |
| 128 | return DISTRO_NAME_PATTERN.test(value); | |
| 129 | } |
apps/server/src/auth/PairingGrantStore.test.ts+12-4| ... | ... | @@ -137,11 +137,12 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 137 | 137 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), |
| 138 | 138 | ); |
| 139 | 139 | |
| 140 | it.effect("seeds the desktop bootstrap credential as a one-time grant", () => | |
| 140 | it.effect("seeds the desktop bootstrap credential as a reusable grant", () => | |
| 141 | 141 | Effect.gen(function* () { |
| 142 | 142 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
| 143 | 143 | const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); |
| 144 | const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); | |
| 144 | const second = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); | |
| 145 | const third = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); | |
| 145 | 146 | |
| 146 | 147 | expect(first.method).toBe("desktop-bootstrap"); |
| 147 | 148 | expect(first.scopes).toEqual([ |
| ... | ... | @@ -155,7 +156,8 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 155 | 156 | "relay:write", |
| 156 | 157 | ]); |
| 157 | 158 | expect(first.subject).toBe("desktop-bootstrap"); |
| 158 | expect(second._tag).toBe("UnknownBootstrapCredentialError"); | |
| 159 | expect(second.method).toBe("desktop-bootstrap"); | |
| 160 | expect(third.method).toBe("desktop-bootstrap"); | |
| 159 | 161 | }).pipe( |
| 160 | 162 | Effect.provide( |
| 161 | 163 | makePairingGrantStoreLayer({ |
| ... | ... | @@ -169,7 +171,13 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 169 | 171 | Effect.gen(function* () { |
| 170 | 172 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
| 171 | 173 | |
| 172 | yield* TestClock.adjust(Duration.minutes(6)); | |
| 174 | // The desktop-bootstrap grant lives for 24h. Within that window | |
| 175 | // it stays reusable. | |
| 176 | yield* TestClock.adjust(Duration.hours(12)); | |
| 177 | const stillValid = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); | |
| 178 | expect(stillValid.method).toBe("desktop-bootstrap"); | |
| 179 | ||
| 180 | yield* TestClock.adjust(Duration.hours(13)); | |
| 173 | 181 | const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); |
| 174 | 182 | |
| 175 | 183 | expect(expired._tag).toBe("ExpiredBootstrapCredentialError"); |
apps/server/src/auth/PairingGrantStore.ts+16-2| ... | ... | @@ -234,6 +234,15 @@ type ConsumeResult = |
| 234 | 234 | }; |
| 235 | 235 | |
| 236 | 236 | const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); |
| 237 | // The desktop-bootstrap grant rides on a trusted IPC channel (fd3 or | |
| 238 | // stdin) at backend launch, so it doesn't have to be short-lived the | |
| 239 | // way a user-facing pairing link does. Letting it live for the | |
| 240 | // lifetime of the backend process (24h is more than long enough for | |
| 241 | // practical desktop use, and well under "forever" in case the seed | |
| 242 | // gets logged anywhere by accident) means a page reload past the 5-min | |
| 243 | // window can still recover by re-bootstrapping rather than locking | |
| 244 | // the user out of the backend. | |
| 245 | const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24); | |
| 237 | 246 | const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; |
| 238 | 247 | const PAIRING_TOKEN_LENGTH = 12; |
| 239 | 248 | const PAIRING_TOKEN_REJECTION_LIMIT = |
| ... | ... | @@ -295,9 +304,14 @@ export const make = Effect.gen(function* () { |
| 295 | 304 | scopes: AuthAdministrativeScopes, |
| 296 | 305 | subject: "desktop-bootstrap", |
| 297 | 306 | expiresAt: DateTime.add(now, { |
| 298 | milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES), | |
| 307 | milliseconds: Duration.toMillis(DESKTOP_BOOTSTRAP_TTL_HOURS), | |
| 299 | 308 | }), |
| 300 | remainingUses: 1, | |
| 309 | // Unbounded uses so the renderer can re-exchange the seed for a | |
| 310 | // fresh bearer session after a page reload (or after the prior | |
| 311 | // bearer expires). The seed itself stays inside the desktop | |
| 312 | // process and the rendered page, both of which the user already | |
| 313 | // implicitly trusts. | |
| 314 | remainingUses: "unbounded", | |
| 301 | 315 | }); |
| 302 | 316 | } |
| 303 | 317 |
apps/server/src/bootstrap.ts+4-1| ... | ... | @@ -222,9 +222,12 @@ const makeDirectBootstrapStream = (fd: number): NodeStream.Readable => { |
| 222 | 222 | } |
| 223 | 223 | }; |
| 224 | 224 | |
| 225 | // Stdin pipes inherited across the wsl.exe boundary report EACCES when we try | |
| 226 | // to re-open them via /proc/self/fd/0 — fall back to reading the fd directly | |
| 227 | // in that case, the same way we already do for ENXIO/EINVAL/EPERM. | |
| 225 | 228 | const isBootstrapFdPathDuplicationError = Predicate.compose( |
| 226 | 229 | Predicate.hasProperty("code"), |
| 227 | (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM", | |
| 230 | (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM" || _.code === "EACCES", | |
| 228 | 231 | ); |
| 229 | 232 | |
| 230 | 233 | function resolveFdPath(fd: number, platform: NodeJS.Platform): string | undefined { |
apps/server/src/provider/providerMaintenanceRunner.test.ts+80| ... | ... | @@ -17,6 +17,8 @@ import * as Sink from "effect/Sink"; |
| 17 | 17 | import * as Stream from "effect/Stream"; |
| 18 | 18 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 19 | 19 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 20 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 21 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; | |
| 20 | 22 | |
| 21 | 23 | import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; |
| 22 | 24 | import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; |
| ... | ... | @@ -35,6 +37,12 @@ const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor"); |
| 35 | 37 | const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); |
| 36 | 38 | const encoder = new TextEncoder(); |
| 37 | 39 | |
| 40 | // Pin a non-win32 platform so `resolveSpawnCommand` is a no-op and the raw | |
| 41 | // `{ command, args }` assertions below hold deterministically on any host | |
| 42 | // (including Windows). Windows-specific resolution is covered by the dedicated | |
| 43 | // win32 case at the end of this suite. | |
| 44 | const NonWindowsPlatform = Layer.succeed(HostProcessPlatform, "linux"); | |
| 45 | ||
| 38 | 46 | function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { |
| 39 | 47 | if (provider === CURSOR_DRIVER) { |
| 40 | 48 | return makeProviderMaintenanceCapabilities({ |
| ... | ... | @@ -230,6 +238,7 @@ describe("providerMaintenanceRunner", () => { |
| 230 | 238 | }).pipe( |
| 231 | 239 | Effect.provide( |
| 232 | 240 | Layer.mergeAll( |
| 241 | NonWindowsPlatform, | |
| 233 | 242 | latestVersionHttpClient("0.0.0"), |
| 234 | 243 | mockSpawnerLayer((command, args) => { |
| 235 | 244 | calls.push({ command, args }); |
| ... | ... | @@ -279,6 +288,7 @@ describe("providerMaintenanceRunner", () => { |
| 279 | 288 | }).pipe( |
| 280 | 289 | Effect.provide( |
| 281 | 290 | Layer.mergeAll( |
| 291 | NonWindowsPlatform, | |
| 282 | 292 | latestVersionHttpClient("0.0.0"), |
| 283 | 293 | mockSpawnerLayer((command, args) => { |
| 284 | 294 | calls.push({ command, args }); |
| ... | ... | @@ -309,6 +319,7 @@ describe("providerMaintenanceRunner", () => { |
| 309 | 319 | }).pipe( |
| 310 | 320 | Effect.provide( |
| 311 | 321 | Layer.mergeAll( |
| 322 | NonWindowsPlatform, | |
| 312 | 323 | latestVersionHttpClient("0.0.0"), |
| 313 | 324 | mockSpawnerLayer((command, args) => { |
| 314 | 325 | calls.push({ command, args }); |
| ... | ... | @@ -381,6 +392,7 @@ describe("providerMaintenanceRunner", () => { |
| 381 | 392 | }).pipe( |
| 382 | 393 | Effect.provide( |
| 383 | 394 | Layer.mergeAll( |
| 395 | NonWindowsPlatform, | |
| 384 | 396 | latestVersionHttpClient("0.124.0-alpha.3"), |
| 385 | 397 | mockSpawnerLayer((command, args) => { |
| 386 | 398 | calls.push({ command, args }); |
| ... | ... | @@ -405,6 +417,7 @@ describe("providerMaintenanceRunner", () => { |
| 405 | 417 | }).pipe( |
| 406 | 418 | Effect.provide( |
| 407 | 419 | Layer.mergeAll( |
| 420 | NonWindowsPlatform, | |
| 408 | 421 | latestVersionHttpClient("0.0.0"), |
| 409 | 422 | mockSpawnerLayer(() => ({ stderr: "permission denied", code: 1 })), |
| 410 | 423 | ), |
| ... | ... | @@ -430,6 +443,7 @@ describe("providerMaintenanceRunner", () => { |
| 430 | 443 | }).pipe( |
| 431 | 444 | Effect.provide( |
| 432 | 445 | Layer.mergeAll( |
| 446 | NonWindowsPlatform, | |
| 433 | 447 | latestVersionHttpClient("9.9.9"), |
| 434 | 448 | mockSpawnerLayer(() => ({ stdout: "updated" })), |
| 435 | 449 | ), |
| ... | ... | @@ -468,6 +482,7 @@ describe("providerMaintenanceRunner", () => { |
| 468 | 482 | }).pipe( |
| 469 | 483 | Effect.provide( |
| 470 | 484 | Layer.mergeAll( |
| 485 | NonWindowsPlatform, | |
| 471 | 486 | latestVersionHttpClient("0.0.0"), |
| 472 | 487 | mockSpawnerLayer(() => { |
| 473 | 488 | startedLatch.resolve(); |
| ... | ... | @@ -544,6 +559,7 @@ describe("providerMaintenanceRunner", () => { |
| 544 | 559 | }).pipe( |
| 545 | 560 | Effect.provide( |
| 546 | 561 | Layer.mergeAll( |
| 562 | NonWindowsPlatform, | |
| 547 | 563 | latestVersionHttpClient("0.0.0"), |
| 548 | 564 | mockSpawnerLayer((_command, args) => { |
| 549 | 565 | calls.push(args.join(" ")); |
| ... | ... | @@ -587,6 +603,7 @@ describe("providerMaintenanceRunner", () => { |
| 587 | 603 | }).pipe( |
| 588 | 604 | Effect.provide( |
| 589 | 605 | Layer.mergeAll( |
| 606 | NonWindowsPlatform, | |
| 590 | 607 | latestVersionHttpClient("0.0.0"), |
| 591 | 608 | mockSpawnerLayer((_command, args) => { |
| 592 | 609 | calls.push(args.join(" ")); |
| ... | ... | @@ -641,10 +658,73 @@ describe("providerMaintenanceRunner", () => { |
| 641 | 658 | }).pipe( |
| 642 | 659 | Effect.provide( |
| 643 | 660 | Layer.mergeAll( |
| 661 | NonWindowsPlatform, | |
| 644 | 662 | latestVersionHttpClient("0.0.0"), |
| 645 | 663 | mockSpawnerLayer(() => ({ stdout: "updated" })), |
| 646 | 664 | ), |
| 647 | 665 | ), |
| 648 | 666 | ), |
| 649 | 667 | ); |
| 668 | ||
| 669 | it.effect("resolves npm to a .cmd shim and routes through the shell on win32", () => { | |
| 670 | const captured: Array<{ | |
| 671 | readonly command: string; | |
| 672 | readonly args: ReadonlyArray<string>; | |
| 673 | readonly shell: boolean | string | undefined; | |
| 674 | }> = []; | |
| 675 | return Effect.gen(function* () { | |
| 676 | const { registry } = yield* makeRegistry(baseProvider); | |
| 677 | const runner = yield* makeTestRunner(registry); | |
| 678 | ||
| 679 | const result = yield* runner.updateProvider(CODEX_DRIVER); | |
| 680 | ||
| 681 | // On win32, resolveSpawnCommand resolves `npm` to the `.cmd` shim and | |
| 682 | // routes the spawn through cmd.exe (shell: true), escaping every arg. | |
| 683 | assert.strictEqual(captured.length, 1); | |
| 684 | const call = captured[0]; | |
| 685 | assert.ok(call, "expected the spawner to be invoked once"); | |
| 686 | // The resolved command is the escaped `.cmd` path. Asserting the precise | |
| 687 | // escaped string is brittle, so verify it carries the resolved shim and | |
| 688 | // that shell mode was used. | |
| 689 | assert.match(call.command, /npm\.cmd/i); | |
| 690 | assert.strictEqual(call.shell, true); | |
| 691 | // Args are escaped for cmd.exe shell mode (each quoted) but still carry | |
| 692 | // the original install command (`install -g @openai/codex@latest`) in order. | |
| 693 | assert.strictEqual(call.args.length, 3); | |
| 694 | assert.match(call.args[0] ?? "", /install/); | |
| 695 | assert.match(call.args[1] ?? "", /-g/); | |
| 696 | assert.match(call.args[2] ?? "", /@openai\/codex@latest/); | |
| 697 | assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); | |
| 698 | }).pipe( | |
| 699 | Effect.provide( | |
| 700 | Layer.mergeAll( | |
| 701 | Layer.succeed(HostProcessPlatform, "win32"), | |
| 702 | Layer.succeed(HostProcessEnvironment, { | |
| 703 | PATH: "C:\\fake\\npm", | |
| 704 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 705 | }), | |
| 706 | Layer.succeed(SpawnExecutableResolution, (command) => | |
| 707 | command === "npm" ? "C:\\fake\\npm\\npm.cmd" : undefined, | |
| 708 | ), | |
| 709 | latestVersionHttpClient("0.0.0"), | |
| 710 | Layer.succeed( | |
| 711 | ChildProcessSpawner.ChildProcessSpawner, | |
| 712 | ChildProcessSpawner.make((command) => { | |
| 713 | const childProcess = command as unknown as { | |
| 714 | readonly command: string; | |
| 715 | readonly args: ReadonlyArray<string>; | |
| 716 | readonly options: { readonly shell?: boolean | string | undefined }; | |
| 717 | }; | |
| 718 | captured.push({ | |
| 719 | command: childProcess.command, | |
| 720 | args: childProcess.args, | |
| 721 | shell: childProcess.options.shell, | |
| 722 | }); | |
| 723 | return Effect.succeed(mockHandle({ stdout: "updated" })); | |
| 724 | }), | |
| 725 | ), | |
| 726 | ), | |
| 727 | ), | |
| 728 | ); | |
| 729 | }); | |
| 650 | 730 | }); |
apps/server/src/provider/providerMaintenanceRunner.ts+8-1| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | type ServerProviderUpdatedPayload, |
| 8 | 8 | type ServerProviderUpdateState, |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 10 | 11 | import * as Cause from "effect/Cause"; |
| 11 | 12 | import * as Context from "effect/Context"; |
| 12 | 13 | import * as Data from "effect/Data"; |
| ... | ... | @@ -75,8 +76,14 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR |
| 75 | 76 | }) { |
| 76 | 77 | const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( |
| 77 | 78 | function* () { |
| 79 | // Resolve the executable for the host platform before spawning. On | |
| 80 | // Windows the update tools are batch shims (e.g. `npm` -> `npm.cmd`), | |
| 81 | // which a bare ChildProcess.spawn cannot launch (spawn npm ENOENT); | |
| 82 | // resolveSpawnCommand finds the real `.cmd` and routes it through the | |
| 83 | // shell. On Linux/macOS (incl. the WSL backend) this is a no-op. | |
| 84 | const resolved = yield* resolveSpawnCommand(input.command, input.args); | |
| 78 | 85 | const child = yield* input.spawner |
| 79 | .spawn(ChildProcess.make(input.command, [...input.args])) | |
| 86 | .spawn(ChildProcess.make(resolved.command, resolved.args, { shell: resolved.shell })) | |
| 80 | 87 | .pipe( |
| 81 | 88 | Effect.mapError( |
| 82 | 89 | (cause) => |
apps/server/src/server.test.ts+6-5| ... | ... | @@ -3680,18 +3680,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3680 | 3680 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 3681 | 3681 | ); |
| 3682 | 3682 | |
| 3683 | it.effect("rejects reusing the same bootstrap credential after it has been exchanged", () => | |
| 3683 | it.effect("allows reusing the desktop bootstrap credential", () => | |
| 3684 | 3684 | Effect.gen(function* () { |
| 3685 | // The desktop-bootstrap grant is delivered over trusted IPC at | |
| 3686 | // backend launch and needs to stay claimable after a renderer | |
| 3687 | // refresh, so it's intentionally reusable (unlike user-facing | |
| 3688 | // one-time pairing credentials). | |
| 3685 | 3689 | yield* buildAppUnderTest(); |
| 3686 | 3690 | |
| 3687 | 3691 | const first = yield* bootstrapBrowserSession(); |
| 3688 | 3692 | const second = yield* bootstrapBrowserSession(); |
| 3689 | 3693 | |
| 3690 | 3694 | assert.equal(first.response.status, 200); |
| 3691 | assert.equal(second.response.status, 401); | |
| 3692 | assert.equal((second.body as { readonly _tag?: string })._tag, "EnvironmentAuthInvalidError"); | |
| 3693 | assert.equal((second.body as { readonly code?: string }).code, "auth_invalid"); | |
| 3694 | assert.equal((second.body as { readonly reason?: string }).reason, "invalid_credential"); | |
| 3695 | assert.equal(second.response.status, 200); | |
| 3695 | 3696 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 3696 | 3697 | ); |
| 3697 | 3698 |
apps/web/src/authBootstrap.test.ts+43-15| ... | ... | @@ -74,13 +74,16 @@ function installTestBrowser(url: string) { |
| 74 | 74 | function installDesktopBootstrap() { |
| 75 | 75 | const testWindow = installTestBrowser("http://localhost/"); |
| 76 | 76 | testWindow.desktopBridge = { |
| 77 | getLocalEnvironmentBootstrap: () => ({ | |
| 78 | label: "Local environment", | |
| 79 | httpBaseUrl: "http://localhost:3773", | |
| 80 | wsBaseUrl: "ws://localhost:3773", | |
| 81 | bootstrapToken: "desktop-bootstrap-token", | |
| 82 | }), | |
| 83 | } as DesktopBridge; | |
| 77 | getLocalEnvironmentBootstraps: () => [ | |
| 78 | { | |
| 79 | id: "primary", | |
| 80 | label: "Local environment", | |
| 81 | httpBaseUrl: "http://localhost:3773", | |
| 82 | wsBaseUrl: "ws://localhost:3773", | |
| 83 | bootstrapToken: "desktop-bootstrap-token", | |
| 84 | }, | |
| 85 | ], | |
| 86 | } as unknown as DesktopBridge; | |
| 84 | 87 | } |
| 85 | 88 | |
| 86 | 89 | function sequence<A>(...values: ReadonlyArray<A>) { |
| ... | ... | @@ -143,7 +146,18 @@ describe("resolveInitialServerAuthGateState", () => { |
| 143 | 146 | browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), |
| 144 | 147 | }); |
| 145 | 148 | |
| 146 | installDesktopBootstrap(); | |
| 149 | const testWindow = installTestBrowser("http://localhost/"); | |
| 150 | testWindow.desktopBridge = { | |
| 151 | getLocalEnvironmentBootstraps: () => [ | |
| 152 | { | |
| 153 | id: "primary", | |
| 154 | label: "Windows", | |
| 155 | httpBaseUrl: "http://localhost:3773", | |
| 156 | wsBaseUrl: "ws://localhost:3773", | |
| 157 | bootstrapToken: "desktop-bootstrap-token", | |
| 158 | }, | |
| 159 | ], | |
| 160 | } as unknown as DesktopBridge; | |
| 147 | 161 | |
| 148 | 162 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); |
| 149 | 163 | |
| ... | ... | @@ -192,12 +206,15 @@ describe("resolveInitialServerAuthGateState", () => { |
| 192 | 206 | |
| 193 | 207 | const testWindow = installTestBrowser("http://127.0.0.1:5733/"); |
| 194 | 208 | testWindow.desktopBridge = { |
| 195 | getLocalEnvironmentBootstrap: () => ({ | |
| 196 | label: "Local environment", | |
| 197 | httpBaseUrl: "http://127.0.0.1:3773", | |
| 198 | wsBaseUrl: "ws://127.0.0.1:3773", | |
| 199 | }), | |
| 200 | } as DesktopBridge; | |
| 209 | getLocalEnvironmentBootstraps: () => [ | |
| 210 | { | |
| 211 | id: "primary", | |
| 212 | label: "Windows", | |
| 213 | httpBaseUrl: "http://127.0.0.1:3773", | |
| 214 | wsBaseUrl: "ws://127.0.0.1:3773", | |
| 215 | }, | |
| 216 | ], | |
| 217 | } as unknown as DesktopBridge; | |
| 201 | 218 | |
| 202 | 219 | const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } = |
| 203 | 220 | await import("./environments/primary"); |
| ... | ... | @@ -373,7 +390,18 @@ describe("resolveInitialServerAuthGateState", () => { |
| 373 | 390 | browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), |
| 374 | 391 | }); |
| 375 | 392 | |
| 376 | installDesktopBootstrap(); | |
| 393 | const testWindow = installTestBrowser("http://localhost/"); | |
| 394 | testWindow.desktopBridge = { | |
| 395 | getLocalEnvironmentBootstraps: () => [ | |
| 396 | { | |
| 397 | id: "primary", | |
| 398 | label: "Windows", | |
| 399 | httpBaseUrl: "http://localhost:3773", | |
| 400 | wsBaseUrl: "ws://localhost:3773", | |
| 401 | bootstrapToken: "desktop-bootstrap-token", | |
| 402 | }, | |
| 403 | ], | |
| 404 | } as unknown as DesktopBridge; | |
| 377 | 405 | |
| 378 | 406 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); |
| 379 | 407 |
apps/web/src/components/CommandPalette.tsx+180-15| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | } from "@t3tools/client-runtime/state/runtime"; |
| 9 | 9 | import { |
| 10 | 10 | DEFAULT_MODEL, |
| 11 | type DesktopWslState, | |
| 11 | 12 | type EnvironmentId, |
| 12 | 13 | type FilesystemBrowseResult, |
| 13 | 14 | type ProjectId, |
| ... | ... | @@ -15,6 +16,7 @@ import { |
| 15 | 16 | type SourceControlDiscoveryResult, |
| 16 | 17 | type SourceControlProviderKind, |
| 17 | 18 | type SourceControlRepositoryInfo, |
| 19 | PRIMARY_LOCAL_ENVIRONMENT_ID, | |
| 18 | 20 | } from "@t3tools/contracts"; |
| 19 | 21 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 20 | 22 | import * as Option from "effect/Option"; |
| ... | ... | @@ -44,9 +46,12 @@ import { |
| 44 | 46 | } from "react"; |
| 45 | 47 | import { useAtomValue } from "@effect/atom-react"; |
| 46 | 48 | import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext"; |
| 49 | import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; | |
| 50 | import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; | |
| 47 | 51 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 48 | 52 | import { useClientSettings } from "../hooks/useSettings"; |
| 49 | 53 | import { readLocalApi } from "../localApi"; |
| 54 | import { desktopLocalBackendId } from "../connection/desktopLocal"; | |
| 50 | 55 | import { filesystemEnvironment } from "../state/filesystem"; |
| 51 | 56 | import { projectEnvironment } from "../state/projects"; |
| 52 | 57 | import { useEnvironmentQuery } from "../state/query"; |
| ... | ... | @@ -79,6 +84,12 @@ import { getLatestThreadForProject } from "../lib/threadSort"; |
| 79 | 84 | import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; |
| 80 | 85 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 81 | 86 | import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; |
| 87 | import { | |
| 88 | applyWslEnvironmentConfiguration, | |
| 89 | parseWslUncPath, | |
| 90 | resolveProjectPickerTarget, | |
| 91 | resolveWslProjectSelection, | |
| 92 | } from "../wslPaths"; | |
| 82 | 93 | import { |
| 83 | 94 | ADDON_ICON_CLASS, |
| 84 | 95 | buildBrowseGroups, |
| ... | ... | @@ -458,6 +469,7 @@ function OpenCommandPaletteDialog(props: { |
| 458 | 469 | reportFailure: false, |
| 459 | 470 | }); |
| 460 | 471 | const { environments } = useEnvironments(); |
| 472 | const desktopLocalBootstraps = useDesktopLocalBootstraps(); | |
| 461 | 473 | const primaryEnvironment = usePrimaryEnvironment(); |
| 462 | 474 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = |
| 463 | 475 | useHandleNewThread(); |
| ... | ... | @@ -500,9 +512,43 @@ function OpenCommandPaletteDialog(props: { |
| 500 | 512 | return options; |
| 501 | 513 | }, [environments]); |
| 502 | 514 | const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null; |
| 515 | const wslAddProjectEnvironmentOption = useMemo( | |
| 516 | () => | |
| 517 | addProjectEnvironmentOptions.find((option) => { | |
| 518 | const environment = environments.find( | |
| 519 | (candidate) => candidate.environmentId === option.environmentId, | |
| 520 | ); | |
| 521 | return environment | |
| 522 | ? desktopLocalBackendId(environment.entry.target)?.startsWith("wsl:") === true | |
| 523 | : false; | |
| 524 | }) ?? null, | |
| 525 | [addProjectEnvironmentOptions, environments], | |
| 526 | ); | |
| 503 | 527 | const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId; |
| 504 | 528 | const browseEnvironment = |
| 505 | 529 | environments.find((environment) => environment.environmentId === browseEnvironmentId) ?? null; |
| 530 | // A desktop-local secondary backend (today: the WSL backend). The picker is | |
| 531 | // available against these too — the desktop dispatches pickFolder into the | |
| 532 | // backend's filesystem when routed by its instance id. | |
| 533 | const browseEnvironmentIsDesktopLocal = | |
| 534 | browseEnvironment !== null && isDesktopLocalConnectionTarget(browseEnvironment.entry.target); | |
| 535 | // Map the browsed desktop-local env to its desktop pool instance id (e.g. | |
| 536 | // "wsl:ubuntu"). The catalog environmentId is descriptor-derived and won't | |
| 537 | // route on the desktop side; pickFolder only recognizes the pool id, which | |
| 538 | // the bootstrap list exposes. Match on backend URL, exactly as Sidebar's | |
| 539 | // LocalSecondaryStatus does (environment.displayUrl === bootstrap.httpBaseUrl). | |
| 540 | const browseDesktopInstanceId = useMemo(() => { | |
| 541 | if (!browseEnvironmentIsDesktopLocal || browseEnvironment === null) { | |
| 542 | return null; | |
| 543 | } | |
| 544 | const displayUrl = browseEnvironment.displayUrl; | |
| 545 | if (displayUrl === null) { | |
| 546 | return null; | |
| 547 | } | |
| 548 | return ( | |
| 549 | desktopLocalBootstraps.find((bootstrap) => bootstrap.httpBaseUrl === displayUrl)?.id ?? null | |
| 550 | ); | |
| 551 | }, [browseEnvironment, browseEnvironmentIsDesktopLocal, desktopLocalBootstraps]); | |
| 506 | 552 | const sourceControlDiscovery = useEnvironmentQuery( |
| 507 | 553 | browseEnvironmentId === null |
| 508 | 554 | ? null |
| ... | ... | @@ -984,6 +1030,21 @@ function OpenCommandPaletteDialog(props: { |
| 984 | 1030 | }, |
| 985 | 1031 | }); |
| 986 | 1032 | |
| 1033 | if (wslAddProjectEnvironmentOption) { | |
| 1034 | actionItems.push({ | |
| 1035 | kind: "action", | |
| 1036 | value: "action:add-project:wsl-folder", | |
| 1037 | searchTerms: ["add project", "open", "wsl", "linux", "folder", "directory"], | |
| 1038 | title: "Open WSL folder", | |
| 1039 | description: wslAddProjectEnvironmentOption.label, | |
| 1040 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | |
| 1041 | keepOpen: true, | |
| 1042 | run: async () => { | |
| 1043 | startAddProjectBrowse(wslAddProjectEnvironmentOption.environmentId); | |
| 1044 | }, | |
| 1045 | }); | |
| 1046 | } | |
| 1047 | ||
| 987 | 1048 | actionItems.push({ |
| 988 | 1049 | kind: "action", |
| 989 | 1050 | value: "action:settings", |
| ... | ... | @@ -1016,11 +1077,16 @@ function OpenCommandPaletteDialog(props: { |
| 1016 | 1077 | threadSearchItems: allThreadItems, |
| 1017 | 1078 | }); |
| 1018 | 1079 | |
| 1019 | const handleAddProject = useCallback( | |
| 1020 | async (rawCwd: string) => { | |
| 1021 | if (!browseEnvironmentId) return; | |
| 1080 | const handleAddProjectForEnvironment = useCallback( | |
| 1081 | async (input: { | |
| 1082 | readonly environmentId: EnvironmentId; | |
| 1083 | readonly rawCwd: string; | |
| 1084 | readonly platform: string; | |
| 1085 | readonly currentProjectCwd: string | null; | |
| 1086 | }) => { | |
| 1087 | const rawCwd = input.rawCwd; | |
| 1022 | 1088 | |
| 1023 | if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) { | |
| 1089 | if (isUnsupportedWindowsProjectPath(rawCwd.trim(), input.platform)) { | |
| 1024 | 1090 | toastManager.add( |
| 1025 | 1091 | stackedThreadToast({ |
| 1026 | 1092 | type: "error", |
| ... | ... | @@ -1031,7 +1097,7 @@ function OpenCommandPaletteDialog(props: { |
| 1031 | 1097 | return; |
| 1032 | 1098 | } |
| 1033 | 1099 | |
| 1034 | if (isExplicitRelativeProjectPath(rawCwd.trim()) && !currentProjectCwdForBrowse) { | |
| 1100 | if (isExplicitRelativeProjectPath(rawCwd.trim()) && !input.currentProjectCwd) { | |
| 1035 | 1101 | toastManager.add( |
| 1036 | 1102 | stackedThreadToast({ |
| 1037 | 1103 | type: "error", |
| ... | ... | @@ -1042,11 +1108,11 @@ function OpenCommandPaletteDialog(props: { |
| 1042 | 1108 | return; |
| 1043 | 1109 | } |
| 1044 | 1110 | |
| 1045 | const cwd = resolveProjectPathForDispatch(rawCwd, currentProjectCwdForBrowse); | |
| 1111 | const cwd = resolveProjectPathForDispatch(rawCwd, input.currentProjectCwd); | |
| 1046 | 1112 | if (cwd.length === 0) return; |
| 1047 | 1113 | |
| 1048 | 1114 | const existing = findProjectByPath( |
| 1049 | projects.filter((project) => project.environmentId === browseEnvironmentId), | |
| 1115 | projects.filter((project) => project.environmentId === input.environmentId), | |
| 1050 | 1116 | cwd, |
| 1051 | 1117 | ); |
| 1052 | 1118 | if (existing) { |
| ... | ... | @@ -1084,7 +1150,7 @@ function OpenCommandPaletteDialog(props: { |
| 1084 | 1150 | |
| 1085 | 1151 | const projectId = newProjectId(); |
| 1086 | 1152 | const createResult = await createProject({ |
| 1087 | environmentId: browseEnvironmentId, | |
| 1153 | environmentId: input.environmentId, | |
| 1088 | 1154 | input: { |
| 1089 | 1155 | projectId, |
| 1090 | 1156 | title: inferProjectTitleFromPath(cwd), |
| ... | ... | @@ -1111,7 +1177,7 @@ function OpenCommandPaletteDialog(props: { |
| 1111 | 1177 | } |
| 1112 | 1178 | |
| 1113 | 1179 | const navigationResult = await settlePromise(() => |
| 1114 | handleNewThread(scopeProjectRef(browseEnvironmentId, projectId)), | |
| 1180 | handleNewThread(scopeProjectRef(input.environmentId, projectId)), | |
| 1115 | 1181 | ); |
| 1116 | 1182 | if (navigationResult._tag === "Failure") { |
| 1117 | 1183 | const error = squashAtomCommandFailure(navigationResult); |
| ... | ... | @@ -1127,9 +1193,6 @@ function OpenCommandPaletteDialog(props: { |
| 1127 | 1193 | setOpen(false); |
| 1128 | 1194 | }, |
| 1129 | 1195 | [ |
| 1130 | browseEnvironmentId, | |
| 1131 | browseEnvironmentPlatform, | |
| 1132 | currentProjectCwdForBrowse, | |
| 1133 | 1196 | handleNewThread, |
| 1134 | 1197 | createProject, |
| 1135 | 1198 | navigate, |
| ... | ... | @@ -1140,6 +1203,24 @@ function OpenCommandPaletteDialog(props: { |
| 1140 | 1203 | ], |
| 1141 | 1204 | ); |
| 1142 | 1205 | |
| 1206 | const handleAddProject = useCallback( | |
| 1207 | async (rawCwd: string) => { | |
| 1208 | if (!browseEnvironmentId) return; | |
| 1209 | await handleAddProjectForEnvironment({ | |
| 1210 | environmentId: browseEnvironmentId, | |
| 1211 | rawCwd, | |
| 1212 | platform: browseEnvironmentPlatform, | |
| 1213 | currentProjectCwd: currentProjectCwdForBrowse, | |
| 1214 | }); | |
| 1215 | }, | |
| 1216 | [ | |
| 1217 | browseEnvironmentId, | |
| 1218 | browseEnvironmentPlatform, | |
| 1219 | currentProjectCwdForBrowse, | |
| 1220 | handleAddProjectForEnvironment, | |
| 1221 | ], | |
| 1222 | ); | |
| 1223 | ||
| 1143 | 1224 | function getDefaultCloneParentPath(environmentId: EnvironmentId): string { |
| 1144 | 1225 | return getAddProjectInitialQueryForEnvironment(environmentId); |
| 1145 | 1226 | } |
| ... | ... | @@ -1372,8 +1453,13 @@ function OpenCommandPaletteDialog(props: { |
| 1372 | 1453 | const canOpenProjectFromFileManager = |
| 1373 | 1454 | isBrowsing && |
| 1374 | 1455 | browseEnvironmentId !== null && |
| 1375 | primaryEnvironmentId !== null && | |
| 1376 | browseEnvironmentId === primaryEnvironmentId && | |
| 1456 | // For a desktop-local (WSL) env, only offer the picker once we have resolved | |
| 1457 | // its desktop pool instance id. Without it pickFolder can't be routed to the | |
| 1458 | // WSL filesystem and would open the primary (Windows) picker, then add the | |
| 1459 | // chosen Windows path against the WSL env -- a wrong-path footgun. Stay | |
| 1460 | // hidden until the bootstrap mapping is available rather than mis-routing. | |
| 1461 | (browseEnvironmentId === primaryEnvironmentId || | |
| 1462 | (browseEnvironmentIsDesktopLocal && browseDesktopInstanceId !== null)) && | |
| 1377 | 1463 | typeof window !== "undefined" && |
| 1378 | 1464 | window.desktopBridge !== undefined; |
| 1379 | 1465 | const fileManagerInitialPath = useMemo(() => { |
| ... | ... | @@ -1468,9 +1554,31 @@ function OpenCommandPaletteDialog(props: { |
| 1468 | 1554 | |
| 1469 | 1555 | setIsPickingProjectFolder(true); |
| 1470 | 1556 | let pickedPath: string | null = null; |
| 1557 | let desktopWslState: DesktopWslState | null = null; | |
| 1471 | 1558 | try { |
| 1559 | desktopWslState = | |
| 1560 | browseEnvironmentId === primaryEnvironmentId && browseEnvironmentPlatform === "Linux" | |
| 1561 | ? ((await window.desktopBridge?.getWslState().catch(() => null)) ?? null) | |
| 1562 | : null; | |
| 1563 | // Route the picker to the browsed env's backend filesystem. The desktop | |
| 1564 | // only resolves a "wsl:*" pool instance id, so for a desktop-local env we | |
| 1565 | // pass the bootstrap-mapped instance id (not the catalog environmentId). | |
| 1566 | // A WSL-only primary has no secondary bootstrap, so resolve its instance | |
| 1567 | // id from desktop settings. Windows and combo-mode primaries still omit | |
| 1568 | // the target to preserve the native primary picker. The desktop converts | |
| 1569 | // a WSL UNC selection back to a Linux path before returning. | |
| 1570 | const pickerTargetEnvironmentId = resolveProjectPickerTarget({ | |
| 1571 | browseEnvironmentId, | |
| 1572 | primaryEnvironmentId, | |
| 1573 | desktopInstanceId: browseDesktopInstanceId, | |
| 1574 | wslConfiguration: desktopWslState, | |
| 1575 | }); | |
| 1576 | const pickerOptions = { | |
| 1577 | ...(fileManagerInitialPath ? { initialPath: fileManagerInitialPath } : {}), | |
| 1578 | ...(pickerTargetEnvironmentId ? { targetEnvironmentId: pickerTargetEnvironmentId } : {}), | |
| 1579 | }; | |
| 1472 | 1580 | pickedPath = await api.dialogs.pickFolder( |
| 1473 | fileManagerInitialPath ? { initialPath: fileManagerInitialPath } : undefined, | |
| 1581 | Object.keys(pickerOptions).length > 0 ? pickerOptions : undefined, | |
| 1474 | 1582 | ); |
| 1475 | 1583 | } catch { |
| 1476 | 1584 | // Ignore picker failures and leave the palette open. |
| ... | ... | @@ -1481,12 +1589,69 @@ function OpenCommandPaletteDialog(props: { |
| 1481 | 1589 | if (!pickedPath) { |
| 1482 | 1590 | return; |
| 1483 | 1591 | } |
| 1592 | if (parseWslUncPath(pickedPath)) { | |
| 1593 | desktopWslState ??= (await window.desktopBridge?.getWslState().catch(() => null)) ?? null; | |
| 1594 | let primaryRunningDistro: string | null = null; | |
| 1595 | try { | |
| 1596 | primaryRunningDistro = | |
| 1597 | window.desktopBridge | |
| 1598 | ?.getLocalEnvironmentBootstraps() | |
| 1599 | .find((bootstrap) => bootstrap.id === PRIMARY_LOCAL_ENVIRONMENT_ID)?.runningDistro ?? | |
| 1600 | null; | |
| 1601 | } catch { | |
| 1602 | // Keep UNC routing strict when the live primary identity cannot be read. | |
| 1603 | } | |
| 1604 | const selection = resolveWslProjectSelection( | |
| 1605 | pickedPath, | |
| 1606 | applyWslEnvironmentConfiguration( | |
| 1607 | environments.flatMap((environment) => { | |
| 1608 | const backendId = desktopLocalBackendId(environment.entry.target); | |
| 1609 | if (!backendId) { | |
| 1610 | return []; | |
| 1611 | } | |
| 1612 | ||
| 1613 | const bootstrap = desktopLocalBootstraps.find( | |
| 1614 | (candidate) => candidate.httpBaseUrl === environment.displayUrl, | |
| 1615 | ); | |
| 1616 | const runningDistro = bootstrap?.runningDistro ?? null; | |
| 1617 | return [{ environmentId: environment.environmentId, backendId, runningDistro }]; | |
| 1618 | }), | |
| 1619 | primaryEnvironmentId, | |
| 1620 | desktopWslState ?? null, | |
| 1621 | primaryRunningDistro, | |
| 1622 | ), | |
| 1623 | ); | |
| 1624 | if (!selection) { | |
| 1625 | toastManager.add( | |
| 1626 | stackedThreadToast({ | |
| 1627 | type: "error", | |
| 1628 | title: "Could not add WSL project", | |
| 1629 | description: "Start the matching WSL backend, then choose the folder again.", | |
| 1630 | }), | |
| 1631 | ); | |
| 1632 | return; | |
| 1633 | } | |
| 1634 | await handleAddProjectForEnvironment({ | |
| 1635 | environmentId: selection.environmentId, | |
| 1636 | rawCwd: selection.linuxPath, | |
| 1637 | platform: "Linux", | |
| 1638 | currentProjectCwd: null, | |
| 1639 | }); | |
| 1640 | return; | |
| 1641 | } | |
| 1484 | 1642 | await handleAddProject(pickedPath); |
| 1485 | 1643 | }, [ |
| 1644 | browseDesktopInstanceId, | |
| 1645 | browseEnvironmentId, | |
| 1646 | browseEnvironmentPlatform, | |
| 1486 | 1647 | canOpenProjectFromFileManager, |
| 1648 | desktopLocalBootstraps, | |
| 1649 | environments, | |
| 1487 | 1650 | fileManagerInitialPath, |
| 1488 | 1651 | handleAddProject, |
| 1652 | handleAddProjectForEnvironment, | |
| 1489 | 1653 | isPickingProjectFolder, |
| 1654 | primaryEnvironmentId, | |
| 1490 | 1655 | ]); |
| 1491 | 1656 | |
| 1492 | 1657 | return ( |
apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx created+226| ... | ... | @@ -0,0 +1,226 @@ |
| 1 | import type { Dispatch, ReactElement, SetStateAction } from "react"; | |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | import { | |
| 4 | type EnvironmentId, | |
| 5 | ProviderDriverKind, | |
| 6 | ProviderInstanceId, | |
| 7 | type ServerProvider, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 10 | ||
| 11 | import type { | |
| 12 | LocalEnvironmentUpdateGroup, | |
| 13 | ProviderUpdateCandidate, | |
| 14 | ProviderUpdateRowStatus, | |
| 15 | } from "./ProviderUpdateLaunchNotification.logic"; | |
| 16 | ||
| 17 | const testState = vi.hoisted(() => ({ | |
| 18 | groups: [] as LocalEnvironmentUpdateGroup[], | |
| 19 | updateProvider: vi.fn(), | |
| 20 | })); | |
| 21 | ||
| 22 | const hooks = vi.hoisted(() => { | |
| 23 | let cursor = 0; | |
| 24 | let slots: unknown[] = []; | |
| 25 | ||
| 26 | const nextIndex = () => cursor++; | |
| 27 | ||
| 28 | return { | |
| 29 | beginRender() { | |
| 30 | cursor = 0; | |
| 31 | }, | |
| 32 | reset() { | |
| 33 | cursor = 0; | |
| 34 | slots = []; | |
| 35 | }, | |
| 36 | useCallback<T>(callback: T): T { | |
| 37 | nextIndex(); | |
| 38 | return callback; | |
| 39 | }, | |
| 40 | useMemo<T>(factory: () => T): T { | |
| 41 | nextIndex(); | |
| 42 | return factory(); | |
| 43 | }, | |
| 44 | useMemoCache(size: number): unknown[] { | |
| 45 | const index = nextIndex(); | |
| 46 | if (!slots[index]) { | |
| 47 | slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); | |
| 48 | } | |
| 49 | return slots[index] as unknown[]; | |
| 50 | }, | |
| 51 | useRef<T>(initialValue: T): { current: T } { | |
| 52 | const index = nextIndex(); | |
| 53 | if (!slots[index]) { | |
| 54 | slots[index] = { current: initialValue }; | |
| 55 | } | |
| 56 | return slots[index] as { current: T }; | |
| 57 | }, | |
| 58 | useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] { | |
| 59 | const index = nextIndex(); | |
| 60 | if (index >= slots.length) { | |
| 61 | slots[index] = | |
| 62 | typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; | |
| 63 | } | |
| 64 | const setValue: Dispatch<SetStateAction<T>> = (nextValue) => { | |
| 65 | const previous = slots[index] as T; | |
| 66 | slots[index] = | |
| 67 | typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; | |
| 68 | }; | |
| 69 | return [slots[index] as T, setValue]; | |
| 70 | }, | |
| 71 | }; | |
| 72 | }); | |
| 73 | ||
| 74 | vi.mock("react", async (importOriginal) => { | |
| 75 | const actual = await importOriginal<typeof import("react")>(); | |
| 76 | return { | |
| 77 | ...actual, | |
| 78 | useCallback: hooks.useCallback, | |
| 79 | useMemo: hooks.useMemo, | |
| 80 | useRef: hooks.useRef, | |
| 81 | useState: hooks.useState, | |
| 82 | }; | |
| 83 | }); | |
| 84 | ||
| 85 | vi.mock("react/compiler-runtime", () => ({ | |
| 86 | c: hooks.useMemoCache, | |
| 87 | })); | |
| 88 | ||
| 89 | vi.mock("~/state/server", () => ({ | |
| 90 | serverEnvironment: { updateProvider: Symbol("updateProvider") }, | |
| 91 | })); | |
| 92 | ||
| 93 | vi.mock("~/state/use-atom-command", () => ({ | |
| 94 | useAtomCommand: () => testState.updateProvider, | |
| 95 | })); | |
| 96 | ||
| 97 | vi.mock("./ProviderUpdateLaunchNotification.environments", () => ({ | |
| 98 | useLocalEnvironmentUpdateGroups: () => ({ | |
| 99 | groups: testState.groups, | |
| 100 | isAnySettling: false, | |
| 101 | }), | |
| 102 | })); | |
| 103 | ||
| 104 | import { ProviderUpdateEnvironmentRows } from "./ProviderUpdateEnvironmentRows"; | |
| 105 | ||
| 106 | const environmentId = "env-wsl" as EnvironmentId; | |
| 107 | const pendingExpiryMs = 6 * 60_000; | |
| 108 | ||
| 109 | function provider(updateStatus?: "succeeded"): ServerProvider { | |
| 110 | const result: ServerProvider = { | |
| 111 | instanceId: ProviderInstanceId.make("codex-wsl"), | |
| 112 | driver: ProviderDriverKind.make("codex"), | |
| 113 | enabled: true, | |
| 114 | installed: true, | |
| 115 | version: updateStatus ? "1.1.0" : "1.0.0", | |
| 116 | status: "ready", | |
| 117 | auth: { status: "authenticated" }, | |
| 118 | checkedAt: "2026-06-26T12:00:00.000Z", | |
| 119 | models: [], | |
| 120 | slashCommands: [], | |
| 121 | skills: [], | |
| 122 | versionAdvisory: { | |
| 123 | status: updateStatus ? "current" : "behind_latest", | |
| 124 | currentVersion: updateStatus ? "1.1.0" : "1.0.0", | |
| 125 | latestVersion: "1.1.0", | |
| 126 | updateCommand: "npm install -g @openai/codex@latest", | |
| 127 | canUpdate: true, | |
| 128 | checkedAt: "2026-06-26T12:00:00.000Z", | |
| 129 | message: updateStatus ? "Up to date." : "Update available.", | |
| 130 | }, | |
| 131 | }; | |
| 132 | ||
| 133 | return updateStatus | |
| 134 | ? { | |
| 135 | ...result, | |
| 136 | updateState: { | |
| 137 | status: updateStatus, | |
| 138 | startedAt: "2026-06-26T12:00:00.000Z", | |
| 139 | finishedAt: "2026-06-26T12:00:01.000Z", | |
| 140 | message: "Provider updated.", | |
| 141 | output: null, | |
| 142 | }, | |
| 143 | } | |
| 144 | : result; | |
| 145 | } | |
| 146 | ||
| 147 | function deferred<T>() { | |
| 148 | let resolve!: (value: T) => void; | |
| 149 | const promise = new Promise<T>((complete) => { | |
| 150 | resolve = complete; | |
| 151 | }); | |
| 152 | return { promise, resolve }; | |
| 153 | } | |
| 154 | ||
| 155 | type RowElement = ReactElement<{ | |
| 156 | readonly status: ProviderUpdateRowStatus; | |
| 157 | readonly onUpdate: () => void; | |
| 158 | }>; | |
| 159 | ||
| 160 | function renderRow(): RowElement { | |
| 161 | hooks.beginRender(); | |
| 162 | const output = ProviderUpdateEnvironmentRows({}) as ReactElement<{ | |
| 163 | readonly children: RowElement | RowElement[]; | |
| 164 | }>; | |
| 165 | const children = output.props.children; | |
| 166 | return Array.isArray(children) ? children[0]! : children; | |
| 167 | } | |
| 168 | ||
| 169 | async function flushPromises(): Promise<void> { | |
| 170 | await Promise.resolve(); | |
| 171 | await Promise.resolve(); | |
| 172 | await Promise.resolve(); | |
| 173 | } | |
| 174 | ||
| 175 | describe("ProviderUpdateEnvironmentRows", () => { | |
| 176 | beforeEach(() => { | |
| 177 | vi.useFakeTimers(); | |
| 178 | hooks.reset(); | |
| 179 | testState.updateProvider.mockReset(); | |
| 180 | const candidate = provider() as ProviderUpdateCandidate; | |
| 181 | testState.groups = [ | |
| 182 | { | |
| 183 | environmentId, | |
| 184 | label: "WSL", | |
| 185 | isPrimary: false, | |
| 186 | isSettling: false, | |
| 187 | candidates: [candidate], | |
| 188 | providers: [candidate], | |
| 189 | }, | |
| 190 | ]; | |
| 191 | }); | |
| 192 | ||
| 193 | afterEach(() => { | |
| 194 | vi.useRealTimers(); | |
| 195 | }); | |
| 196 | ||
| 197 | it("keeps a successor pending when an expired request resolves late, then shows its success", async () => { | |
| 198 | const firstRequest = | |
| 199 | deferred<ReturnType<typeof AsyncResult.success<{ providers: ServerProvider[] }>>>(); | |
| 200 | const successorRequest = | |
| 201 | deferred<ReturnType<typeof AsyncResult.success<{ providers: ServerProvider[] }>>>(); | |
| 202 | testState.updateProvider | |
| 203 | .mockReturnValueOnce(firstRequest.promise) | |
| 204 | .mockReturnValueOnce(successorRequest.promise); | |
| 205 | ||
| 206 | renderRow().props.onUpdate(); | |
| 207 | expect(renderRow().props.status.kind).toBe("loading"); | |
| 208 | ||
| 209 | await vi.advanceTimersByTimeAsync(pendingExpiryMs); | |
| 210 | expect(renderRow().props.status.kind).toBe("failed"); | |
| 211 | ||
| 212 | renderRow().props.onUpdate(); | |
| 213 | expect(testState.updateProvider).toHaveBeenCalledTimes(2); | |
| 214 | expect(renderRow().props.status.kind).toBe("loading"); | |
| 215 | ||
| 216 | firstRequest.resolve(AsyncResult.success({ providers: [provider("succeeded")] })); | |
| 217 | await flushPromises(); | |
| 218 | ||
| 219 | expect(renderRow().props.status.kind).toBe("loading"); | |
| 220 | ||
| 221 | successorRequest.resolve(AsyncResult.success({ providers: [provider("succeeded")] })); | |
| 222 | await flushPromises(); | |
| 223 | ||
| 224 | expect(renderRow().props.status.kind).toBe("success"); | |
| 225 | }); | |
| 226 | }); |
apps/web/src/components/ProviderUpdateEnvironmentRows.tsx created+397| ... | ... | @@ -0,0 +1,397 @@ |
| 1 | import { CheckIcon } from "lucide-react"; | |
| 2 | import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; | |
| 3 | import type { EnvironmentId, ServerProvider } from "@t3tools/contracts"; | |
| 4 | import { | |
| 5 | isAtomCommandInterrupted, | |
| 6 | squashAtomCommandFailure, | |
| 7 | type AtomCommandResult, | |
| 8 | } from "@t3tools/client-runtime/state/runtime"; | |
| 9 | ||
| 10 | import { cn } from "~/lib/utils"; | |
| 11 | import { serverEnvironment } from "~/state/server"; | |
| 12 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 13 | import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments"; | |
| 14 | import { | |
| 15 | collectProviderUpdateOutcomeSnapshots, | |
| 16 | firstRejectedProviderUpdateMessage, | |
| 17 | getProviderUpdateProgressToastView, | |
| 18 | getProviderUpdateSidebarPillView, | |
| 19 | isTerminalProviderUpdatePhase, | |
| 20 | resolveEnvironmentUpdateRowStatus, | |
| 21 | type LocalEnvironmentUpdateGroup, | |
| 22 | type LocalProviderUpdateOutcome, | |
| 23 | type ProviderUpdateRowStatus, | |
| 24 | type ProviderUpdateRowStatusKind, | |
| 25 | type ProviderUpdateToastView, | |
| 26 | } from "./ProviderUpdateLaunchNotification.logic"; | |
| 27 | import { Button } from "./ui/button"; | |
| 28 | import { Spinner } from "./ui/spinner"; | |
| 29 | ||
| 30 | type ProviderUpdateCommandResult = AtomCommandResult< | |
| 31 | { readonly providers: ReadonlyArray<ServerProvider> }, | |
| 32 | unknown | |
| 33 | >; | |
| 34 | ||
| 35 | /** | |
| 36 | * Map one targeted instance's update command result into the settled-outcome | |
| 37 | * shape the multi-backend reducers consume: a non-interrupted failure becomes a | |
| 38 | * rejection carrying its message; a success carries the post-update snapshot of | |
| 39 | * the targeted instance (null when the backend did not report it). | |
| 40 | */ | |
| 41 | function toProviderUpdateOutcome(input: { | |
| 42 | readonly environmentId: EnvironmentId; | |
| 43 | readonly isPrimary: boolean; | |
| 44 | readonly target: { | |
| 45 | readonly driver: ServerProvider["driver"]; | |
| 46 | readonly instanceId: ServerProvider["instanceId"]; | |
| 47 | }; | |
| 48 | readonly result: ProviderUpdateCommandResult; | |
| 49 | }): PromiseSettledResult<LocalProviderUpdateOutcome> { | |
| 50 | if (input.result._tag === "Failure") { | |
| 51 | if (isAtomCommandInterrupted(input.result)) { | |
| 52 | // An interrupted dispatch (e.g. superseded) is neither a success nor a | |
| 53 | // hard failure — surface it as a non-contributing, non-rejecting outcome. | |
| 54 | return { | |
| 55 | status: "fulfilled", | |
| 56 | value: { | |
| 57 | environmentId: input.environmentId, | |
| 58 | isPrimary: input.isPrimary, | |
| 59 | driver: input.target.driver, | |
| 60 | instanceId: input.target.instanceId, | |
| 61 | provider: null, | |
| 62 | }, | |
| 63 | }; | |
| 64 | } | |
| 65 | const error = squashAtomCommandFailure(input.result); | |
| 66 | return { | |
| 67 | status: "rejected", | |
| 68 | reason: error instanceof Error ? error : new Error("Provider update failed."), | |
| 69 | }; | |
| 70 | } | |
| 71 | ||
| 72 | const provider = | |
| 73 | input.result.value.providers.find( | |
| 74 | (candidate) => candidate.instanceId === input.target.instanceId, | |
| 75 | ) ?? null; | |
| 76 | return { | |
| 77 | status: "fulfilled", | |
| 78 | value: { | |
| 79 | environmentId: input.environmentId, | |
| 80 | isPrimary: input.isPrimary, | |
| 81 | driver: input.target.driver, | |
| 82 | instanceId: input.target.instanceId, | |
| 83 | provider, | |
| 84 | }, | |
| 85 | }; | |
| 86 | } | |
| 87 | ||
| 88 | // Transport-hang safety net. The dispatch's `finally` clears the spinner and the | |
| 89 | // in-flight guard on completion, so this only matters if a request never resolves | |
| 90 | // at all (e.g. the socket drops mid-flight without surfacing an error). Keep it | |
| 91 | // well beyond the server's own update timeout (5 min) so a legitimately slow | |
| 92 | // update (npm installs routinely run tens of seconds) is never cut off and left | |
| 93 | // showing a dead, unresponsive Update button. | |
| 94 | const PENDING_EXPIRY_MS = 6 * 60_000; | |
| 95 | ||
| 96 | function rowToneClass(kind: ProviderUpdateRowStatusKind): string { | |
| 97 | switch (kind) { | |
| 98 | case "failed": | |
| 99 | return "text-destructive"; | |
| 100 | case "unchanged": | |
| 101 | return "text-warning"; | |
| 102 | case "success": | |
| 103 | return "text-success"; | |
| 104 | default: | |
| 105 | return "text-muted-foreground"; | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| 109 | function EnvironmentUpdateRow({ | |
| 110 | group, | |
| 111 | status, | |
| 112 | onUpdate, | |
| 113 | }: { | |
| 114 | readonly group: LocalEnvironmentUpdateGroup; | |
| 115 | readonly status: ProviderUpdateRowStatus; | |
| 116 | readonly onUpdate: () => void; | |
| 117 | }) { | |
| 118 | let trailing: ReactNode; | |
| 119 | switch (status.kind) { | |
| 120 | case "loading": | |
| 121 | trailing = <Spinner className="size-4 text-muted-foreground" />; | |
| 122 | break; | |
| 123 | case "success": | |
| 124 | trailing = <CheckIcon aria-hidden="true" className="size-4 text-success" />; | |
| 125 | break; | |
| 126 | case "failed": | |
| 127 | case "unchanged": | |
| 128 | trailing = ( | |
| 129 | <Button size="xs" variant="outline" onClick={onUpdate}> | |
| 130 | Retry | |
| 131 | </Button> | |
| 132 | ); | |
| 133 | break; | |
| 134 | default: | |
| 135 | trailing = ( | |
| 136 | <Button size="xs" onClick={onUpdate}> | |
| 137 | Update | |
| 138 | </Button> | |
| 139 | ); | |
| 140 | break; | |
| 141 | } | |
| 142 | ||
| 143 | return ( | |
| 144 | <div className="flex items-center justify-between gap-3 py-0.5"> | |
| 145 | <div className="flex min-w-0 flex-col"> | |
| 146 | <span className="truncate font-medium text-foreground">{group.label}</span> | |
| 147 | <span className={cn("truncate text-xs", rowToneClass(status.kind))}>{status.text}</span> | |
| 148 | </div> | |
| 149 | <div className="shrink-0">{trailing}</div> | |
| 150 | </div> | |
| 151 | ); | |
| 152 | } | |
| 153 | ||
| 154 | /** | |
| 155 | * The launch popover's body when WSL is present: one row per local environment | |
| 156 | * (Windows + WSL), each with its own "update all" trigger that targets only | |
| 157 | * that environment's backend. | |
| 158 | */ | |
| 159 | export function ProviderUpdateEnvironmentRows({ | |
| 160 | onInteract, | |
| 161 | }: { | |
| 162 | /** Called the first time the user triggers an update, so the host can stop refreshing the prompt. */ | |
| 163 | readonly onInteract?: () => void; | |
| 164 | }) { | |
| 165 | const { groups } = useLocalEnvironmentUpdateGroups(); | |
| 166 | const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { | |
| 167 | reportFailure: false, | |
| 168 | }); | |
| 169 | const groupByEnvironment = useMemo( | |
| 170 | () => new Map(groups.map((group) => [group.environmentId, group] as const)), | |
| 171 | [groups], | |
| 172 | ); | |
| 173 | ||
| 174 | // Only surface results that land after this popover opened. | |
| 175 | const visibleAfterIsoRef = useRef<string>(new Date().toISOString()); | |
| 176 | ||
| 177 | // Synchronous re-entry guard. setPendingEnvironments is an async state update, | |
| 178 | // and PENDING_EXPIRY_MS can clear the spinner while a request is still in | |
| 179 | // flight, so a rapid double-click (or a click after the expiry fires mid- | |
| 180 | // request) would otherwise dispatch a second full round of updates. A ref | |
| 181 | // updates synchronously, so we can bail before doing any work. | |
| 182 | const inFlightEnvironmentsRef = useRef<Set<EnvironmentId>>(new Set()); | |
| 183 | ||
| 184 | // Monotonic per-environment request version. Bumped on each dispatch and | |
| 185 | // captured locally, so an attempt that was superseded -- e.g. one that already | |
| 186 | // tripped the expiry safety net and was retried -- detects it is no longer | |
| 187 | // current and skips every state write when it finally resolves, instead of | |
| 188 | // clobbering the newer attempt's spinner/result/error or its in-flight guard. | |
| 189 | const requestVersionRef = useRef<Map<EnvironmentId, number>>(new Map()); | |
| 190 | ||
| 191 | const [pendingEnvironments, setPendingEnvironments] = useState<ReadonlySet<EnvironmentId>>( | |
| 192 | () => new Set(), | |
| 193 | ); | |
| 194 | const [errorByEnvironment, setErrorByEnvironment] = useState<ReadonlyMap<EnvironmentId, string>>( | |
| 195 | () => new Map(), | |
| 196 | ); | |
| 197 | const [resultByEnvironment, setResultByEnvironment] = useState< | |
| 198 | ReadonlyMap<EnvironmentId, ProviderUpdateToastView> | |
| 199 | >(() => new Map()); | |
| 200 | ||
| 201 | const clearPending = useCallback((environmentId: EnvironmentId) => { | |
| 202 | setPendingEnvironments((previous) => { | |
| 203 | if (!previous.has(environmentId)) { | |
| 204 | return previous; | |
| 205 | } | |
| 206 | const next = new Set(previous); | |
| 207 | next.delete(environmentId); | |
| 208 | return next; | |
| 209 | }); | |
| 210 | }, []); | |
| 211 | ||
| 212 | const handleUpdate = useCallback( | |
| 213 | async (environmentId: EnvironmentId) => { | |
| 214 | const group = groupByEnvironment.get(environmentId); | |
| 215 | if (!group || group.candidates.length === 0) { | |
| 216 | return; | |
| 217 | } | |
| 218 | if (inFlightEnvironmentsRef.current.has(environmentId)) { | |
| 219 | return; | |
| 220 | } | |
| 221 | inFlightEnvironmentsRef.current.add(environmentId); | |
| 222 | const requestVersion = (requestVersionRef.current.get(environmentId) ?? 0) + 1; | |
| 223 | requestVersionRef.current.set(environmentId, requestVersion); | |
| 224 | const isCurrentRequest = () => | |
| 225 | requestVersionRef.current.get(environmentId) === requestVersion; | |
| 226 | onInteract?.(); | |
| 227 | const providerCount = group.candidates.length; | |
| 228 | const targets = group.candidates.map((candidate) => ({ | |
| 229 | driver: candidate.driver, | |
| 230 | instanceId: candidate.instanceId, | |
| 231 | })); | |
| 232 | ||
| 233 | setPendingEnvironments((previous) => new Set(previous).add(environmentId)); | |
| 234 | setErrorByEnvironment((previous) => { | |
| 235 | if (!previous.has(environmentId)) { | |
| 236 | return previous; | |
| 237 | } | |
| 238 | const next = new Map(previous); | |
| 239 | next.delete(environmentId); | |
| 240 | return next; | |
| 241 | }); | |
| 242 | setResultByEnvironment((previous) => { | |
| 243 | if (!previous.has(environmentId)) { | |
| 244 | return previous; | |
| 245 | } | |
| 246 | const next = new Map(previous); | |
| 247 | next.delete(environmentId); | |
| 248 | return next; | |
| 249 | }); | |
| 250 | ||
| 251 | const expiry = setTimeout(() => { | |
| 252 | // A newer attempt may have superseded this one; if so, leave its state | |
| 253 | // untouched. | |
| 254 | if (!isCurrentRequest()) { | |
| 255 | return; | |
| 256 | } | |
| 257 | // The request is presumed dead (see PENDING_EXPIRY_MS). Clear the | |
| 258 | // spinner AND the in-flight guard together so the row never strands on a | |
| 259 | // dead Update button, and surface feedback so the timeout is visible | |
| 260 | // rather than silently reverting to idle. | |
| 261 | inFlightEnvironmentsRef.current.delete(environmentId); | |
| 262 | clearPending(environmentId); | |
| 263 | setErrorByEnvironment((previous) => | |
| 264 | new Map(previous).set(environmentId, "Update timed out — try again."), | |
| 265 | ); | |
| 266 | }, PENDING_EXPIRY_MS); | |
| 267 | try { | |
| 268 | // Dispatch each candidate's update to this environment's own backend and | |
| 269 | // normalize every settled outcome into the multi-backend reducer shape. | |
| 270 | const results = await Promise.all( | |
| 271 | targets.map(async (target): Promise<PromiseSettledResult<LocalProviderUpdateOutcome>> => { | |
| 272 | try { | |
| 273 | const result = await updateProvider({ | |
| 274 | environmentId, | |
| 275 | input: { provider: target.driver, instanceId: target.instanceId }, | |
| 276 | }); | |
| 277 | return toProviderUpdateOutcome({ | |
| 278 | environmentId, | |
| 279 | isPrimary: group.isPrimary, | |
| 280 | target, | |
| 281 | result, | |
| 282 | }); | |
| 283 | } catch (error) { | |
| 284 | return { | |
| 285 | status: "rejected", | |
| 286 | reason: error instanceof Error ? error : new Error("Provider update failed."), | |
| 287 | }; | |
| 288 | } | |
| 289 | }), | |
| 290 | ); | |
| 291 | if (!isCurrentRequest()) { | |
| 292 | // A newer attempt superseded this one while it was in flight; leave | |
| 293 | // the newer attempt's state intact. | |
| 294 | return; | |
| 295 | } | |
| 296 | // The request resolved (not a transport hang), so clear any stale | |
| 297 | // timeout error the expiry may have set -- otherwise a late success | |
| 298 | // would be masked, since an error takes priority in the row status. | |
| 299 | setErrorByEnvironment((previous) => { | |
| 300 | if (!previous.has(environmentId)) { | |
| 301 | return previous; | |
| 302 | } | |
| 303 | const next = new Map(previous); | |
| 304 | next.delete(environmentId); | |
| 305 | return next; | |
| 306 | }); | |
| 307 | if (results.length === 0) { | |
| 308 | setErrorByEnvironment((previous) => | |
| 309 | new Map(previous).set( | |
| 310 | environmentId, | |
| 311 | "This environment isn’t connected — try again once it reconnects.", | |
| 312 | ), | |
| 313 | ); | |
| 314 | return; | |
| 315 | } | |
| 316 | const rejectedMessage = firstRejectedProviderUpdateMessage(results); | |
| 317 | if (rejectedMessage) { | |
| 318 | setErrorByEnvironment((previous) => | |
| 319 | new Map(previous).set(environmentId, rejectedMessage), | |
| 320 | ); | |
| 321 | return; | |
| 322 | } | |
| 323 | const view = getProviderUpdateProgressToastView({ | |
| 324 | providers: collectProviderUpdateOutcomeSnapshots(results), | |
| 325 | providerCount, | |
| 326 | }); | |
| 327 | // Only persist a terminal outcome. A non-terminal ("running"/"initial") | |
| 328 | // view means this dispatch could not confirm completion — e.g. a snapshot | |
| 329 | // came back without its targeted instance (collectProviderUpdateOutcome- | |
| 330 | // Snapshots drops null providers), which happens when the command is | |
| 331 | // interrupted as a second backend connects and supersedes the in-flight | |
| 332 | // update. A stored view never re-polls, so persisting it would pin the | |
| 333 | // row's spinner forever once the pending flag expires. Drop it and let | |
| 334 | // the live per-environment provider state (pill) plus the pending expiry | |
| 335 | // drive the row, so it self-heals to whatever the backend actually did. | |
| 336 | if (isTerminalProviderUpdatePhase(view.phase)) { | |
| 337 | setResultByEnvironment((previous) => new Map(previous).set(environmentId, view)); | |
| 338 | } | |
| 339 | } catch (error) { | |
| 340 | if (isCurrentRequest()) { | |
| 341 | setErrorByEnvironment((previous) => | |
| 342 | new Map(previous).set( | |
| 343 | environmentId, | |
| 344 | error instanceof Error ? error.message : "Provider update failed.", | |
| 345 | ), | |
| 346 | ); | |
| 347 | } | |
| 348 | } finally { | |
| 349 | clearTimeout(expiry); | |
| 350 | // Only the current attempt owns the shared spinner and in-flight guard; | |
| 351 | // a superseded attempt resolving late must not clear a newer one's. | |
| 352 | if (isCurrentRequest()) { | |
| 353 | clearPending(environmentId); | |
| 354 | inFlightEnvironmentsRef.current.delete(environmentId); | |
| 355 | } | |
| 356 | } | |
| 357 | }, | |
| 358 | [clearPending, groupByEnvironment, onInteract, updateProvider], | |
| 359 | ); | |
| 360 | ||
| 361 | const rows = groups | |
| 362 | .map((group) => ({ | |
| 363 | group, | |
| 364 | status: resolveEnvironmentUpdateRowStatus({ | |
| 365 | group, | |
| 366 | error: errorByEnvironment.get(group.environmentId), | |
| 367 | result: resultByEnvironment.get(group.environmentId), | |
| 368 | // Derive the live pill from the candidates this row is actually | |
| 369 | // tracking, not every provider in the environment. Otherwise an | |
| 370 | // unrelated provider's recent success (or one candidate succeeding while | |
| 371 | // another was interrupted) makes the pill report success and hides the | |
| 372 | // Update action for candidates that are still outdated. | |
| 373 | pill: getProviderUpdateSidebarPillView(group.candidates, { | |
| 374 | visibleAfterIso: visibleAfterIsoRef.current, | |
| 375 | }), | |
| 376 | isPending: pendingEnvironments.has(group.environmentId), | |
| 377 | }), | |
| 378 | })) | |
| 379 | .filter(({ group, status }) => group.candidates.length > 0 || status.kind !== "idle"); | |
| 380 | ||
| 381 | if (rows.length === 0) { | |
| 382 | return null; | |
| 383 | } | |
| 384 | ||
| 385 | return ( | |
| 386 | <div className="mt-0.5 flex flex-col gap-1"> | |
| 387 | {rows.map(({ group, status }) => ( | |
| 388 | <EnvironmentUpdateRow | |
| 389 | key={group.environmentId} | |
| 390 | group={group} | |
| 391 | status={status} | |
| 392 | onUpdate={() => handleUpdate(group.environmentId)} | |
| 393 | /> | |
| 394 | ))} | |
| 395 | </div> | |
| 396 | ); | |
| 397 | } |
apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts created+97| ... | ... | @@ -0,0 +1,97 @@ |
| 1 | import type { ConnectionCatalogEntry } from "@t3tools/client-runtime/connection"; | |
| 2 | import type { ServerConfig } from "@t3tools/contracts"; | |
| 3 | import { useMemo } from "react"; | |
| 4 | ||
| 5 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; | |
| 6 | import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; | |
| 7 | import { | |
| 8 | buildLocalEnvironmentUpdateGroups, | |
| 9 | deriveEnvironmentDisplayLabel, | |
| 10 | type EnvironmentUpdateConnectionState, | |
| 11 | type LocalEnvironmentProvidersInput, | |
| 12 | type LocalEnvironmentUpdateGroup, | |
| 13 | } from "./ProviderUpdateLaunchNotification.logic"; | |
| 14 | ||
| 15 | /** | |
| 16 | * A local environment is either the same-origin primary backend or a | |
| 17 | * desktop-local secondary (the parallel WSL backend), which connects over | |
| 18 | * loopback with a bearer token and carries a `local:<backendInstanceId>` | |
| 19 | * connection id. SSH, relay, and other remote targets are excluded. | |
| 20 | */ | |
| 21 | function isLocalConnectionTarget(target: ConnectionCatalogEntry["target"]): boolean { | |
| 22 | return target._tag === "PrimaryConnectionTarget" || isDesktopLocalConnectionTarget(target); | |
| 23 | } | |
| 24 | ||
| 25 | function normalizeConnectionState(phase: string | undefined): EnvironmentUpdateConnectionState { | |
| 26 | switch (phase) { | |
| 27 | case "connected": | |
| 28 | return "ready"; | |
| 29 | case "connecting": | |
| 30 | case "reconnecting": | |
| 31 | return "connecting"; | |
| 32 | case "error": | |
| 33 | return "error"; | |
| 34 | case "offline": | |
| 35 | return "disconnected"; | |
| 36 | default: | |
| 37 | // "available" (or anything not yet observed) — the backend has not | |
| 38 | // confirmed it is serving yet, so treat it as still settling so the | |
| 39 | // popover waits for it. | |
| 40 | return "connecting"; | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | /** | |
| 45 | * Reactively enumerate the enabled local environments (the primary plus any | |
| 46 | * desktop-local secondary such as WSL) with each one's full provider list and a | |
| 47 | * flag for whether any is still connecting. Drives the launch popover's gating | |
| 48 | * and its per-environment update triggers. | |
| 49 | */ | |
| 50 | export function useLocalEnvironmentUpdateGroups(): { | |
| 51 | readonly groups: LocalEnvironmentUpdateGroup[]; | |
| 52 | readonly isAnySettling: boolean; | |
| 53 | } { | |
| 54 | const { environments } = useEnvironments(); | |
| 55 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 56 | ||
| 57 | return useMemo(() => { | |
| 58 | const inputs: LocalEnvironmentProvidersInput[] = []; | |
| 59 | ||
| 60 | for (const environment of environments) { | |
| 61 | if (!isLocalConnectionTarget(environment.entry.target)) { | |
| 62 | continue; | |
| 63 | } | |
| 64 | ||
| 65 | const isPrimary = environment.environmentId === primaryEnvironmentId; | |
| 66 | const serverConfig: ServerConfig | null = environment.serverConfig; | |
| 67 | ||
| 68 | inputs.push({ | |
| 69 | environmentId: environment.environmentId, | |
| 70 | // Secondaries carry a meaningful label straight from the platform source | |
| 71 | // (e.g. "WSL (Ubuntu)"). The primary's catalog label can be the account | |
| 72 | // name, so fall back to its platform OS so the row reads "Windows"/"Linux". | |
| 73 | label: isPrimary | |
| 74 | ? deriveEnvironmentDisplayLabel({ | |
| 75 | isWsl: false, | |
| 76 | wslDistro: null, | |
| 77 | platformOs: serverConfig?.environment.platform.os, | |
| 78 | fallbackLabel: environment.label, | |
| 79 | }) | |
| 80 | : environment.label, | |
| 81 | isPrimary, | |
| 82 | // The primary is the backend serving this renderer, so it is ready | |
| 83 | // whenever its providers are available; secondaries report their live | |
| 84 | // connection phase. | |
| 85 | connectionState: isPrimary | |
| 86 | ? "ready" | |
| 87 | : normalizeConnectionState(environment.connection.phase), | |
| 88 | providers: serverConfig?.providers ?? [], | |
| 89 | }); | |
| 90 | } | |
| 91 | ||
| 92 | // Primary first, then the rest in catalog order. | |
| 93 | inputs.sort((left, right) => Number(right.isPrimary) - Number(left.isPrimary)); | |
| 94 | ||
| 95 | return buildLocalEnvironmentUpdateGroups(inputs); | |
| 96 | }, [environments, primaryEnvironmentId]); | |
| 97 | } |
apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts+448-1| ... | ... | @@ -1,13 +1,24 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | type EnvironmentId, | |
| 4 | ProviderDriverKind, | |
| 5 | ProviderInstanceId, | |
| 6 | type ServerProvider, | |
| 7 | } from "@t3tools/contracts"; | |
| 3 | 8 | import * as Cause from "effect/Cause"; |
| 4 | 9 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | 10 | |
| 6 | 11 | import { |
| 12 | buildLocalEnvironmentUpdateGroups, | |
| 7 | 13 | canOneClickUpdateProviderCandidate, |
| 8 | 14 | collectProviderUpdateCandidates, |
| 15 | collectProviderUpdateOutcomeSnapshots, | |
| 9 | 16 | collectUpdatedProviderSnapshots, |
| 17 | deriveEnvironmentDisplayLabel, | |
| 18 | environmentGroupsWithUpdates, | |
| 10 | 19 | firstFailedProviderUpdateMessage, |
| 20 | firstRejectedProviderUpdateMessage, | |
| 21 | firstUnsuccessfulSecondaryProviderOutcome, | |
| 11 | 22 | getProviderUpdateInitialToastView, |
| 12 | 23 | getProviderUpdateProgressToastView, |
| 13 | 24 | getProviderUpdateRejectedToastView, |
| ... | ... | @@ -15,8 +26,17 @@ import { |
| 15 | 26 | getSingleProviderUpdateProgressToastView, |
| 16 | 27 | hasOneClickUpdateProviderCandidate, |
| 17 | 28 | isProviderUpdateCandidate, |
| 29 | isTerminalProviderUpdatePhase, | |
| 30 | localEnvironmentUpdateNotificationKey, | |
| 31 | parseWslDistroFromInstanceId, | |
| 18 | 32 | providerUpdateNotificationKey, |
| 33 | resolveEnvironmentUpdateRowStatus, | |
| 34 | type LocalEnvironmentProvidersInput, | |
| 35 | type LocalEnvironmentUpdateGroup, | |
| 36 | type LocalProviderUpdateOutcome, | |
| 19 | 37 | type ProviderUpdateCandidate, |
| 38 | type ProviderUpdateSidebarPillView, | |
| 39 | type ProviderUpdateToastView, | |
| 20 | 40 | } from "./ProviderUpdateLaunchNotification.logic"; |
| 21 | 41 | |
| 22 | 42 | const checkedAt = "2026-04-23T10:00:00.000Z"; |
| ... | ... | @@ -665,4 +685,431 @@ describe("provider update launch notification logic", () => { |
| 665 | 685 | ]), |
| 666 | 686 | ).toBeNull(); |
| 667 | 687 | }); |
| 688 | ||
| 689 | describe("multi-backend update outcomes", () => { | |
| 690 | const terminalState = ( | |
| 691 | status: "succeeded" | "failed" | "unchanged", | |
| 692 | message: string, | |
| 693 | ): NonNullable<ServerProvider["updateState"]> => ({ | |
| 694 | status, | |
| 695 | startedAt: checkedAt, | |
| 696 | finishedAt: checkedAt, | |
| 697 | message, | |
| 698 | output: null, | |
| 699 | }); | |
| 700 | ||
| 701 | const fulfilledOutcome = ( | |
| 702 | isPrimary: boolean, | |
| 703 | snapshot: ServerProvider | null, | |
| 704 | environment = "env", | |
| 705 | ): PromiseSettledResult<LocalProviderUpdateOutcome> => ({ | |
| 706 | status: "fulfilled", | |
| 707 | value: { | |
| 708 | environmentId: environment as LocalProviderUpdateOutcome["environmentId"], | |
| 709 | isPrimary, | |
| 710 | driver: snapshot?.driver ?? driver("codex"), | |
| 711 | instanceId: snapshot?.instanceId ?? instanceId("codex"), | |
| 712 | provider: snapshot, | |
| 713 | }, | |
| 714 | }); | |
| 715 | ||
| 716 | it("surfaces a secondary backend's failed update over the primary's success", () => { | |
| 717 | const snapshots = collectProviderUpdateOutcomeSnapshots([ | |
| 718 | fulfilledOutcome( | |
| 719 | true, | |
| 720 | provider({ | |
| 721 | driver: driver("codex"), | |
| 722 | updateState: terminalState("succeeded", "Provider updated."), | |
| 723 | }), | |
| 724 | ), | |
| 725 | fulfilledOutcome( | |
| 726 | false, | |
| 727 | provider({ | |
| 728 | driver: driver("codex"), | |
| 729 | updateState: terminalState("failed", "npm: NotFound"), | |
| 730 | }), | |
| 731 | ), | |
| 732 | ]); | |
| 733 | ||
| 734 | expect(snapshots).toHaveLength(1); | |
| 735 | expect(snapshots[0]?.updateState?.status).toBe("failed"); | |
| 736 | expect( | |
| 737 | getProviderUpdateProgressToastView({ providers: snapshots, providerCount: 1 }), | |
| 738 | ).toMatchObject({ phase: "failed" }); | |
| 739 | }); | |
| 740 | ||
| 741 | it("surfaces a secondary backend that stayed outdated over the primary's success", () => { | |
| 742 | const snapshots = collectProviderUpdateOutcomeSnapshots([ | |
| 743 | fulfilledOutcome( | |
| 744 | true, | |
| 745 | provider({ | |
| 746 | driver: driver("codex"), | |
| 747 | updateState: terminalState("succeeded", "Provider updated."), | |
| 748 | }), | |
| 749 | ), | |
| 750 | fulfilledOutcome( | |
| 751 | false, | |
| 752 | provider({ | |
| 753 | driver: driver("codex"), | |
| 754 | updateState: terminalState("unchanged", "still outdated"), | |
| 755 | }), | |
| 756 | ), | |
| 757 | ]); | |
| 758 | ||
| 759 | expect(snapshots[0]?.updateState?.status).toBe("unchanged"); | |
| 760 | expect( | |
| 761 | getProviderUpdateProgressToastView({ providers: snapshots, providerCount: 1 }), | |
| 762 | ).toMatchObject({ phase: "unchanged" }); | |
| 763 | }); | |
| 764 | ||
| 765 | it("reports success only when every backend succeeded", () => { | |
| 766 | const snapshots = collectProviderUpdateOutcomeSnapshots([ | |
| 767 | fulfilledOutcome( | |
| 768 | true, | |
| 769 | provider({ | |
| 770 | driver: driver("codex"), | |
| 771 | updateState: terminalState("succeeded", "Provider updated."), | |
| 772 | }), | |
| 773 | ), | |
| 774 | fulfilledOutcome( | |
| 775 | false, | |
| 776 | provider({ | |
| 777 | driver: driver("codex"), | |
| 778 | updateState: terminalState("succeeded", "Provider updated."), | |
| 779 | }), | |
| 780 | ), | |
| 781 | ]); | |
| 782 | ||
| 783 | expect( | |
| 784 | getProviderUpdateProgressToastView({ providers: snapshots, providerCount: 1 }), | |
| 785 | ).toMatchObject({ phase: "succeeded" }); | |
| 786 | }); | |
| 787 | ||
| 788 | it("ignores backends that did not return the targeted instance", () => { | |
| 789 | const primary = provider({ | |
| 790 | driver: driver("codex"), | |
| 791 | updateState: terminalState("succeeded", "Provider updated."), | |
| 792 | }); | |
| 793 | const snapshots = collectProviderUpdateOutcomeSnapshots([ | |
| 794 | fulfilledOutcome(true, primary), | |
| 795 | fulfilledOutcome(false, null), | |
| 796 | ]); | |
| 797 | ||
| 798 | expect(snapshots).toEqual([primary]); | |
| 799 | }); | |
| 800 | ||
| 801 | it("flags the first unsuccessful secondary outcome, skipping the primary and successes", () => { | |
| 802 | const primaryFailed = provider({ | |
| 803 | driver: driver("codex"), | |
| 804 | updateState: terminalState("failed", "primary boom"), | |
| 805 | }); | |
| 806 | ||
| 807 | expect( | |
| 808 | firstUnsuccessfulSecondaryProviderOutcome([ | |
| 809 | fulfilledOutcome(true, primaryFailed), | |
| 810 | fulfilledOutcome( | |
| 811 | false, | |
| 812 | provider({ | |
| 813 | driver: driver("codex"), | |
| 814 | updateState: terminalState("succeeded", "ok"), | |
| 815 | }), | |
| 816 | ), | |
| 817 | ]), | |
| 818 | ).toBeNull(); | |
| 819 | ||
| 820 | expect( | |
| 821 | firstUnsuccessfulSecondaryProviderOutcome([ | |
| 822 | fulfilledOutcome(true, primaryFailed), | |
| 823 | fulfilledOutcome( | |
| 824 | false, | |
| 825 | provider({ | |
| 826 | driver: driver("codex"), | |
| 827 | updateState: terminalState("failed", "wsl boom"), | |
| 828 | }), | |
| 829 | ), | |
| 830 | ]), | |
| 831 | ).toMatchObject({ status: "failed", provider: { updateState: { message: "wsl boom" } } }); | |
| 832 | }); | |
| 833 | ||
| 834 | it("treats a rejected dispatch as not contributing a snapshot", () => { | |
| 835 | const primary = provider({ | |
| 836 | driver: driver("codex"), | |
| 837 | updateState: terminalState("succeeded", "Provider updated."), | |
| 838 | }); | |
| 839 | const results: PromiseSettledResult<LocalProviderUpdateOutcome>[] = [ | |
| 840 | fulfilledOutcome(true, primary), | |
| 841 | { status: "rejected", reason: new Error("WebSocket closed") }, | |
| 842 | ]; | |
| 843 | ||
| 844 | expect(collectProviderUpdateOutcomeSnapshots(results)).toEqual([primary]); | |
| 845 | expect(firstRejectedProviderUpdateMessage(results)).toBe("WebSocket closed"); | |
| 846 | }); | |
| 847 | }); | |
| 848 | ||
| 849 | describe("per-environment update grouping", () => { | |
| 850 | const environment = ( | |
| 851 | input: { | |
| 852 | readonly environmentId: string; | |
| 853 | readonly providers: ReadonlyArray<ServerProvider>; | |
| 854 | } & Partial<Omit<LocalEnvironmentProvidersInput, "environmentId" | "providers">>, | |
| 855 | ): LocalEnvironmentProvidersInput => ({ | |
| 856 | environmentId: input.environmentId as EnvironmentId, | |
| 857 | label: input.label ?? input.environmentId, | |
| 858 | isPrimary: input.isPrimary ?? false, | |
| 859 | connectionState: input.connectionState ?? "ready", | |
| 860 | providers: input.providers, | |
| 861 | }); | |
| 862 | ||
| 863 | it("groups each environment's outdated one-click candidates", () => { | |
| 864 | const result = buildLocalEnvironmentUpdateGroups([ | |
| 865 | environment({ | |
| 866 | environmentId: "env-windows", | |
| 867 | label: "Windows", | |
| 868 | isPrimary: true, | |
| 869 | providers: [provider({ driver: driver("codex"), latestVersion: "1.1.0" })], | |
| 870 | }), | |
| 871 | environment({ | |
| 872 | environmentId: "env-wsl", | |
| 873 | label: "WSL", | |
| 874 | providers: [provider({ driver: driver("codex"), latestVersion: "1.1.0" })], | |
| 875 | }), | |
| 876 | ]); | |
| 877 | ||
| 878 | expect(result.isAnySettling).toBe(false); | |
| 879 | expect(result.groups.map((group) => group.label)).toEqual(["Windows", "WSL"]); | |
| 880 | expect(result.groups.every((group) => group.candidates.length === 1)).toBe(true); | |
| 881 | }); | |
| 882 | ||
| 883 | it("flags settling while a secondary backend is still connecting", () => { | |
| 884 | const result = buildLocalEnvironmentUpdateGroups([ | |
| 885 | environment({ | |
| 886 | environmentId: "env-windows", | |
| 887 | isPrimary: true, | |
| 888 | providers: [provider({ driver: driver("codex") })], | |
| 889 | }), | |
| 890 | environment({ environmentId: "env-wsl", connectionState: "connecting", providers: [] }), | |
| 891 | ]); | |
| 892 | ||
| 893 | expect(result.isAnySettling).toBe(true); | |
| 894 | expect( | |
| 895 | result.groups.find((group) => group.environmentId === ("env-wsl" as EnvironmentId)) | |
| 896 | ?.isSettling, | |
| 897 | ).toBe(true); | |
| 898 | }); | |
| 899 | ||
| 900 | it("keeps only environments that have a one-click update on offer", () => { | |
| 901 | const { groups } = buildLocalEnvironmentUpdateGroups([ | |
| 902 | environment({ | |
| 903 | environmentId: "env-windows", | |
| 904 | isPrimary: true, | |
| 905 | providers: [provider({ driver: driver("codex") })], | |
| 906 | }), | |
| 907 | environment({ | |
| 908 | environmentId: "env-wsl", | |
| 909 | providers: [ | |
| 910 | provider({ driver: driver("codex"), advisoryStatus: "current", latestVersion: null }), | |
| 911 | ], | |
| 912 | }), | |
| 913 | ]); | |
| 914 | ||
| 915 | expect(environmentGroupsWithUpdates(groups).map((group) => group.environmentId)).toEqual([ | |
| 916 | "env-windows", | |
| 917 | ]); | |
| 918 | }); | |
| 919 | ||
| 920 | it("keys the notification by environment, driver and latest version", () => { | |
| 921 | const noUpdates = buildLocalEnvironmentUpdateGroups([ | |
| 922 | environment({ | |
| 923 | environmentId: "env-windows", | |
| 924 | isPrimary: true, | |
| 925 | providers: [ | |
| 926 | provider({ driver: driver("codex"), advisoryStatus: "current", latestVersion: null }), | |
| 927 | ], | |
| 928 | }), | |
| 929 | ]); | |
| 930 | expect(localEnvironmentUpdateNotificationKey(noUpdates.groups)).toBeNull(); | |
| 931 | ||
| 932 | const both = buildLocalEnvironmentUpdateGroups([ | |
| 933 | environment({ | |
| 934 | environmentId: "env-windows", | |
| 935 | isPrimary: true, | |
| 936 | providers: [provider({ driver: driver("codex"), latestVersion: "1.1.0" })], | |
| 937 | }), | |
| 938 | environment({ | |
| 939 | environmentId: "env-wsl", | |
| 940 | providers: [provider({ driver: driver("codex"), latestVersion: "1.1.0" })], | |
| 941 | }), | |
| 942 | ]); | |
| 943 | const key = localEnvironmentUpdateNotificationKey(both.groups); | |
| 944 | expect(key).toContain("env-windows=codex:1.1.0"); | |
| 945 | expect(key).toContain("env-wsl=codex:1.1.0"); | |
| 946 | }); | |
| 947 | ||
| 948 | it("labels environments by platform so they are distinguishable", () => { | |
| 949 | expect( | |
| 950 | deriveEnvironmentDisplayLabel({ | |
| 951 | isWsl: false, | |
| 952 | wslDistro: null, | |
| 953 | platformOs: "windows", | |
| 954 | fallbackLabel: "Jgratton24", | |
| 955 | }), | |
| 956 | ).toBe("Windows"); | |
| 957 | expect( | |
| 958 | deriveEnvironmentDisplayLabel({ | |
| 959 | isWsl: true, | |
| 960 | wslDistro: null, | |
| 961 | platformOs: "linux", | |
| 962 | fallbackLabel: "Jgratton24", | |
| 963 | }), | |
| 964 | ).toBe("WSL"); | |
| 965 | expect( | |
| 966 | deriveEnvironmentDisplayLabel({ | |
| 967 | isWsl: true, | |
| 968 | wslDistro: "ubuntu", | |
| 969 | platformOs: "linux", | |
| 970 | fallbackLabel: "Jgratton24", | |
| 971 | }), | |
| 972 | ).toBe("WSL · ubuntu"); | |
| 973 | expect( | |
| 974 | deriveEnvironmentDisplayLabel({ | |
| 975 | isWsl: false, | |
| 976 | wslDistro: null, | |
| 977 | platformOs: undefined, | |
| 978 | fallbackLabel: "My Device", | |
| 979 | }), | |
| 980 | ).toBe("My Device"); | |
| 981 | }); | |
| 982 | ||
| 983 | it("parses the WSL distro from the backend instance id", () => { | |
| 984 | expect(parseWslDistroFromInstanceId("wsl:ubuntu")).toBe("ubuntu"); | |
| 985 | expect(parseWslDistroFromInstanceId("wsl:default")).toBeNull(); | |
| 986 | expect(parseWslDistroFromInstanceId("wsl:")).toBeNull(); | |
| 987 | expect(parseWslDistroFromInstanceId("ssh:host")).toBeNull(); | |
| 988 | expect(parseWslDistroFromInstanceId(undefined)).toBeNull(); | |
| 989 | }); | |
| 990 | }); | |
| 991 | ||
| 992 | describe("isTerminalProviderUpdatePhase", () => { | |
| 993 | it("treats succeeded/failed/unchanged as terminal", () => { | |
| 994 | expect(isTerminalProviderUpdatePhase("succeeded")).toBe(true); | |
| 995 | expect(isTerminalProviderUpdatePhase("failed")).toBe(true); | |
| 996 | expect(isTerminalProviderUpdatePhase("unchanged")).toBe(true); | |
| 997 | }); | |
| 998 | ||
| 999 | it("treats running/initial as non-terminal so they are not persisted", () => { | |
| 1000 | // The per-environment update row uses this to decide what to store. A | |
| 1001 | // "running"/"initial" snapshot never re-polls, so persisting it would pin | |
| 1002 | // the row's spinner forever once pending clears (see the | |
| 1003 | // resolveEnvironmentUpdateRowStatus "keeps a non-terminal result on | |
| 1004 | // loading even after pending clears" case). Dropping these lets the live | |
| 1005 | // per-environment provider state drive the row so it self-heals. | |
| 1006 | expect(isTerminalProviderUpdatePhase("running")).toBe(false); | |
| 1007 | expect(isTerminalProviderUpdatePhase("initial")).toBe(false); | |
| 1008 | }); | |
| 1009 | }); | |
| 1010 | ||
| 1011 | describe("resolveEnvironmentUpdateRowStatus", () => { | |
| 1012 | const group: LocalEnvironmentUpdateGroup = { | |
| 1013 | environmentId: "env-wsl" as EnvironmentId, | |
| 1014 | label: "WSL", | |
| 1015 | isPrimary: false, | |
| 1016 | isSettling: false, | |
| 1017 | candidates: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], | |
| 1018 | providers: [], | |
| 1019 | }; | |
| 1020 | const runningResult: ProviderUpdateToastView = { | |
| 1021 | phase: "running", | |
| 1022 | type: "loading", | |
| 1023 | title: "Updating providers", | |
| 1024 | description: "Running provider update command.", | |
| 1025 | }; | |
| 1026 | const succeededResult: ProviderUpdateToastView = { | |
| 1027 | phase: "succeeded", | |
| 1028 | type: "success", | |
| 1029 | title: "Provider updated", | |
| 1030 | description: "New sessions will use the updated provider.", | |
| 1031 | }; | |
| 1032 | const successPill: ProviderUpdateSidebarPillView = { | |
| 1033 | key: "succeeded:codex", | |
| 1034 | tone: "success", | |
| 1035 | title: "Codex updated", | |
| 1036 | description: "New sessions will use the updated provider.", | |
| 1037 | }; | |
| 1038 | ||
| 1039 | it("prefers a transport error", () => { | |
| 1040 | expect( | |
| 1041 | resolveEnvironmentUpdateRowStatus({ | |
| 1042 | group, | |
| 1043 | error: "boom", | |
| 1044 | result: succeededResult, | |
| 1045 | pill: successPill, | |
| 1046 | isPending: true, | |
| 1047 | }), | |
| 1048 | ).toMatchObject({ kind: "failed", text: "boom" }); | |
| 1049 | }); | |
| 1050 | ||
| 1051 | it("uses a terminal result snapshot", () => { | |
| 1052 | expect( | |
| 1053 | resolveEnvironmentUpdateRowStatus({ | |
| 1054 | group, | |
| 1055 | error: undefined, | |
| 1056 | result: succeededResult, | |
| 1057 | pill: null, | |
| 1058 | isPending: false, | |
| 1059 | }), | |
| 1060 | ).toMatchObject({ kind: "success" }); | |
| 1061 | }); | |
| 1062 | ||
| 1063 | it("falls through a non-terminal result to live server state", () => { | |
| 1064 | // The dispatch snapshot is still "running", but server state already | |
| 1065 | // reports success — the row must not stay pinned on "Updating…". | |
| 1066 | expect( | |
| 1067 | resolveEnvironmentUpdateRowStatus({ | |
| 1068 | group, | |
| 1069 | error: undefined, | |
| 1070 | result: runningResult, | |
| 1071 | pill: successPill, | |
| 1072 | isPending: true, | |
| 1073 | }), | |
| 1074 | ).toMatchObject({ kind: "success" }); | |
| 1075 | }); | |
| 1076 | ||
| 1077 | it("shows the pending spinner before any signal arrives", () => { | |
| 1078 | expect( | |
| 1079 | resolveEnvironmentUpdateRowStatus({ | |
| 1080 | group, | |
| 1081 | error: undefined, | |
| 1082 | result: runningResult, | |
| 1083 | pill: null, | |
| 1084 | isPending: true, | |
| 1085 | }), | |
| 1086 | ).toMatchObject({ kind: "loading" }); | |
| 1087 | }); | |
| 1088 | ||
| 1089 | it("keeps a non-terminal result on loading even after pending clears", () => { | |
| 1090 | // The dispatch returned an incomplete ("running") snapshot and pending was | |
| 1091 | // cleared in finally — the row must not revert to the idle Update button. | |
| 1092 | expect( | |
| 1093 | resolveEnvironmentUpdateRowStatus({ | |
| 1094 | group, | |
| 1095 | error: undefined, | |
| 1096 | result: runningResult, | |
| 1097 | pill: null, | |
| 1098 | isPending: false, | |
| 1099 | }), | |
| 1100 | ).toMatchObject({ kind: "loading" }); | |
| 1101 | }); | |
| 1102 | ||
| 1103 | it("lists the providers when idle", () => { | |
| 1104 | expect( | |
| 1105 | resolveEnvironmentUpdateRowStatus({ | |
| 1106 | group, | |
| 1107 | error: undefined, | |
| 1108 | result: undefined, | |
| 1109 | pill: null, | |
| 1110 | isPending: false, | |
| 1111 | }), | |
| 1112 | ).toMatchObject({ kind: "idle", text: "Codex" }); | |
| 1113 | }); | |
| 1114 | }); | |
| 668 | 1115 | }); |
apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts+293| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import { |
| 2 | 2 | defaultInstanceIdForDriver, |
| 3 | 3 | PROVIDER_DISPLAY_NAMES, |
| 4 | type EnvironmentId, | |
| 5 | type ExecutionEnvironmentPlatformOs, | |
| 4 | 6 | type ProviderDriverKind, |
| 5 | 7 | type ProviderInstanceId, |
| 6 | 8 | type ServerProvider, |
| ... | ... | @@ -28,6 +30,17 @@ export interface ProviderUpdateToastView { |
| 28 | 30 | readonly dismissAfterVisibleMs?: number; |
| 29 | 31 | } |
| 30 | 32 | |
| 33 | /** | |
| 34 | * Terminal update phases — outcomes that are safe to persist as a one-shot row | |
| 35 | * result. A non-terminal ("initial"/"running") snapshot never re-polls itself, | |
| 36 | * so storing one pins an update row's spinner indefinitely once its pending flag | |
| 37 | * expires; such phases must be dropped in favor of the live per-environment | |
| 38 | * provider state instead of being persisted. | |
| 39 | */ | |
| 40 | export function isTerminalProviderUpdatePhase(phase: ProviderUpdateToastPhase): boolean { | |
| 41 | return phase === "succeeded" || phase === "failed" || phase === "unchanged"; | |
| 42 | } | |
| 43 | ||
| 31 | 44 | export type ProviderUpdateSidebarPillTone = "loading" | "warning" | "error" | "success"; |
| 32 | 45 | |
| 33 | 46 | export interface ProviderUpdateSidebarPillView { |
| ... | ... | @@ -540,3 +553,283 @@ function getFailedProviderUpdateDescription(providers: ReadonlyArray<ServerProvi |
| 540 | 553 | } |
| 541 | 554 | return `${formatProviderList(providers)} failed to update. Check provider settings for details.`; |
| 542 | 555 | } |
| 556 | ||
| 557 | // =========================================================================== | |
| 558 | // Multi-environment provider updates | |
| 559 | // | |
| 560 | // With a desktop-local secondary backend present (the WSL backend alongside the | |
| 561 | // Windows primary), a provider update is applied across every local backend. | |
| 562 | // Each environment owns its own provider instances, so candidates and progress | |
| 563 | // are computed per environment and the dispatch targets that environment's | |
| 564 | // connection. These helpers are pure; the dispatch itself runs through the | |
| 565 | // `serverEnvironment.updateProvider` atom command in the components. | |
| 566 | // =========================================================================== | |
| 567 | ||
| 568 | /** | |
| 569 | * The settled result of dispatching a provider update to one local backend. | |
| 570 | * `provider` is the post-update snapshot of the targeted instance returned by | |
| 571 | * that backend (null when the backend did not report the targeted instance, | |
| 572 | * e.g. it does not have it installed). | |
| 573 | */ | |
| 574 | export interface LocalProviderUpdateOutcome { | |
| 575 | readonly environmentId: EnvironmentId; | |
| 576 | readonly isPrimary: boolean; | |
| 577 | readonly driver: ProviderDriverKind; | |
| 578 | readonly instanceId: ProviderInstanceId; | |
| 579 | readonly provider: ServerProvider | null; | |
| 580 | } | |
| 581 | ||
| 582 | // Worst-case ordering across backends: a failed copy outranks an unchanged one, | |
| 583 | // which outranks a still-running one, which outranks a succeeded one. | |
| 584 | const PROVIDER_UPDATE_STATUS_SEVERITY: Record<string, number> = { | |
| 585 | succeeded: 1, | |
| 586 | queued: 2, | |
| 587 | running: 2, | |
| 588 | unchanged: 3, | |
| 589 | failed: 4, | |
| 590 | }; | |
| 591 | ||
| 592 | function providerUpdateOutcomeSeverity(provider: ServerProvider): number { | |
| 593 | return PROVIDER_UPDATE_STATUS_SEVERITY[provider.updateState?.status ?? ""] ?? 0; | |
| 594 | } | |
| 595 | ||
| 596 | export function firstRejectedProviderUpdateMessage( | |
| 597 | results: ReadonlyArray<PromiseSettledResult<unknown>>, | |
| 598 | ): string | null { | |
| 599 | const rejected = results.find((result) => result.status === "rejected"); | |
| 600 | if (!rejected) { | |
| 601 | return null; | |
| 602 | } | |
| 603 | return rejected.reason instanceof Error ? rejected.reason.message : "Provider update failed."; | |
| 604 | } | |
| 605 | ||
| 606 | /** | |
| 607 | * Reduce per-backend update outcomes to one representative snapshot per driver, | |
| 608 | * keeping the worst-case status across every local backend. Because the same | |
| 609 | * driver has a distinct instance id per environment, a secondary backend (e.g. | |
| 610 | * WSL) that *resolved* with a failed or unchanged provider would otherwise be | |
| 611 | * filtered out (its instance id is not the primary's) or collapsed behind the | |
| 612 | * primary's success — this surfaces it instead. | |
| 613 | */ | |
| 614 | export function collectProviderUpdateOutcomeSnapshots( | |
| 615 | results: ReadonlyArray<PromiseSettledResult<LocalProviderUpdateOutcome>>, | |
| 616 | ): ServerProvider[] { | |
| 617 | const worstByDriver = new Map<ProviderDriverKind, ServerProvider>(); | |
| 618 | for (const result of results) { | |
| 619 | if (result.status !== "fulfilled" || result.value.provider === null) { | |
| 620 | continue; | |
| 621 | } | |
| 622 | const provider = result.value.provider; | |
| 623 | const current = worstByDriver.get(provider.driver); | |
| 624 | if ( | |
| 625 | !current || | |
| 626 | providerUpdateOutcomeSeverity(provider) > providerUpdateOutcomeSeverity(current) | |
| 627 | ) { | |
| 628 | worstByDriver.set(provider.driver, provider); | |
| 629 | } | |
| 630 | } | |
| 631 | return [...worstByDriver.values()]; | |
| 632 | } | |
| 633 | ||
| 634 | /** | |
| 635 | * The first secondary (non-primary) backend whose update resolved without | |
| 636 | * succeeding. The primary's own failed/unchanged state is already surfaced | |
| 637 | * inline in settings, so only secondaries (which have no inline row) need an | |
| 638 | * explicit callout. | |
| 639 | */ | |
| 640 | export function firstUnsuccessfulSecondaryProviderOutcome( | |
| 641 | results: ReadonlyArray<PromiseSettledResult<LocalProviderUpdateOutcome>>, | |
| 642 | ): { readonly provider: ServerProvider; readonly status: "failed" | "unchanged" } | null { | |
| 643 | for (const result of results) { | |
| 644 | if (result.status !== "fulfilled") { | |
| 645 | continue; | |
| 646 | } | |
| 647 | const outcome = result.value; | |
| 648 | if (outcome.isPrimary || outcome.provider === null) { | |
| 649 | continue; | |
| 650 | } | |
| 651 | const status = outcome.provider.updateState?.status; | |
| 652 | if (status === "failed" || status === "unchanged") { | |
| 653 | return { provider: outcome.provider, status }; | |
| 654 | } | |
| 655 | } | |
| 656 | return null; | |
| 657 | } | |
| 658 | ||
| 659 | const WSL_INSTANCE_ID_PREFIX = "wsl:"; | |
| 660 | ||
| 661 | /** The distro name from a WSL backend instance id ("wsl:ubuntu" -> "ubuntu"), or null for the default. */ | |
| 662 | export function parseWslDistroFromInstanceId(instanceId: string | undefined): string | null { | |
| 663 | if (!instanceId || !instanceId.startsWith(WSL_INSTANCE_ID_PREFIX)) { | |
| 664 | return null; | |
| 665 | } | |
| 666 | const distro = instanceId.slice(WSL_INSTANCE_ID_PREFIX.length).trim(); | |
| 667 | return distro.length === 0 || distro === "default" ? null : distro; | |
| 668 | } | |
| 669 | ||
| 670 | /** | |
| 671 | * A human label that distinguishes local environments by platform (so the | |
| 672 | * popover shows "Windows" / "WSL" rather than the account name twice). WSL is | |
| 673 | * identified by its backend instance id; everything else falls back to the | |
| 674 | * reported OS, then the environment's own label. | |
| 675 | */ | |
| 676 | export function deriveEnvironmentDisplayLabel(input: { | |
| 677 | readonly isWsl: boolean; | |
| 678 | readonly wslDistro: string | null; | |
| 679 | readonly platformOs: ExecutionEnvironmentPlatformOs | undefined; | |
| 680 | readonly fallbackLabel: string; | |
| 681 | }): string { | |
| 682 | if (input.isWsl) { | |
| 683 | return input.wslDistro ? `WSL · ${input.wslDistro}` : "WSL"; | |
| 684 | } | |
| 685 | switch (input.platformOs) { | |
| 686 | case "windows": | |
| 687 | return "Windows"; | |
| 688 | case "darwin": | |
| 689 | return "macOS"; | |
| 690 | case "linux": | |
| 691 | return "Linux"; | |
| 692 | default: | |
| 693 | return input.fallbackLabel; | |
| 694 | } | |
| 695 | } | |
| 696 | ||
| 697 | /** Connection state of a local environment, normalized across primary/secondary sources. */ | |
| 698 | export type EnvironmentUpdateConnectionState = "connecting" | "ready" | "disconnected" | "error"; | |
| 699 | ||
| 700 | export interface LocalEnvironmentProvidersInput { | |
| 701 | readonly environmentId: EnvironmentId; | |
| 702 | readonly label: string; | |
| 703 | readonly isPrimary: boolean; | |
| 704 | readonly connectionState: EnvironmentUpdateConnectionState; | |
| 705 | readonly providers: ReadonlyArray<ServerProvider>; | |
| 706 | } | |
| 707 | ||
| 708 | export interface LocalEnvironmentUpdateGroup { | |
| 709 | readonly environmentId: EnvironmentId; | |
| 710 | readonly label: string; | |
| 711 | readonly isPrimary: boolean; | |
| 712 | /** True while this environment's backend is still connecting (e.g. WSL booting). */ | |
| 713 | readonly isSettling: boolean; | |
| 714 | /** Outdated, one-click-updatable providers in this environment. */ | |
| 715 | readonly candidates: ProviderUpdateCandidate[]; | |
| 716 | /** Full provider list for this environment, used to derive live update progress. */ | |
| 717 | readonly providers: ReadonlyArray<ServerProvider>; | |
| 718 | } | |
| 719 | ||
| 720 | /** | |
| 721 | * Build one update group per local environment, pairing each environment's | |
| 722 | * outdated one-click candidates with its own provider list, and report whether | |
| 723 | * any environment is still settling (so the caller can defer the popover). | |
| 724 | */ | |
| 725 | export function buildLocalEnvironmentUpdateGroups( | |
| 726 | environments: ReadonlyArray<LocalEnvironmentProvidersInput>, | |
| 727 | ): { groups: LocalEnvironmentUpdateGroup[]; isAnySettling: boolean } { | |
| 728 | const groups = environments.map((environment) => ({ | |
| 729 | environmentId: environment.environmentId, | |
| 730 | label: environment.label, | |
| 731 | isPrimary: environment.isPrimary, | |
| 732 | isSettling: environment.connectionState === "connecting", | |
| 733 | candidates: collectProviderUpdateCandidates(environment.providers).filter((candidate) => | |
| 734 | canOneClickUpdateProviderCandidate(candidate, environment.providers), | |
| 735 | ), | |
| 736 | providers: environment.providers, | |
| 737 | })); | |
| 738 | const isAnySettling = environments.some( | |
| 739 | (environment) => environment.connectionState === "connecting", | |
| 740 | ); | |
| 741 | return { groups, isAnySettling }; | |
| 742 | } | |
| 743 | ||
| 744 | /** Groups that actually have a one-click update available, in display order (primary first). */ | |
| 745 | export function environmentGroupsWithUpdates( | |
| 746 | groups: ReadonlyArray<LocalEnvironmentUpdateGroup>, | |
| 747 | ): LocalEnvironmentUpdateGroup[] { | |
| 748 | return groups.filter((group) => group.candidates.length > 0); | |
| 749 | } | |
| 750 | ||
| 751 | /** | |
| 752 | * Stable key over the set of (environment, driver, latest version) updates on | |
| 753 | * offer, so the popover is shown once per distinct set and re-shown when it | |
| 754 | * changes. | |
| 755 | */ | |
| 756 | export function localEnvironmentUpdateNotificationKey( | |
| 757 | groups: ReadonlyArray<LocalEnvironmentUpdateGroup>, | |
| 758 | ): string | null { | |
| 759 | const parts = environmentGroupsWithUpdates(groups) | |
| 760 | .map((group) => { | |
| 761 | const providerParts = group.candidates | |
| 762 | .map((candidate) => `${candidate.driver}:${candidate.versionAdvisory.latestVersion}`) | |
| 763 | .toSorted() | |
| 764 | .join(","); | |
| 765 | return `${group.environmentId}=${providerParts}`; | |
| 766 | }) | |
| 767 | .toSorted(); | |
| 768 | return parts.length > 0 ? parts.join("|") : null; | |
| 769 | } | |
| 770 | ||
| 771 | export type ProviderUpdateRowStatusKind = "idle" | "loading" | "success" | "failed" | "unchanged"; | |
| 772 | ||
| 773 | export interface ProviderUpdateRowStatus { | |
| 774 | readonly kind: ProviderUpdateRowStatusKind; | |
| 775 | readonly text: string; | |
| 776 | } | |
| 777 | ||
| 778 | function environmentProviderNames(group: LocalEnvironmentUpdateGroup): string { | |
| 779 | return group.candidates | |
| 780 | .map((candidate) => PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver) | |
| 781 | .join(", "); | |
| 782 | } | |
| 783 | ||
| 784 | /** | |
| 785 | * Resolve one environment row's display from every available signal, in | |
| 786 | * priority order: a transport rejection, then the dispatch's own *terminal* | |
| 787 | * result payload (reliable even when a secondary backend's config does not | |
| 788 | * re-sync), then live server state (reliable even when the dispatch RPC is lost | |
| 789 | * to a reconnect), then the optimistic pending spinner, then the idle state. | |
| 790 | * | |
| 791 | * A non-terminal result snapshot ("running") is intentionally skipped rather | |
| 792 | * than treated as authoritative, so live server state can still drive the row | |
| 793 | * to its terminal status instead of pinning it on "Updating…". | |
| 794 | */ | |
| 795 | export function resolveEnvironmentUpdateRowStatus(input: { | |
| 796 | readonly group: LocalEnvironmentUpdateGroup; | |
| 797 | readonly error: string | undefined; | |
| 798 | readonly result: ProviderUpdateToastView | undefined; | |
| 799 | readonly pill: ProviderUpdateSidebarPillView | null; | |
| 800 | readonly isPending: boolean; | |
| 801 | }): ProviderUpdateRowStatus { | |
| 802 | if (input.error) { | |
| 803 | return { kind: "failed", text: input.error }; | |
| 804 | } | |
| 805 | if (input.result) { | |
| 806 | switch (input.result.phase) { | |
| 807 | case "succeeded": | |
| 808 | return { kind: "success", text: "Updated" }; | |
| 809 | case "failed": | |
| 810 | return { kind: "failed", text: input.result.description }; | |
| 811 | case "unchanged": | |
| 812 | return { kind: "unchanged", text: input.result.description }; | |
| 813 | // "running" / "initial": non-terminal snapshot — fall through to live state. | |
| 814 | } | |
| 815 | } | |
| 816 | if (input.pill) { | |
| 817 | switch (input.pill.tone) { | |
| 818 | case "success": | |
| 819 | return { kind: "success", text: "Updated" }; | |
| 820 | case "error": | |
| 821 | return { kind: "failed", text: input.pill.description }; | |
| 822 | case "warning": | |
| 823 | return { kind: "unchanged", text: input.pill.description }; | |
| 824 | default: | |
| 825 | return { kind: "loading", text: "Updating…" }; | |
| 826 | } | |
| 827 | } | |
| 828 | // A non-terminal result snapshot or the optimistic pending flag means an | |
| 829 | // update is still in flight — keep showing the spinner rather than reverting | |
| 830 | // to the Update button as if nothing happened. | |
| 831 | if (input.result || input.isPending) { | |
| 832 | return { kind: "loading", text: "Updating…" }; | |
| 833 | } | |
| 834 | return { kind: "idle", text: environmentProviderNames(input.group) }; | |
| 835 | } |
apps/web/src/components/ProviderUpdateLaunchNotification.tsx+138-254| ... | ... | @@ -1,313 +1,197 @@ |
| 1 | 1 | import { useNavigate } from "@tanstack/react-router"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | |
| 3 | 2 | import { DownloadIcon } from "lucide-react"; |
| 4 | import { useCallback, useEffect, useMemo, useRef } from "react"; | |
| 5 | import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; | |
| 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 6 | 4 | |
| 7 | import { primaryServerProvidersAtom, serverEnvironment } from "../state/server"; | |
| 8 | import { usePrimaryEnvironment } from "../state/environments"; | |
| 5 | import { useEnvironments } from "~/state/environments"; | |
| 6 | import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; | |
| 9 | 7 | import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; |
| 10 | import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; | |
| 8 | import { ProviderUpdateEnvironmentRows } from "./ProviderUpdateEnvironmentRows"; | |
| 9 | import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments"; | |
| 11 | 10 | import { |
| 12 | canOneClickUpdateProviderCandidate, | |
| 13 | 11 | collectProviderUpdateCandidates, |
| 14 | collectUpdatedProviderSnapshots, | |
| 15 | firstFailedProviderUpdateMessage, | |
| 12 | environmentGroupsWithUpdates, | |
| 16 | 13 | getProviderUpdateInitialToastView, |
| 17 | getProviderUpdateProgressToastView, | |
| 18 | getProviderUpdateRejectedToastView, | |
| 19 | getProviderUpdateRunningToastView, | |
| 20 | providerUpdateNotificationKey, | |
| 21 | type ProviderUpdateToastView, | |
| 14 | localEnvironmentUpdateNotificationKey, | |
| 22 | 15 | } from "./ProviderUpdateLaunchNotification.logic"; |
| 16 | import { ProviderUpdatePrimaryNotification } from "./ProviderUpdatePrimaryNotification"; | |
| 23 | 17 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 24 | import { useAtomCommand } from "../state/use-atom-command"; | |
| 25 | 18 | |
| 26 | const seenProviderUpdateNotificationKeys = new Set<string>(); | |
| 27 | type ProviderUpdateToastId = ReturnType<typeof toastManager.add>; | |
| 28 | ||
| 29 | type ActiveProviderUpdateToast = | |
| 30 | | { readonly kind: "prompt"; readonly key: string; readonly toastId: ProviderUpdateToastId } | |
| 31 | | { | |
| 32 | readonly kind: "update"; | |
| 33 | readonly key: string; | |
| 34 | readonly toastId: ProviderUpdateToastId; | |
| 35 | readonly providerInstanceIds: ReadonlySet<ProviderInstanceId>; | |
| 36 | readonly providerCount: number; | |
| 37 | }; | |
| 38 | ||
| 39 | function ProviderUpdateToastIcon({ provider }: { provider: ProviderDriverKind }) { | |
| 40 | const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[provider]; | |
| 41 | ||
| 42 | if (!ProviderIcon) { | |
| 43 | return ( | |
| 44 | <span className="relative inline-flex size-4 shrink-0 items-center justify-center"> | |
| 45 | <DownloadIcon aria-hidden="true" className="size-4 text-success" strokeWidth={2.5} /> | |
| 46 | </span> | |
| 47 | ); | |
| 48 | } | |
| 49 | ||
| 50 | return ( | |
| 51 | <span className="relative inline-flex size-4 shrink-0 items-center justify-center"> | |
| 52 | <ProviderIcon aria-hidden="true" className="size-4" /> | |
| 53 | <span className="absolute -right-1 -bottom-1 inline-flex size-3 items-center justify-center rounded-full bg-popover"> | |
| 54 | <DownloadIcon aria-hidden="true" className="size-2.5 text-success" strokeWidth={2.5} /> | |
| 55 | </span> | |
| 56 | </span> | |
| 19 | /** | |
| 20 | * True when a desktop-local secondary backend (the parallel WSL backend) is | |
| 21 | * present alongside the primary. Local secondaries connect over loopback with a | |
| 22 | * `local:<backendInstanceId>` bearer connection id; everything else (SSH, relay, | |
| 23 | * remote) is ignored. Gating on this keeps non-WSL users on the unchanged | |
| 24 | * single-prompt flow. | |
| 25 | */ | |
| 26 | function useHasLocalSecondaryEnvironment(): boolean { | |
| 27 | const { environments } = useEnvironments(); | |
| 28 | return useMemo( | |
| 29 | () => | |
| 30 | environments.some((environment) => isDesktopLocalConnectionTarget(environment.entry.target)), | |
| 31 | [environments], | |
| 57 | 32 | ); |
| 58 | 33 | } |
| 59 | 34 | |
| 60 | function updateProviderUpdateToast(input: { | |
| 61 | readonly toastId: ProviderUpdateToastId; | |
| 62 | readonly view: ProviderUpdateToastView; | |
| 63 | readonly openSettings: () => void; | |
| 64 | }) { | |
| 65 | if (input.view.type === "loading" || input.view.type === "success") { | |
| 66 | toastManager.update(input.toastId, { | |
| 67 | type: input.view.type, | |
| 68 | title: input.view.title, | |
| 69 | description: input.view.description, | |
| 70 | timeout: 0, | |
| 71 | data: { | |
| 72 | hideCopyButton: true, | |
| 73 | ...(input.view.dismissAfterVisibleMs !== undefined | |
| 74 | ? { dismissAfterVisibleMs: input.view.dismissAfterVisibleMs } | |
| 75 | : {}), | |
| 76 | }, | |
| 77 | }); | |
| 78 | return; | |
| 79 | } | |
| 35 | /** | |
| 36 | * The provider update popover. With a WSL backend present it splits the update | |
| 37 | * trigger per environment; without one (the common case) it falls back to the | |
| 38 | * single-prompt flow so non-WSL users see no change. | |
| 39 | */ | |
| 40 | export function ProviderUpdateLaunchNotification() { | |
| 41 | const hasLocalSecondary = useHasLocalSecondaryEnvironment(); | |
| 80 | 42 | |
| 81 | toastManager.update( | |
| 82 | input.toastId, | |
| 83 | stackedThreadToast({ | |
| 84 | type: input.view.type, | |
| 85 | title: input.view.title, | |
| 86 | description: input.view.description, | |
| 87 | timeout: 0, | |
| 88 | actionProps: { | |
| 89 | children: "Settings", | |
| 90 | onClick: input.openSettings, | |
| 91 | }, | |
| 92 | actionVariant: "outline", | |
| 93 | data: { | |
| 94 | hideCopyButton: true, | |
| 95 | }, | |
| 96 | }), | |
| 43 | return hasLocalSecondary ? ( | |
| 44 | <ProviderUpdateEnvironmentsNotification /> | |
| 45 | ) : ( | |
| 46 | <ProviderUpdatePrimaryNotification /> | |
| 97 | 47 | ); |
| 98 | 48 | } |
| 99 | 49 | |
| 100 | function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { | |
| 101 | return view.phase === "failed" || view.phase === "unchanged" || view.phase === "succeeded"; | |
| 102 | } | |
| 50 | const seenProviderUpdateNotificationKeys = new Set<string>(); | |
| 51 | type ProviderUpdateToastId = ReturnType<typeof toastManager.add>; | |
| 103 | 52 | |
| 104 | export function ProviderUpdateLaunchNotification() { | |
| 53 | // While a local backend (e.g. WSL) is still connecting, defer the popover so it | |
| 54 | // reflects every environment. Cap the wait so a stuck or failed backend can't | |
| 55 | // suppress the primary's updates indefinitely. | |
| 56 | const SETTLING_GRACE_MS = 30_000; | |
| 57 | ||
| 58 | function ProviderUpdateEnvironmentsNotification() { | |
| 105 | 59 | const navigate = useNavigate(); |
| 106 | const providers = useAtomValue(primaryServerProvidersAtom); | |
| 107 | const primaryEnvironment = usePrimaryEnvironment(); | |
| 108 | const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { | |
| 109 | reportFailure: false, | |
| 110 | }); | |
| 111 | const activeToastRef = useRef<ActiveProviderUpdateToast | null>(null); | |
| 60 | const { groups, isAnySettling } = useLocalEnvironmentUpdateGroups(); | |
| 112 | 61 | const { dismissedNotificationKeys, dismissNotificationKey } = |
| 113 | 62 | useDismissedProviderUpdateNotificationKeys(); |
| 114 | 63 | |
| 115 | const updateProviders = useMemo(() => collectProviderUpdateCandidates(providers), [providers]); | |
| 116 | const notificationKey = useMemo( | |
| 117 | () => providerUpdateNotificationKey(updateProviders), | |
| 118 | [updateProviders], | |
| 119 | ); | |
| 120 | const oneClickProviders = useMemo( | |
| 121 | () => | |
| 122 | updateProviders.filter((provider) => canOneClickUpdateProviderCandidate(provider, providers)), | |
| 123 | [providers, updateProviders], | |
| 124 | ); | |
| 125 | ||
| 126 | const openProviderSettings = useCallback( | |
| 127 | (toastId?: ProviderUpdateToastId) => { | |
| 128 | const activeToast = activeToastRef.current; | |
| 129 | if (toastId !== undefined) { | |
| 130 | toastManager.close(toastId); | |
| 131 | } else if (activeToast) { | |
| 132 | toastManager.close(activeToast.toastId); | |
| 133 | } | |
| 134 | if (activeToast && (toastId === undefined || activeToast.toastId === toastId)) { | |
| 64 | const activeToastRef = useRef<{ | |
| 65 | readonly toastId: ProviderUpdateToastId; | |
| 66 | readonly key: string; | |
| 67 | } | null>(null); | |
| 68 | const notificationKeyRef = useRef<string | null>(null); | |
| 69 | // Whether the user has triggered an update from the current toast. Until they | |
| 70 | // do, the prompt is replaced when the available updates change; afterward it | |
| 71 | // is kept so in-progress rows are not torn down. | |
| 72 | const hasInteractedRef = useRef(false); | |
| 73 | ||
| 74 | // Close our prompt if this flow unmounts (e.g. the WSL backend is disabled | |
| 75 | // and we fall back to the single-prompt flow). | |
| 76 | useEffect(() => { | |
| 77 | return () => { | |
| 78 | if (activeToastRef.current !== null) { | |
| 79 | toastManager.close(activeToastRef.current.toastId); | |
| 135 | 80 | activeToastRef.current = null; |
| 136 | 81 | } |
| 137 | void navigate({ to: "/settings/providers" }); | |
| 138 | }, | |
| 139 | [navigate], | |
| 82 | }; | |
| 83 | }, []); | |
| 84 | ||
| 85 | const updateGroups = useMemo(() => environmentGroupsWithUpdates(groups), [groups]); | |
| 86 | const notificationKey = useMemo(() => localEnvironmentUpdateNotificationKey(groups), [groups]); | |
| 87 | useEffect(() => { | |
| 88 | notificationKeyRef.current = notificationKey; | |
| 89 | }, [notificationKey]); | |
| 90 | ||
| 91 | // Title summarizes the distinct providers on offer across all environments; | |
| 92 | // the per-environment detail lives in the popover body. | |
| 93 | const candidateUnion = useMemo( | |
| 94 | () => collectProviderUpdateCandidates(updateGroups.flatMap((group) => group.candidates)), | |
| 95 | [updateGroups], | |
| 140 | 96 | ); |
| 141 | 97 | |
| 98 | // Defer while any local backend is still connecting, up to the grace period. | |
| 99 | const [settleGraceElapsed, setSettleGraceElapsed] = useState(false); | |
| 142 | 100 | useEffect(() => { |
| 143 | const activeToast = activeToastRef.current; | |
| 144 | if (activeToast?.kind !== "update") { | |
| 101 | if (!isAnySettling) { | |
| 102 | setSettleGraceElapsed(false); | |
| 145 | 103 | return; |
| 146 | 104 | } |
| 147 | ||
| 148 | const activeProviders = providers.filter((provider) => | |
| 149 | activeToast.providerInstanceIds.has(provider.instanceId), | |
| 150 | ); | |
| 151 | const view = getProviderUpdateProgressToastView({ | |
| 152 | providers: activeProviders, | |
| 153 | providerCount: activeToast.providerCount, | |
| 154 | }); | |
| 155 | updateProviderUpdateToast({ | |
| 156 | toastId: activeToast.toastId, | |
| 157 | view, | |
| 158 | openSettings: () => openProviderSettings(activeToast.toastId), | |
| 159 | }); | |
| 160 | ||
| 161 | if (isTerminalProviderUpdateToastView(view)) { | |
| 105 | const timer = setTimeout(() => setSettleGraceElapsed(true), SETTLING_GRACE_MS); | |
| 106 | return () => clearTimeout(timer); | |
| 107 | }, [isAnySettling]); | |
| 108 | const isGated = isAnySettling && !settleGraceElapsed; | |
| 109 | ||
| 110 | const openProviderSettings = useCallback(() => { | |
| 111 | const active = activeToastRef.current; | |
| 112 | if (active !== null) { | |
| 113 | toastManager.close(active.toastId); | |
| 162 | 114 | activeToastRef.current = null; |
| 163 | 115 | } |
| 164 | }, [providers, openProviderSettings]); | |
| 116 | void navigate({ to: "/settings/providers" }); | |
| 117 | }, [navigate]); | |
| 165 | 118 | |
| 166 | 119 | useEffect(() => { |
| 167 | const activeToast = activeToastRef.current; | |
| 168 | if (activeToast?.kind === "prompt" && activeToast.key !== notificationKey) { | |
| 169 | toastManager.close(activeToast.toastId); | |
| 120 | // Whether a fresh prompt can actually be shown for the current update set. | |
| 121 | const canShowPrompt = | |
| 122 | notificationKey !== null && | |
| 123 | !isGated && | |
| 124 | !dismissedNotificationKeys.has(notificationKey) && | |
| 125 | !seenProviderUpdateNotificationKeys.has(notificationKey); | |
| 126 | ||
| 127 | // Close a prompt the user hasn't acted on yet when the available updates | |
| 128 | // change: when they clear entirely (key null) so the toast doesn't linger, | |
| 129 | // and when a fresh set is ready to replace it. Keep it only while a backend | |
| 130 | // is re-settling (updates still exist, just gated) — and once an update is | |
| 131 | // in progress, so its rows survive. | |
| 132 | const active = activeToastRef.current; | |
| 133 | if ( | |
| 134 | active && | |
| 135 | active.key !== notificationKey && | |
| 136 | !hasInteractedRef.current && | |
| 137 | (notificationKey === null || !isGated) | |
| 138 | ) { | |
| 139 | toastManager.close(active.toastId); | |
| 170 | 140 | activeToastRef.current = null; |
| 171 | 141 | } |
| 172 | 142 | |
| 173 | if ( | |
| 174 | !notificationKey || | |
| 175 | dismissedNotificationKeys.has(notificationKey) || | |
| 176 | seenProviderUpdateNotificationKeys.has(notificationKey) || | |
| 177 | activeToastRef.current | |
| 178 | ) { | |
| 143 | if (!notificationKey || !canShowPrompt || activeToastRef.current !== null) { | |
| 179 | 144 | return; |
| 180 | 145 | } |
| 181 | 146 | |
| 182 | 147 | seenProviderUpdateNotificationKeys.add(notificationKey); |
| 148 | hasInteractedRef.current = false; | |
| 183 | 149 | |
| 184 | const initialView = getProviderUpdateInitialToastView({ updateProviders, oneClickProviders }); | |
| 185 | ||
| 186 | let toastId!: ProviderUpdateToastId; | |
| 187 | let updateStarted = false; | |
| 188 | const openSettings = () => openProviderSettings(toastId); | |
| 189 | 150 | const dismissPrompt = () => { |
| 190 | dismissNotificationKey(notificationKey); | |
| 191 | }; | |
| 192 | ||
| 193 | const runUpdates = () => { | |
| 194 | if (updateStarted || oneClickProviders.length === 0 || !primaryEnvironment) { | |
| 195 | return; | |
| 151 | // Dismiss whatever set is still on offer at close time, so the popover | |
| 152 | // does not re-pop for updates the user just declined. | |
| 153 | const liveKey = notificationKeyRef.current; | |
| 154 | if (liveKey) { | |
| 155 | dismissNotificationKey(liveKey); | |
| 196 | 156 | } |
| 197 | updateStarted = true; | |
| 198 | ||
| 199 | const providerCount = oneClickProviders.length; | |
| 200 | const providerInstanceIds = new Set(oneClickProviders.map((provider) => provider.instanceId)); | |
| 201 | activeToastRef.current = { | |
| 202 | kind: "update", | |
| 203 | key: notificationKey, | |
| 204 | toastId, | |
| 205 | providerInstanceIds, | |
| 206 | providerCount, | |
| 207 | }; | |
| 208 | ||
| 209 | updateProviderUpdateToast({ | |
| 210 | toastId, | |
| 211 | view: getProviderUpdateRunningToastView(providerCount), | |
| 212 | openSettings, | |
| 213 | }); | |
| 214 | ||
| 215 | void (async () => { | |
| 216 | const results = []; | |
| 217 | for (const provider of oneClickProviders) { | |
| 218 | results.push( | |
| 219 | await updateProvider({ | |
| 220 | environmentId: primaryEnvironment.environmentId, | |
| 221 | input: { | |
| 222 | provider: provider.driver, | |
| 223 | instanceId: provider.instanceId, | |
| 224 | }, | |
| 225 | }), | |
| 226 | ); | |
| 227 | } | |
| 228 | ||
| 229 | const activeUpdateToast = activeToastRef.current; | |
| 230 | if (activeUpdateToast?.kind !== "update" || activeUpdateToast.toastId !== toastId) { | |
| 231 | return; | |
| 232 | } | |
| 233 | ||
| 234 | const failedMessage = firstFailedProviderUpdateMessage(results); | |
| 235 | if (failedMessage) { | |
| 236 | updateProviderUpdateToast({ | |
| 237 | toastId, | |
| 238 | view: getProviderUpdateRejectedToastView(providerCount, failedMessage), | |
| 239 | openSettings, | |
| 240 | }); | |
| 241 | activeToastRef.current = null; | |
| 242 | return; | |
| 243 | } | |
| 244 | ||
| 245 | const updatedProviderSnapshots = collectUpdatedProviderSnapshots({ | |
| 246 | results, | |
| 247 | providerInstanceIds, | |
| 248 | }); | |
| 249 | const view = getProviderUpdateProgressToastView({ | |
| 250 | providers: updatedProviderSnapshots, | |
| 251 | providerCount, | |
| 252 | }); | |
| 253 | updateProviderUpdateToast({ | |
| 254 | toastId, | |
| 255 | view, | |
| 256 | openSettings, | |
| 257 | }); | |
| 258 | ||
| 259 | if (isTerminalProviderUpdateToastView(view)) { | |
| 260 | activeToastRef.current = null; | |
| 261 | } | |
| 262 | })(); | |
| 157 | activeToastRef.current = null; | |
| 263 | 158 | }; |
| 264 | 159 | |
| 265 | toastId = toastManager.add( | |
| 160 | const toastId = toastManager.add( | |
| 266 | 161 | stackedThreadToast({ |
| 267 | type: initialView.type, | |
| 268 | title: initialView.title, | |
| 269 | description: initialView.description, | |
| 162 | type: "warning", | |
| 163 | title: getProviderUpdateInitialToastView({ | |
| 164 | updateProviders: candidateUnion, | |
| 165 | oneClickProviders: candidateUnion, | |
| 166 | }).title, | |
| 167 | description: ( | |
| 168 | <ProviderUpdateEnvironmentRows | |
| 169 | onInteract={() => { | |
| 170 | hasInteractedRef.current = true; | |
| 171 | }} | |
| 172 | /> | |
| 173 | ), | |
| 270 | 174 | timeout: 0, |
| 271 | actionProps: | |
| 272 | oneClickProviders.length > 0 | |
| 273 | ? { | |
| 274 | children: "Update", | |
| 275 | onClick: runUpdates, | |
| 276 | } | |
| 277 | : { | |
| 278 | children: "Settings", | |
| 279 | onClick: openSettings, | |
| 280 | }, | |
| 281 | actionVariant: oneClickProviders.length > 0 ? "default" : "outline", | |
| 175 | actionProps: { | |
| 176 | children: "Settings", | |
| 177 | onClick: openProviderSettings, | |
| 178 | }, | |
| 179 | actionVariant: "outline", | |
| 282 | 180 | data: { |
| 283 | leadingIcon: | |
| 284 | updateProviders.length === 1 ? ( | |
| 285 | <ProviderUpdateToastIcon provider={updateProviders[0]!.driver} /> | |
| 286 | ) : undefined, | |
| 287 | 181 | hideCopyButton: true, |
| 182 | leadingIcon: <DownloadIcon aria-hidden="true" className="size-4 text-success" />, | |
| 288 | 183 | onClose: dismissPrompt, |
| 289 | ...(oneClickProviders.length > 0 | |
| 290 | ? { | |
| 291 | secondaryActionProps: { | |
| 292 | children: "Settings", | |
| 293 | onClick: openSettings, | |
| 294 | }, | |
| 295 | secondaryActionVariant: "outline" as const, | |
| 296 | } | |
| 297 | : {}), | |
| 298 | 184 | }, |
| 299 | 185 | }), |
| 300 | 186 | ); |
| 301 | activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; | |
| 187 | activeToastRef.current = { toastId, key: notificationKey }; | |
| 302 | 188 | }, [ |
| 303 | updateProvider, | |
| 304 | dismissNotificationKey, | |
| 305 | dismissedNotificationKeys, | |
| 306 | 189 | notificationKey, |
| 307 | oneClickProviders, | |
| 190 | isGated, | |
| 191 | candidateUnion, | |
| 192 | dismissedNotificationKeys, | |
| 193 | dismissNotificationKey, | |
| 308 | 194 | openProviderSettings, |
| 309 | primaryEnvironment, | |
| 310 | updateProviders, | |
| 311 | 195 | ]); |
| 312 | 196 | |
| 313 | 197 | return null; |
apps/web/src/components/ProviderUpdatePrimaryNotification.tsx created+331| ... | ... | @@ -0,0 +1,331 @@ |
| 1 | import { useNavigate } from "@tanstack/react-router"; | |
| 2 | import { useAtomValue } from "@effect/atom-react"; | |
| 3 | import { DownloadIcon } from "lucide-react"; | |
| 4 | import { useCallback, useEffect, useMemo, useRef } from "react"; | |
| 5 | import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; | |
| 6 | ||
| 7 | import { primaryServerProvidersAtom, serverEnvironment } from "../state/server"; | |
| 8 | import { usePrimaryEnvironment } from "../state/environments"; | |
| 9 | import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; | |
| 10 | import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; | |
| 11 | import { | |
| 12 | canOneClickUpdateProviderCandidate, | |
| 13 | collectProviderUpdateCandidates, | |
| 14 | collectUpdatedProviderSnapshots, | |
| 15 | firstFailedProviderUpdateMessage, | |
| 16 | getProviderUpdateInitialToastView, | |
| 17 | getProviderUpdateProgressToastView, | |
| 18 | getProviderUpdateRejectedToastView, | |
| 19 | getProviderUpdateRunningToastView, | |
| 20 | providerUpdateNotificationKey, | |
| 21 | type ProviderUpdateToastView, | |
| 22 | } from "./ProviderUpdateLaunchNotification.logic"; | |
| 23 | import { stackedThreadToast, toastManager } from "./ui/toast"; | |
| 24 | import { useAtomCommand } from "../state/use-atom-command"; | |
| 25 | ||
| 26 | const seenProviderUpdateNotificationKeys = new Set<string>(); | |
| 27 | type ProviderUpdateToastId = ReturnType<typeof toastManager.add>; | |
| 28 | ||
| 29 | type ActiveProviderUpdateToast = | |
| 30 | | { readonly kind: "prompt"; readonly key: string; readonly toastId: ProviderUpdateToastId } | |
| 31 | | { | |
| 32 | readonly kind: "update"; | |
| 33 | readonly key: string; | |
| 34 | readonly toastId: ProviderUpdateToastId; | |
| 35 | readonly providerInstanceIds: ReadonlySet<ProviderInstanceId>; | |
| 36 | readonly providerCount: number; | |
| 37 | }; | |
| 38 | ||
| 39 | function ProviderUpdateToastIcon({ provider }: { provider: ProviderDriverKind }) { | |
| 40 | const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[provider]; | |
| 41 | ||
| 42 | if (!ProviderIcon) { | |
| 43 | return ( | |
| 44 | <span className="relative inline-flex size-4 shrink-0 items-center justify-center"> | |
| 45 | <DownloadIcon aria-hidden="true" className="size-4 text-success" strokeWidth={2.5} /> | |
| 46 | </span> | |
| 47 | ); | |
| 48 | } | |
| 49 | ||
| 50 | return ( | |
| 51 | <span className="relative inline-flex size-4 shrink-0 items-center justify-center"> | |
| 52 | <ProviderIcon aria-hidden="true" className="size-4" /> | |
| 53 | <span className="absolute -right-1 -bottom-1 inline-flex size-3 items-center justify-center rounded-full bg-popover"> | |
| 54 | <DownloadIcon aria-hidden="true" className="size-2.5 text-success" strokeWidth={2.5} /> | |
| 55 | </span> | |
| 56 | </span> | |
| 57 | ); | |
| 58 | } | |
| 59 | ||
| 60 | function updateProviderUpdateToast(input: { | |
| 61 | readonly toastId: ProviderUpdateToastId; | |
| 62 | readonly view: ProviderUpdateToastView; | |
| 63 | readonly openSettings: () => void; | |
| 64 | }) { | |
| 65 | if (input.view.type === "loading" || input.view.type === "success") { | |
| 66 | toastManager.update(input.toastId, { | |
| 67 | type: input.view.type, | |
| 68 | title: input.view.title, | |
| 69 | description: input.view.description, | |
| 70 | timeout: 0, | |
| 71 | data: { | |
| 72 | hideCopyButton: true, | |
| 73 | ...(input.view.dismissAfterVisibleMs !== undefined | |
| 74 | ? { dismissAfterVisibleMs: input.view.dismissAfterVisibleMs } | |
| 75 | : {}), | |
| 76 | }, | |
| 77 | }); | |
| 78 | return; | |
| 79 | } | |
| 80 | ||
| 81 | toastManager.update( | |
| 82 | input.toastId, | |
| 83 | stackedThreadToast({ | |
| 84 | type: input.view.type, | |
| 85 | title: input.view.title, | |
| 86 | description: input.view.description, | |
| 87 | timeout: 0, | |
| 88 | actionProps: { | |
| 89 | children: "Settings", | |
| 90 | onClick: input.openSettings, | |
| 91 | }, | |
| 92 | actionVariant: "outline", | |
| 93 | data: { | |
| 94 | hideCopyButton: true, | |
| 95 | }, | |
| 96 | }), | |
| 97 | ); | |
| 98 | } | |
| 99 | ||
| 100 | function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { | |
| 101 | return view.phase === "failed" || view.phase === "unchanged" || view.phase === "succeeded"; | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * The single-prompt provider update notification used when there is only one | |
| 106 | * local environment (no WSL backend). Non-WSL users see exactly this flow — the | |
| 107 | * per-environment split is gated behind WSL presence. | |
| 108 | */ | |
| 109 | export function ProviderUpdatePrimaryNotification() { | |
| 110 | const navigate = useNavigate(); | |
| 111 | const providers = useAtomValue(primaryServerProvidersAtom); | |
| 112 | const primaryEnvironment = usePrimaryEnvironment(); | |
| 113 | const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { | |
| 114 | reportFailure: false, | |
| 115 | }); | |
| 116 | const activeToastRef = useRef<ActiveProviderUpdateToast | null>(null); | |
| 117 | const { dismissedNotificationKeys, dismissNotificationKey } = | |
| 118 | useDismissedProviderUpdateNotificationKeys(); | |
| 119 | ||
| 120 | // If this flow unmounts (e.g. a WSL backend appears and we switch to the | |
| 121 | // per-environment popover), close any prompt it owns so it does not linger. | |
| 122 | useEffect(() => { | |
| 123 | return () => { | |
| 124 | const activeToast = activeToastRef.current; | |
| 125 | if (activeToast) { | |
| 126 | toastManager.close(activeToast.toastId); | |
| 127 | activeToastRef.current = null; | |
| 128 | } | |
| 129 | }; | |
| 130 | }, []); | |
| 131 | ||
| 132 | const updateProviders = useMemo(() => collectProviderUpdateCandidates(providers), [providers]); | |
| 133 | const notificationKey = useMemo( | |
| 134 | () => providerUpdateNotificationKey(updateProviders), | |
| 135 | [updateProviders], | |
| 136 | ); | |
| 137 | const oneClickProviders = useMemo( | |
| 138 | () => | |
| 139 | updateProviders.filter((provider) => canOneClickUpdateProviderCandidate(provider, providers)), | |
| 140 | [providers, updateProviders], | |
| 141 | ); | |
| 142 | ||
| 143 | const openProviderSettings = useCallback( | |
| 144 | (toastId?: ProviderUpdateToastId) => { | |
| 145 | const activeToast = activeToastRef.current; | |
| 146 | if (toastId !== undefined) { | |
| 147 | toastManager.close(toastId); | |
| 148 | } else if (activeToast) { | |
| 149 | toastManager.close(activeToast.toastId); | |
| 150 | } | |
| 151 | if (activeToast && (toastId === undefined || activeToast.toastId === toastId)) { | |
| 152 | activeToastRef.current = null; | |
| 153 | } | |
| 154 | void navigate({ to: "/settings/providers" }); | |
| 155 | }, | |
| 156 | [navigate], | |
| 157 | ); | |
| 158 | ||
| 159 | useEffect(() => { | |
| 160 | const activeToast = activeToastRef.current; | |
| 161 | if (activeToast?.kind !== "update") { | |
| 162 | return; | |
| 163 | } | |
| 164 | ||
| 165 | const activeProviders = providers.filter((provider) => | |
| 166 | activeToast.providerInstanceIds.has(provider.instanceId), | |
| 167 | ); | |
| 168 | const view = getProviderUpdateProgressToastView({ | |
| 169 | providers: activeProviders, | |
| 170 | providerCount: activeToast.providerCount, | |
| 171 | }); | |
| 172 | updateProviderUpdateToast({ | |
| 173 | toastId: activeToast.toastId, | |
| 174 | view, | |
| 175 | openSettings: () => openProviderSettings(activeToast.toastId), | |
| 176 | }); | |
| 177 | ||
| 178 | if (isTerminalProviderUpdateToastView(view)) { | |
| 179 | activeToastRef.current = null; | |
| 180 | } | |
| 181 | }, [providers, openProviderSettings]); | |
| 182 | ||
| 183 | useEffect(() => { | |
| 184 | const activeToast = activeToastRef.current; | |
| 185 | if (activeToast?.kind === "prompt" && activeToast.key !== notificationKey) { | |
| 186 | toastManager.close(activeToast.toastId); | |
| 187 | activeToastRef.current = null; | |
| 188 | } | |
| 189 | ||
| 190 | if ( | |
| 191 | !notificationKey || | |
| 192 | dismissedNotificationKeys.has(notificationKey) || | |
| 193 | seenProviderUpdateNotificationKeys.has(notificationKey) || | |
| 194 | activeToastRef.current | |
| 195 | ) { | |
| 196 | return; | |
| 197 | } | |
| 198 | ||
| 199 | seenProviderUpdateNotificationKeys.add(notificationKey); | |
| 200 | ||
| 201 | const initialView = getProviderUpdateInitialToastView({ updateProviders, oneClickProviders }); | |
| 202 | ||
| 203 | let toastId!: ProviderUpdateToastId; | |
| 204 | let updateStarted = false; | |
| 205 | const openSettings = () => openProviderSettings(toastId); | |
| 206 | const dismissPrompt = () => { | |
| 207 | dismissNotificationKey(notificationKey); | |
| 208 | }; | |
| 209 | ||
| 210 | const runUpdates = () => { | |
| 211 | if (updateStarted || oneClickProviders.length === 0 || !primaryEnvironment) { | |
| 212 | return; | |
| 213 | } | |
| 214 | updateStarted = true; | |
| 215 | ||
| 216 | const providerCount = oneClickProviders.length; | |
| 217 | const providerInstanceIds = new Set(oneClickProviders.map((provider) => provider.instanceId)); | |
| 218 | activeToastRef.current = { | |
| 219 | kind: "update", | |
| 220 | key: notificationKey, | |
| 221 | toastId, | |
| 222 | providerInstanceIds, | |
| 223 | providerCount, | |
| 224 | }; | |
| 225 | ||
| 226 | updateProviderUpdateToast({ | |
| 227 | toastId, | |
| 228 | view: getProviderUpdateRunningToastView(providerCount), | |
| 229 | openSettings, | |
| 230 | }); | |
| 231 | ||
| 232 | void (async () => { | |
| 233 | const results = []; | |
| 234 | for (const provider of oneClickProviders) { | |
| 235 | results.push( | |
| 236 | await updateProvider({ | |
| 237 | environmentId: primaryEnvironment.environmentId, | |
| 238 | input: { | |
| 239 | provider: provider.driver, | |
| 240 | instanceId: provider.instanceId, | |
| 241 | }, | |
| 242 | }), | |
| 243 | ); | |
| 244 | } | |
| 245 | ||
| 246 | const activeUpdateToast = activeToastRef.current; | |
| 247 | if (activeUpdateToast?.kind !== "update" || activeUpdateToast.toastId !== toastId) { | |
| 248 | return; | |
| 249 | } | |
| 250 | ||
| 251 | const failedMessage = firstFailedProviderUpdateMessage(results); | |
| 252 | if (failedMessage) { | |
| 253 | updateProviderUpdateToast({ | |
| 254 | toastId, | |
| 255 | view: getProviderUpdateRejectedToastView(providerCount, failedMessage), | |
| 256 | openSettings, | |
| 257 | }); | |
| 258 | activeToastRef.current = null; | |
| 259 | return; | |
| 260 | } | |
| 261 | ||
| 262 | const updatedProviderSnapshots = collectUpdatedProviderSnapshots({ | |
| 263 | results, | |
| 264 | providerInstanceIds, | |
| 265 | }); | |
| 266 | const view = getProviderUpdateProgressToastView({ | |
| 267 | providers: updatedProviderSnapshots, | |
| 268 | providerCount, | |
| 269 | }); | |
| 270 | updateProviderUpdateToast({ | |
| 271 | toastId, | |
| 272 | view, | |
| 273 | openSettings, | |
| 274 | }); | |
| 275 | ||
| 276 | if (isTerminalProviderUpdateToastView(view)) { | |
| 277 | activeToastRef.current = null; | |
| 278 | } | |
| 279 | })(); | |
| 280 | }; | |
| 281 | ||
| 282 | toastId = toastManager.add( | |
| 283 | stackedThreadToast({ | |
| 284 | type: initialView.type, | |
| 285 | title: initialView.title, | |
| 286 | description: initialView.description, | |
| 287 | timeout: 0, | |
| 288 | actionProps: | |
| 289 | oneClickProviders.length > 0 | |
| 290 | ? { | |
| 291 | children: "Update", | |
| 292 | onClick: runUpdates, | |
| 293 | } | |
| 294 | : { | |
| 295 | children: "Settings", | |
| 296 | onClick: openSettings, | |
| 297 | }, | |
| 298 | actionVariant: oneClickProviders.length > 0 ? "default" : "outline", | |
| 299 | data: { | |
| 300 | leadingIcon: | |
| 301 | updateProviders.length === 1 ? ( | |
| 302 | <ProviderUpdateToastIcon provider={updateProviders[0]!.driver} /> | |
| 303 | ) : undefined, | |
| 304 | hideCopyButton: true, | |
| 305 | onClose: dismissPrompt, | |
| 306 | ...(oneClickProviders.length > 0 | |
| 307 | ? { | |
| 308 | secondaryActionProps: { | |
| 309 | children: "Settings", | |
| 310 | onClick: openSettings, | |
| 311 | }, | |
| 312 | secondaryActionVariant: "outline" as const, | |
| 313 | } | |
| 314 | : {}), | |
| 315 | }, | |
| 316 | }), | |
| 317 | ); | |
| 318 | activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; | |
| 319 | }, [ | |
| 320 | updateProvider, | |
| 321 | dismissNotificationKey, | |
| 322 | dismissedNotificationKeys, | |
| 323 | notificationKey, | |
| 324 | oneClickProviders, | |
| 325 | openProviderSettings, | |
| 326 | primaryEnvironment, | |
| 327 | updateProviders, | |
| 328 | ]); | |
| 329 | ||
| 330 | return null; | |
| 331 | } |
apps/web/src/components/Sidebar.tsx+119-8| ... | ... | @@ -3,8 +3,10 @@ import { |
| 3 | 3 | ArrowUpDownIcon, |
| 4 | 4 | ChevronRightIcon, |
| 5 | 5 | CloudIcon, |
| 6 | ContainerIcon, | |
| 6 | 7 | FolderPlusIcon, |
| 7 | 8 | Globe2Icon, |
| 9 | LoaderIcon, | |
| 8 | 10 | SearchIcon, |
| 9 | 11 | SettingsIcon, |
| 10 | 12 | SquarePenIcon, |
| ... | ... | @@ -69,6 +71,8 @@ import { |
| 69 | 71 | type SidebarThreadPreviewCount, |
| 70 | 72 | type SidebarThreadSortOrder, |
| 71 | 73 | } from "@t3tools/contracts/settings"; |
| 74 | import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; | |
| 75 | import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; | |
| 72 | 76 | import { isElectron } from "../env"; |
| 73 | 77 | import { APP_STAGE_LABEL } from "../branding"; |
| 74 | 78 | import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| ... | ... | @@ -398,7 +402,16 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 398 | 402 | const isRemoteThread = |
| 399 | 403 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; |
| 400 | 404 | const remoteEnvLabel = environment?.label ?? null; |
| 401 | const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; | |
| 405 | // A desktop-local secondary backend (e.g. the WSL backend) shows up as a | |
| 406 | // bearer environment whose connection id is prefixed "local:". It runs on the | |
| 407 | // user's own machine, so the cloud icon is misleading — label it "Local" and | |
| 408 | // suppress the cloud icon (the project header already shows a container icon | |
| 409 | // for desktop-local projects, see sidebarProjectGrouping). | |
| 410 | const isDesktopLocalThread = | |
| 411 | environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); | |
| 412 | const threadEnvironmentLabel = isRemoteThread | |
| 413 | ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) | |
| 414 | : null; | |
| 402 | 415 | // For grouped projects, the thread may belong to a different environment |
| 403 | 416 | // than the representative project. Look up the thread's own project cwd |
| 404 | 417 | // so git status (and thus PR detection) queries the correct path. |
| ... | ... | @@ -821,7 +834,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 821 | 834 | ) : null} |
| 822 | 835 | <span className={threadMetaClassName}> |
| 823 | 836 | <span className="inline-flex items-center gap-1"> |
| 824 | {isRemoteThread && ( | |
| 837 | {isRemoteThread && !isDesktopLocalThread && ( | |
| 825 | 838 | <Tooltip> |
| 826 | 839 | <TooltipTrigger |
| 827 | 840 | render={ |
| ... | ... | @@ -2249,18 +2262,24 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2249 | 2262 | render={ |
| 2250 | 2263 | <span |
| 2251 | 2264 | aria-label={ |
| 2252 | project.environmentPresence === "remote-only" | |
| 2253 | ? "Remote project" | |
| 2254 | : "Available in multiple environments" | |
| 2265 | project.allRemoteMembersAreDesktopLocal | |
| 2266 | ? "Local sandbox project" | |
| 2267 | : "Remote project" | |
| 2255 | 2268 | } |
| 2256 | 2269 | className="pointer-events-none absolute top-1 right-1.5 inline-flex size-5 items-center justify-center rounded-md text-muted-foreground/60 transition-opacity duration-150 max-sm:right-7 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0 max-sm:group-hover/project-header:opacity-100 max-sm:group-focus-within/project-header:opacity-100" |
| 2257 | 2270 | /> |
| 2258 | 2271 | } |
| 2259 | 2272 | > |
| 2260 | <CloudIcon className="size-3" /> | |
| 2273 | {project.allRemoteMembersAreDesktopLocal ? ( | |
| 2274 | <ContainerIcon className="size-3" /> | |
| 2275 | ) : ( | |
| 2276 | <CloudIcon className="size-3" /> | |
| 2277 | )} | |
| 2261 | 2278 | </TooltipTrigger> |
| 2262 | 2279 | <TooltipPopup side="top"> |
| 2263 | Remote environment: {project.remoteEnvironmentLabels.join(", ")} | |
| 2280 | {project.allRemoteMembersAreDesktopLocal | |
| 2281 | ? `Local sandbox: ${project.remoteEnvironmentLabels.join(", ")}` | |
| 2282 | : `Remote environment: ${project.remoteEnvironmentLabels.join(", ")}`} | |
| 2264 | 2283 | </TooltipPopup> |
| 2265 | 2284 | </Tooltip> |
| 2266 | 2285 | )} |
| ... | ... | @@ -2452,6 +2471,81 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar |
| 2452 | 2471 | ); |
| 2453 | 2472 | }); |
| 2454 | 2473 | |
| 2474 | function LocalSecondaryStatus() { | |
| 2475 | const { environments } = useEnvironments(); | |
| 2476 | // The desktop reports which local secondary backends (e.g. the WSL backend) | |
| 2477 | // exist; the hook polls because the bridge has no change event. A backend that | |
| 2478 | // is still cold-booting has no httpBaseUrl yet and isn't in the catalog, so we | |
| 2479 | // surface "Connecting" straight from the bootstrap list and clear it once the | |
| 2480 | // matching environment reports a connected phase. | |
| 2481 | const secondaries = useDesktopLocalBootstraps(); | |
| 2482 | ||
| 2483 | // Connected desktop-local environments keyed by their backend URL so we can | |
| 2484 | // match a bootstrap (which only knows the URL) to its connection phase. | |
| 2485 | const localEnvByUrl = useMemo(() => { | |
| 2486 | const map = new Map<string, { phase: string; error: string | null }>(); | |
| 2487 | for (const environment of environments) { | |
| 2488 | if ( | |
| 2489 | isDesktopLocalConnectionTarget(environment.entry.target) && | |
| 2490 | environment.displayUrl !== null | |
| 2491 | ) { | |
| 2492 | map.set(environment.displayUrl, { | |
| 2493 | phase: environment.connection.phase, | |
| 2494 | error: environment.connection.error, | |
| 2495 | }); | |
| 2496 | } | |
| 2497 | } | |
| 2498 | return map; | |
| 2499 | }, [environments]); | |
| 2500 | ||
| 2501 | const connecting: string[] = []; | |
| 2502 | const failed: Array<{ label: string; error: string | null }> = []; | |
| 2503 | for (const bootstrap of secondaries) { | |
| 2504 | const env = | |
| 2505 | bootstrap.httpBaseUrl !== null ? localEnvByUrl.get(bootstrap.httpBaseUrl) : undefined; | |
| 2506 | if (env?.phase === "connected") { | |
| 2507 | continue; | |
| 2508 | } | |
| 2509 | if (env?.phase === "error") { | |
| 2510 | failed.push({ label: bootstrap.label, error: env.error }); | |
| 2511 | continue; | |
| 2512 | } | |
| 2513 | connecting.push(bootstrap.label); | |
| 2514 | } | |
| 2515 | ||
| 2516 | if (connecting.length === 0 && failed.length === 0) { | |
| 2517 | return null; | |
| 2518 | } | |
| 2519 | ||
| 2520 | return ( | |
| 2521 | <SidebarGroup className="px-2 pt-2 pb-0"> | |
| 2522 | {connecting.length > 0 ? ( | |
| 2523 | <Alert | |
| 2524 | variant="default" | |
| 2525 | className="rounded-2xl border-border/40 bg-accent/40 text-muted-foreground" | |
| 2526 | > | |
| 2527 | <LoaderIcon className="animate-spin" /> | |
| 2528 | <AlertTitle className="text-xs font-medium text-foreground"> | |
| 2529 | Connecting {connecting.join(", ")} | |
| 2530 | </AlertTitle> | |
| 2531 | </Alert> | |
| 2532 | ) : null} | |
| 2533 | {failed.length > 0 ? ( | |
| 2534 | <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8"> | |
| 2535 | <TriangleAlertIcon /> | |
| 2536 | <AlertTitle>Couldn't connect {failed.map((entry) => entry.label).join(", ")}</AlertTitle> | |
| 2537 | <AlertDescription> | |
| 2538 | {failed | |
| 2539 | .map((entry) => entry.error) | |
| 2540 | .filter(Boolean) | |
| 2541 | .join("; ") || "The backend didn't respond."} | |
| 2542 | </AlertDescription> | |
| 2543 | </Alert> | |
| 2544 | ) : null} | |
| 2545 | </SidebarGroup> | |
| 2546 | ); | |
| 2547 | } | |
| 2548 | ||
| 2455 | 2549 | type SortableProjectHandleProps = Pick< |
| 2456 | 2550 | ReturnType<typeof useSortable>, |
| 2457 | 2551 | "attributes" | "listeners" | "setActivatorNodeRef" |
| ... | ... | @@ -2889,6 +2983,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( |
| 2889 | 2983 | </Alert> |
| 2890 | 2984 | </SidebarGroup> |
| 2891 | 2985 | ) : null} |
| 2986 | <LocalSecondaryStatus /> | |
| 2892 | 2987 | <SidebarGroup className="px-2 py-2"> |
| 2893 | 2988 | <div className="mb-1 flex items-center justify-between pl-2 pr-1.5"> |
| 2894 | 2989 | <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60"> |
| ... | ... | @@ -3058,6 +3153,15 @@ export default function Sidebar() { |
| 3058 | 3153 | ), |
| 3059 | 3154 | [environments], |
| 3060 | 3155 | ); |
| 3156 | const desktopLocalEnvironmentIds = useMemo( | |
| 3157 | () => | |
| 3158 | new Set( | |
| 3159 | environments | |
| 3160 | .filter((environment) => isDesktopLocalConnectionTarget(environment.entry.target)) | |
| 3161 | .map((environment) => environment.environmentId), | |
| 3162 | ), | |
| 3163 | [environments], | |
| 3164 | ); | |
| 3061 | 3165 | const orderedProjects = useMemo(() => { |
| 3062 | 3166 | return orderItemsByPreferredIds({ |
| 3063 | 3167 | items: projects, |
| ... | ... | @@ -3096,8 +3200,15 @@ export default function Sidebar() { |
| 3096 | 3200 | settings: projectGroupingSettings, |
| 3097 | 3201 | primaryEnvironmentId, |
| 3098 | 3202 | resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, |
| 3203 | isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), | |
| 3099 | 3204 | }); |
| 3100 | }, [environmentLabelById, orderedProjects, projectGroupingSettings, primaryEnvironmentId]); | |
| 3205 | }, [ | |
| 3206 | environmentLabelById, | |
| 3207 | desktopLocalEnvironmentIds, | |
| 3208 | orderedProjects, | |
| 3209 | projectGroupingSettings, | |
| 3210 | primaryEnvironmentId, | |
| 3211 | ]); | |
| 3101 | 3212 | |
| 3102 | 3213 | const sidebarProjectByKey = useMemo( |
| 3103 | 3214 | () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), |
apps/web/src/components/settings/ConnectionsSettings.logic.test.ts created+75| ... | ... | @@ -0,0 +1,75 @@ |
| 1 | import type { DesktopWslState } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; | |
| 4 | ||
| 5 | const baseWslState: DesktopWslState = { | |
| 6 | enabled: false, | |
| 7 | distro: null, | |
| 8 | available: true, | |
| 9 | wslOnly: true, | |
| 10 | distros: [], | |
| 11 | preflightError: null, | |
| 12 | }; | |
| 13 | ||
| 14 | describe("applyWslEnableSelection", () => { | |
| 15 | it("clears WSL-only and updates the distro before enabling both backends", async () => { | |
| 16 | const calls: Array<string> = []; | |
| 17 | let persistedWslOnly = true; | |
| 18 | let persistedDistro: string | null = "Ubuntu"; | |
| 19 | const setWslDistro = vi.fn(async (distro: string | null) => { | |
| 20 | calls.push(`setWslDistro:${distro ?? "default"}`); | |
| 21 | persistedDistro = distro; | |
| 22 | return { ...baseWslState, distro, wslOnly: persistedWslOnly }; | |
| 23 | }); | |
| 24 | const setWslBackendEnabled = vi.fn(async (enabled: boolean) => { | |
| 25 | calls.push(`setWslBackendEnabled:${enabled}`); | |
| 26 | return { | |
| 27 | ...baseWslState, | |
| 28 | enabled, | |
| 29 | distro: persistedDistro, | |
| 30 | wslOnly: persistedWslOnly, | |
| 31 | }; | |
| 32 | }); | |
| 33 | const setWslOnly = vi.fn(async (enabled: boolean) => { | |
| 34 | calls.push(`setWslOnly:${enabled}`); | |
| 35 | persistedWslOnly = enabled; | |
| 36 | return { ...baseWslState, distro: persistedDistro, wslOnly: enabled }; | |
| 37 | }); | |
| 38 | ||
| 39 | const state = await applyWslEnableSelection({ | |
| 40 | bridge: { setWslDistro, setWslBackendEnabled, setWslOnly }, | |
| 41 | mode: "both", | |
| 42 | nextDistro: "Debian", | |
| 43 | persistedDistro: "Ubuntu", | |
| 44 | }); | |
| 45 | ||
| 46 | expect(calls).toEqual(["setWslOnly:false", "setWslDistro:Debian", "setWslBackendEnabled:true"]); | |
| 47 | expect(state).toMatchObject({ enabled: true, distro: "Debian", wslOnly: false }); | |
| 48 | }); | |
| 49 | ||
| 50 | it("stages WSL-only before enabling without rewriting an unchanged distro", async () => { | |
| 51 | const calls: Array<string> = []; | |
| 52 | let persistedWslOnly = false; | |
| 53 | const setWslDistro = vi.fn(async () => baseWslState); | |
| 54 | const setWslOnly = vi.fn(async (enabled: boolean) => { | |
| 55 | calls.push(`setWslOnly:${enabled}`); | |
| 56 | persistedWslOnly = enabled; | |
| 57 | return { ...baseWslState, wslOnly: enabled }; | |
| 58 | }); | |
| 59 | const setWslBackendEnabled = vi.fn(async (enabled: boolean) => { | |
| 60 | calls.push(`setWslBackendEnabled:${enabled}`); | |
| 61 | return { ...baseWslState, enabled, wslOnly: persistedWslOnly }; | |
| 62 | }); | |
| 63 | ||
| 64 | const state = await applyWslEnableSelection({ | |
| 65 | bridge: { setWslDistro, setWslBackendEnabled, setWslOnly }, | |
| 66 | mode: "wsl-only", | |
| 67 | nextDistro: null, | |
| 68 | persistedDistro: null, | |
| 69 | }); | |
| 70 | ||
| 71 | expect(calls).toEqual(["setWslOnly:true", "setWslBackendEnabled:true"]); | |
| 72 | expect(setWslDistro).not.toHaveBeenCalled(); | |
| 73 | expect(state).toMatchObject({ enabled: true, wslOnly: true }); | |
| 74 | }); | |
| 75 | }); |
apps/web/src/components/settings/ConnectionsSettings.logic.ts created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | import type { DesktopBridge, DesktopWslState } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | type WslEnableBridge = Pick<DesktopBridge, "setWslBackendEnabled" | "setWslDistro" | "setWslOnly">; | |
| 4 | ||
| 5 | export async function applyWslEnableSelection(input: { | |
| 6 | readonly bridge: WslEnableBridge; | |
| 7 | readonly mode: "both" | "wsl-only"; | |
| 8 | readonly nextDistro: string | null; | |
| 9 | readonly persistedDistro: string | null; | |
| 10 | }): Promise<DesktopWslState> { | |
| 11 | const { bridge, mode, nextDistro, persistedDistro } = input; | |
| 12 | ||
| 13 | // Stage every preference before enabling. The desktop only relaunches for | |
| 14 | // mode/distro changes while WSL is active, so the final enable observes the | |
| 15 | // complete selection and is the only call that may relaunch. | |
| 16 | await bridge.setWslOnly(mode === "wsl-only"); | |
| 17 | if (persistedDistro !== nextDistro) { | |
| 18 | await bridge.setWslDistro(nextDistro); | |
| 19 | } | |
| 20 | return await bridge.setWslBackendEnabled(true); | |
| 21 | } |
apps/web/src/components/settings/ConnectionsSettings.tsx+494-14| ... | ... | @@ -27,6 +27,7 @@ import { |
| 27 | 27 | type DesktopDiscoveredSshHost, |
| 28 | 28 | type DesktopSshEnvironmentTarget, |
| 29 | 29 | type DesktopServerExposureState, |
| 30 | type DesktopWslState, | |
| 30 | 31 | type EnvironmentId, |
| 31 | 32 | } from "@t3tools/contracts"; |
| 32 | 33 | import { |
| ... | ... | @@ -48,6 +49,7 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 48 | 49 | import { cn } from "../../lib/utils"; |
| 49 | 50 | import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; |
| 50 | 51 | import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; |
| 52 | import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; | |
| 51 | 53 | import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig"; |
| 52 | 54 | import { |
| 53 | 55 | SettingsPageContainer, |
| ... | ... | @@ -82,6 +84,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 82 | 84 | import { QRCodeSvg } from "../ui/qr-code"; |
| 83 | 85 | import { Skeleton } from "../ui/skeleton"; |
| 84 | 86 | import { Spinner } from "../ui/spinner"; |
| 87 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | |
| 85 | 88 | import { Switch } from "../ui/switch"; |
| 86 | 89 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 87 | 90 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | ... | @@ -111,6 +114,7 @@ import { |
| 111 | 114 | type ServerClientSessionRecord, |
| 112 | 115 | type ServerPairingLinkRecord, |
| 113 | 116 | } from "~/environments/primary"; |
| 117 | import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; | |
| 114 | 118 | import { useUiStateStore } from "~/uiStateStore"; |
| 115 | 119 | import { resolveServerConfigVersionMismatch } from "~/versionSkew"; |
| 116 | 120 | import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; |
| ... | ... | @@ -132,6 +136,7 @@ import { |
| 132 | 136 | refreshDesktopNetworkAccessState, |
| 133 | 137 | } from "~/state/desktopNetworkAccess"; |
| 134 | 138 | import { desktopSshHostsStateAtom } from "~/state/desktopSshHosts"; |
| 139 | import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState"; | |
| 135 | 140 | import { |
| 136 | 141 | type EnvironmentPresentation, |
| 137 | 142 | useEnvironments, |
| ... | ... | @@ -145,6 +150,12 @@ const DEFAULT_TAILSCALE_SERVE_PORT = 443; |
| 145 | 150 | const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = []; |
| 146 | 151 | const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray<DesktopDiscoveredSshHost> = []; |
| 147 | 152 | |
| 153 | // Sentinels for the consolidated WSL backend picker. The colon is | |
| 154 | // rejected by DISTRO_NAME_PATTERN (validated on the desktop side) so | |
| 155 | // neither can collide with a real distro name. | |
| 156 | const BACKEND_VALUE_DEFAULT_WSL = "backend:default-wsl"; | |
| 157 | const BACKEND_VALUE_WSL_OFF = "backend:wsl-off"; | |
| 158 | ||
| 148 | 159 | const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { |
| 149 | 160 | dateStyle: "medium", |
| 150 | 161 | timeStyle: "short", |
| ... | ... | @@ -1461,6 +1472,12 @@ function SavedBackendListRow({ |
| 1461 | 1472 | environment.relayManaged ? "T3 Connect" : null, |
| 1462 | 1473 | ].filter((value): value is string => value !== null); |
| 1463 | 1474 | |
| 1475 | // The WSL backend is a desktop-managed local backend (it surfaces as a bearer | |
| 1476 | // environment whose connection id is prefixed "local:"), not a remote | |
| 1477 | // environment you connect to or remove here — its lifecycle is driven by the | |
| 1478 | // WSL on/off + distro picker on this page. | |
| 1479 | const isWslEnvironment = isDesktopLocalConnectionTarget(environment.entry.target); | |
| 1480 | ||
| 1464 | 1481 | return ( |
| 1465 | 1482 | <div className={ITEM_ROW_CLASSNAME}> |
| 1466 | 1483 | <div className={ITEM_ROW_INNER_CLASSNAME}> |
| ... | ... | @@ -1503,20 +1520,37 @@ function SavedBackendListRow({ |
| 1503 | 1520 | ) : null} |
| 1504 | 1521 | </div> |
| 1505 | 1522 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
| 1506 | <Button | |
| 1507 | size="xs" | |
| 1508 | variant="outline" | |
| 1509 | disabled={isConnecting || removingEnvironmentId === environmentId} | |
| 1510 | onClick={() => void (isConnected ? onRemove(environmentId) : onConnect(environmentId))} | |
| 1511 | > | |
| 1512 | {isConnected | |
| 1513 | ? removingEnvironmentId === environmentId | |
| 1514 | ? "Disconnecting…" | |
| 1515 | : "Disconnect" | |
| 1516 | : isConnecting | |
| 1517 | ? "Connecting…" | |
| 1518 | : "Connect"} | |
| 1519 | </Button> | |
| 1523 | {isWslEnvironment ? ( | |
| 1524 | <Tooltip> | |
| 1525 | <TooltipTrigger | |
| 1526 | render={ | |
| 1527 | <Button size="xs" variant="outline" disabled> | |
| 1528 | Managed above | |
| 1529 | </Button> | |
| 1530 | } | |
| 1531 | /> | |
| 1532 | <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight"> | |
| 1533 | The WSL backend is managed by the WSL setting above — turn it on or off there. | |
| 1534 | </TooltipPopup> | |
| 1535 | </Tooltip> | |
| 1536 | ) : ( | |
| 1537 | <Button | |
| 1538 | size="xs" | |
| 1539 | variant="outline" | |
| 1540 | disabled={isConnecting || removingEnvironmentId === environmentId} | |
| 1541 | onClick={() => | |
| 1542 | void (isConnected ? onRemove(environmentId) : onConnect(environmentId)) | |
| 1543 | } | |
| 1544 | > | |
| 1545 | {isConnected | |
| 1546 | ? removingEnvironmentId === environmentId | |
| 1547 | ? "Disconnecting…" | |
| 1548 | : "Disconnect" | |
| 1549 | : isConnecting | |
| 1550 | ? "Connecting…" | |
| 1551 | : "Connect"} | |
| 1552 | </Button> | |
| 1553 | )} | |
| 1520 | 1554 | </div> |
| 1521 | 1555 | </div> |
| 1522 | 1556 | </div> |
| ... | ... | @@ -2066,6 +2100,30 @@ export function ConnectionsSettings() { |
| 2066 | 2100 | const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); |
| 2067 | 2101 | const [isDesktopServerExposureDialogOpen, setIsDesktopServerExposureDialogOpen] = useState(false); |
| 2068 | 2102 | const [isUpdatingTailscaleServe, setIsUpdatingTailscaleServe] = useState(false); |
| 2103 | const [isUpdatingWslBackend, setIsUpdatingWslBackend] = useState(false); | |
| 2104 | const [desktopWslMutationError, setDesktopWslMutationError] = useState<string | null>(null); | |
| 2105 | // Pending WSL setting change waiting on user confirmation. Set when | |
| 2106 | // the user tries a destructive change (disable, switch distro, | |
| 2107 | // toggle wsl-only) while the WSL backend has saved-env state on this | |
| 2108 | // machine. Confirming applies the change; cancelling drops it | |
| 2109 | // without touching the persisted setting. Null when nothing is | |
| 2110 | // pending. | |
| 2111 | type PendingWslChange = | |
| 2112 | // wasWslOnly is true when the user picked Off while wsl-only mode | |
| 2113 | // was active. In that case "disable" also clears wsl-only and | |
| 2114 | // relaunches onto the Windows backend, because leaving wsl-only on | |
| 2115 | // with wslBackendEnabled off is a meaningless state (wsl-only is | |
| 2116 | // only honoured when the WSL backend is enabled). | |
| 2117 | | { readonly kind: "disable"; readonly wasWslOnly: boolean } | |
| 2118 | | { readonly kind: "distro"; readonly nextDistro: string | null } | |
| 2119 | // Asked at enable time so the user picks the mode upfront instead | |
| 2120 | // of being dropped into "both backends" and having to discover the | |
| 2121 | // wsl-only switch separately. Resolved through enable-mode action | |
| 2122 | // buttons on the dialog rather than a single Confirm. | |
| 2123 | | { readonly kind: "enable"; readonly nextDistro: string | null } | |
| 2124 | | { readonly kind: "wsl-only"; readonly nextValue: boolean }; | |
| 2125 | const [pendingWslChange, setPendingWslChange] = useState<PendingWslChange | null>(null); | |
| 2126 | const isWslConfirmDialogOpen = pendingWslChange !== null; | |
| 2069 | 2127 | const [pendingTailscaleServeEndpoint, setPendingTailscaleServeEndpoint] = |
| 2070 | 2128 | useState<AdvertisedEndpoint | null>(null); |
| 2071 | 2129 | const [disableTailscaleServeDialogOpen, setDisableTailscaleServeDialogOpen] = useState(false); |
| ... | ... | @@ -2102,6 +2160,12 @@ export function ConnectionsSettings() { |
| 2102 | 2160 | ? desktopSshHostsStateAtom |
| 2103 | 2161 | : null, |
| 2104 | 2162 | ); |
| 2163 | const desktopWsl = useEnvironmentQuery( | |
| 2164 | canManageLocalBackend && desktopBridge ? desktopWslStateAtom : null, | |
| 2165 | ); | |
| 2166 | const desktopWslState = desktopWsl.data; | |
| 2167 | const desktopWslError = desktopWslMutationError ?? desktopWsl.error; | |
| 2168 | const isLoadingWslState = desktopWsl.isPending && desktopWsl.data === null; | |
| 2105 | 2169 | const discoveredSshHosts = desktopSshHosts.data ?? EMPTY_DISCOVERED_SSH_HOSTS; |
| 2106 | 2170 | const unsavedDiscoveredSshHosts = useMemo( |
| 2107 | 2171 | () => |
| ... | ... | @@ -2793,6 +2857,313 @@ export function ConnectionsSettings() { |
| 2793 | 2857 | ); |
| 2794 | 2858 | }) |
| 2795 | 2859 | : null; |
| 2860 | // Apply a setting change immediately. The orchestrator reconciles the | |
| 2861 | // pool in the background and the primary backend is untouched, so we | |
| 2862 | // don't gate this behind a confirmation dialog. After the desktop | |
| 2863 | // side persists the change and nudges its orchestrator, we trigger | |
| 2864 | // the renderer's reconciler so the WSL backend's saved-env-shaped | |
| 2865 | // entry catches up (registers/unregisters) without a reload. | |
| 2866 | const applyWslSettingChange = useCallback( | |
| 2867 | async (apply: () => Promise<DesktopWslState>) => { | |
| 2868 | if (!desktopBridge) return; | |
| 2869 | setIsUpdatingWslBackend(true); | |
| 2870 | setDesktopWslMutationError(null); | |
| 2871 | try { | |
| 2872 | await apply(); | |
| 2873 | refreshDesktopWslState(); | |
| 2874 | // The connection platform source polls the desktop bootstrap list and | |
| 2875 | // reconciles the environment catalog automatically, so toggling the WSL | |
| 2876 | // backend on/off or switching distros is picked up here without an | |
| 2877 | // explicit renderer reconcile. | |
| 2878 | } catch (error) { | |
| 2879 | const message = error instanceof Error ? error.message : "Failed to update WSL backend."; | |
| 2880 | setDesktopWslMutationError(message); | |
| 2881 | toastManager.add( | |
| 2882 | stackedThreadToast({ | |
| 2883 | type: "error", | |
| 2884 | title: "Could not change WSL backend", | |
| 2885 | description: message, | |
| 2886 | }), | |
| 2887 | ); | |
| 2888 | refreshDesktopWslState(); | |
| 2889 | } finally { | |
| 2890 | setIsUpdatingWslBackend(false); | |
| 2891 | } | |
| 2892 | }, | |
| 2893 | [desktopBridge], | |
| 2894 | ); | |
| 2895 | ||
| 2896 | // Reload the keep-alive WSL state atom. Clearing the mutation error before | |
| 2897 | // refresh lets the atom-owned load error become the visible retry state. | |
| 2898 | const loadWslState = useCallback(() => { | |
| 2899 | setDesktopWslMutationError(null); | |
| 2900 | refreshDesktopWslState(); | |
| 2901 | }, []); | |
| 2902 | ||
| 2903 | // True when a desktop-local WSL backend is currently registered as an | |
| 2904 | // environment on this machine. We use this as a proxy for "the user has work | |
| 2905 | // that lives on the WSL side": if WSL has connected in a way that registered | |
| 2906 | // the env, disabling or switching distros could disrupt open threads/projects. | |
| 2907 | // If WSL never connected (fresh install, toggled on then immediately off, | |
| 2908 | // etc.) there's no local environment, so we skip the confirmation dialog. | |
| 2909 | const hasWslRegistrationToLose = useMemo(() => { | |
| 2910 | return environments.some((environment) => | |
| 2911 | isDesktopLocalConnectionTarget(environment.entry.target), | |
| 2912 | ); | |
| 2913 | }, [environments]); | |
| 2914 | ||
| 2915 | // Single picker for "WSL backend off" vs "running on distro X". The | |
| 2916 | // dropdown maps "Off" to disable and any distro entry to enable + | |
| 2917 | // run on that distro. Splitting these into a separate switch and | |
| 2918 | // dropdown was confusing — they're the same decision. | |
| 2919 | const handleSelectWslMode = useCallback( | |
| 2920 | (value: string) => { | |
| 2921 | if (!desktopBridge || !desktopWslState) return; | |
| 2922 | const defaultDistroName = | |
| 2923 | desktopWslState.distros.find((distro) => distro.isDefault)?.name ?? null; | |
| 2924 | if (value === BACKEND_VALUE_WSL_OFF) { | |
| 2925 | // Match the recovery row's visibility (`enabled || wslOnly`): when WSL | |
| 2926 | // went unavailable while wsl-only was persisted, `enabled` can be false | |
| 2927 | // while `wslOnly` is true, and the "Switch to Windows" button must | |
| 2928 | // still clear that state instead of silently no-op'ing. | |
| 2929 | if (!desktopWslState.enabled && !desktopWslState.wslOnly) return; | |
| 2930 | const wasWslOnly = desktopWslState.wslOnly; | |
| 2931 | // Confirm when there's WSL state to lose, OR when wsl-only is | |
| 2932 | // on (turning the only running backend off needs to switch | |
| 2933 | // back to Windows and restart — always consequential). | |
| 2934 | if (hasWslRegistrationToLose || wasWslOnly) { | |
| 2935 | setPendingWslChange({ kind: "disable", wasWslOnly }); | |
| 2936 | return; | |
| 2937 | } | |
| 2938 | void applyWslSettingChange(() => desktopBridge.setWslBackendEnabled(false)); | |
| 2939 | return; | |
| 2940 | } | |
| 2941 | const nextDistro = value === BACKEND_VALUE_DEFAULT_WSL ? null : value; | |
| 2942 | const resolvedNext = nextDistro ?? defaultDistroName; | |
| 2943 | if (!desktopWslState.enabled) { | |
| 2944 | // Was off, user picked a distro: ask whether to run both | |
| 2945 | // backends or only WSL. We always ask here so the user picks | |
| 2946 | // the mode upfront instead of having to discover the wsl-only | |
| 2947 | // switch afterwards. | |
| 2948 | setPendingWslChange({ kind: "enable", nextDistro }); | |
| 2949 | return; | |
| 2950 | } | |
| 2951 | // Already enabled — treat as a distro switch. Skip the change if | |
| 2952 | // the user re-picked the row that's already selected. | |
| 2953 | const resolvedCurrent = desktopWslState.distro ?? defaultDistroName; | |
| 2954 | if (resolvedCurrent === resolvedNext) return; | |
| 2955 | // Confirm when there's WSL registration to lose, OR in wsl-only mode: | |
| 2956 | // there the primary IS the WSL backend, so a distro change relaunches | |
| 2957 | // the app (the IPC handler does this) rather than swapping a secondary, | |
| 2958 | // and the user should see that coming. | |
| 2959 | if (hasWslRegistrationToLose || desktopWslState.wslOnly) { | |
| 2960 | setPendingWslChange({ kind: "distro", nextDistro }); | |
| 2961 | return; | |
| 2962 | } | |
| 2963 | void applyWslSettingChange(() => desktopBridge.setWslDistro(nextDistro)); | |
| 2964 | }, | |
| 2965 | [applyWslSettingChange, desktopBridge, desktopWslState, hasWslRegistrationToLose], | |
| 2966 | ); | |
| 2967 | ||
| 2968 | // Dispatched from the enable modal's two action buttons. | |
| 2969 | const handleConfirmEnableWsl = useCallback( | |
| 2970 | (mode: "both" | "wsl-only") => { | |
| 2971 | if (!desktopBridge || !pendingWslChange || pendingWslChange.kind !== "enable") return; | |
| 2972 | const nextDistro = pendingWslChange.nextDistro; | |
| 2973 | setPendingWslChange(null); | |
| 2974 | const persistedDistro = desktopWslState?.distro ?? null; | |
| 2975 | void applyWslSettingChange(() => | |
| 2976 | applyWslEnableSelection({ | |
| 2977 | bridge: desktopBridge, | |
| 2978 | mode, | |
| 2979 | nextDistro, | |
| 2980 | persistedDistro, | |
| 2981 | }), | |
| 2982 | ); | |
| 2983 | }, | |
| 2984 | [applyWslSettingChange, desktopBridge, desktopWslState, pendingWslChange], | |
| 2985 | ); | |
| 2986 | ||
| 2987 | const handleToggleWslOnly = useCallback( | |
| 2988 | (enabled: boolean) => { | |
| 2989 | if (!desktopBridge || !desktopWslState || desktopWslState.wslOnly === enabled) return; | |
| 2990 | // wsl-only changes which backend the pool uses as "primary", | |
| 2991 | // which is decided once at app launch. The desktop side persists | |
| 2992 | // the setting immediately but doesn't tear down or restart | |
| 2993 | // anything itself; the renderer warns the user to expect a | |
| 2994 | // restart and (in a follow-up) can trigger it automatically. | |
| 2995 | // Always prompt — even enabling is consequential here. | |
| 2996 | setPendingWslChange({ kind: "wsl-only", nextValue: enabled }); | |
| 2997 | }, | |
| 2998 | [desktopBridge, desktopWslState], | |
| 2999 | ); | |
| 3000 | ||
| 3001 | const handleConfirmWslChange = useCallback(() => { | |
| 3002 | if (!desktopBridge || !pendingWslChange) return; | |
| 3003 | const change = pendingWslChange; | |
| 3004 | // The enable kind resolves through handleConfirmEnableWsl, not | |
| 3005 | // this single Confirm path. | |
| 3006 | if (change.kind === "enable") return; | |
| 3007 | setPendingWslChange(null); | |
| 3008 | if (change.kind === "disable") { | |
| 3009 | void applyWslSettingChange(async () => { | |
| 3010 | const next = await desktopBridge.setWslBackendEnabled(false); | |
| 3011 | if (change.wasWslOnly) { | |
| 3012 | // Clearing wsl-only relaunches onto the Windows backend. | |
| 3013 | return await desktopBridge.setWslOnly(false); | |
| 3014 | } | |
| 3015 | return next; | |
| 3016 | }); | |
| 3017 | return; | |
| 3018 | } | |
| 3019 | if (change.kind === "distro") { | |
| 3020 | void applyWslSettingChange(() => desktopBridge.setWslDistro(change.nextDistro)); | |
| 3021 | return; | |
| 3022 | } | |
| 3023 | void applyWslSettingChange(() => desktopBridge.setWslOnly(change.nextValue)); | |
| 3024 | }, [applyWslSettingChange, desktopBridge, pendingWslChange]); | |
| 3025 | ||
| 3026 | const renderWslRow = () => { | |
| 3027 | if (!desktopWslState) { | |
| 3028 | // A load failed: keep a recovery row (with retry) visible instead of | |
| 3029 | // silently hiding the section. The error persists across an in-flight | |
| 3030 | // retry so the row doesn't flicker away, and the button reflects the | |
| 3031 | // loading state. With no error we simply haven't loaded yet (or WSL | |
| 3032 | // management isn't available), so render nothing. | |
| 3033 | if (desktopWslError && canManageLocalBackend) { | |
| 3034 | return ( | |
| 3035 | <SettingsRow | |
| 3036 | title="WSL backend" | |
| 3037 | description="Couldn't load the WSL backend state." | |
| 3038 | status={<span className="block text-destructive">{desktopWslError}</span>} | |
| 3039 | control={ | |
| 3040 | <Button | |
| 3041 | size="xs" | |
| 3042 | variant="outline" | |
| 3043 | onClick={loadWslState} | |
| 3044 | disabled={isLoadingWslState} | |
| 3045 | > | |
| 3046 | {isLoadingWslState ? "Retrying…" : "Retry"} | |
| 3047 | </Button> | |
| 3048 | } | |
| 3049 | /> | |
| 3050 | ); | |
| 3051 | } | |
| 3052 | return null; | |
| 3053 | } | |
| 3054 | // WSL went unavailable while the user still has the WSL backend persisted | |
| 3055 | // (it may have been uninstalled or its distro removed). The desktop side | |
| 3056 | // falls back to the Windows backend, but the normal distro picker needs a | |
| 3057 | // live distro list it no longer has. Without a control here the user would | |
| 3058 | // be stranded on a WSL preference they can't clear, so render a recovery | |
| 3059 | // row that switches back to Windows. When WSL is unavailable AND unused, | |
| 3060 | // there's nothing to recover — keep the section hidden as before. | |
| 3061 | if (!desktopWslState.available) { | |
| 3062 | if (!desktopWslState.enabled && !desktopWslState.wslOnly) return null; | |
| 3063 | return ( | |
| 3064 | <SettingsRow | |
| 3065 | title="WSL backend" | |
| 3066 | description="WSL is no longer available, so the Windows backend is running instead. Switch off the WSL backend to clear this preference." | |
| 3067 | status={ | |
| 3068 | desktopWslError ? ( | |
| 3069 | <span className="block text-destructive">{desktopWslError}</span> | |
| 3070 | ) : null | |
| 3071 | } | |
| 3072 | control={ | |
| 3073 | <Button | |
| 3074 | variant="outline" | |
| 3075 | disabled={isUpdatingWslBackend} | |
| 3076 | onClick={() => handleSelectWslMode(BACKEND_VALUE_WSL_OFF)} | |
| 3077 | > | |
| 3078 | Switch to Windows | |
| 3079 | </Button> | |
| 3080 | } | |
| 3081 | /> | |
| 3082 | ); | |
| 3083 | } | |
| 3084 | // Distro is null when the user wants the WSL default. Map it to the | |
| 3085 | // real default's name so the Select highlights a real option; fall | |
| 3086 | // back to the sentinel only when no distros are listed yet (the | |
| 3087 | // dropdown then renders a single placeholder that matches). | |
| 3088 | const defaultDistroName = | |
| 3089 | desktopWslState.distros.find((distro) => distro.isDefault)?.name ?? null; | |
| 3090 | const selectValue = !desktopWslState.enabled | |
| 3091 | ? BACKEND_VALUE_WSL_OFF | |
| 3092 | : (desktopWslState.distro ?? defaultDistroName ?? BACKEND_VALUE_DEFAULT_WSL); | |
| 3093 | const selectLabel = | |
| 3094 | selectValue === BACKEND_VALUE_WSL_OFF | |
| 3095 | ? "Off" | |
| 3096 | : selectValue === BACKEND_VALUE_DEFAULT_WSL | |
| 3097 | ? "Default distro" | |
| 3098 | : selectValue; | |
| 3099 | return ( | |
| 3100 | <> | |
| 3101 | <SettingsRow | |
| 3102 | title="WSL backend" | |
| 3103 | description="Run a second backend inside a WSL distro alongside the Windows one. Pick a distro to start it; pick Off to stop it. Projects opened against the WSL backend live on the Linux side; Windows projects stay where they are." | |
| 3104 | status={ | |
| 3105 | desktopWslError ? ( | |
| 3106 | <span className="block text-destructive">{desktopWslError}</span> | |
| 3107 | ) : desktopWslState.preflightError ? ( | |
| 3108 | <span className="block text-destructive"> | |
| 3109 | WSL backend couldn't start: {desktopWslState.preflightError} | |
| 3110 | </span> | |
| 3111 | ) : null | |
| 3112 | } | |
| 3113 | control={ | |
| 3114 | <Select | |
| 3115 | value={selectValue} | |
| 3116 | onValueChange={(value) => { | |
| 3117 | if (typeof value !== "string") return; | |
| 3118 | handleSelectWslMode(value); | |
| 3119 | }} | |
| 3120 | > | |
| 3121 | <SelectTrigger | |
| 3122 | className="w-full sm:w-56" | |
| 3123 | aria-label="WSL backend" | |
| 3124 | disabled={isUpdatingWslBackend} | |
| 3125 | > | |
| 3126 | <SelectValue>{selectLabel}</SelectValue> | |
| 3127 | </SelectTrigger> | |
| 3128 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 3129 | <SelectItem hideIndicator value={BACKEND_VALUE_WSL_OFF}> | |
| 3130 | Off | |
| 3131 | </SelectItem> | |
| 3132 | {desktopWslState.distros.length === 0 ? ( | |
| 3133 | <SelectItem hideIndicator value={BACKEND_VALUE_DEFAULT_WSL}> | |
| 3134 | Default distro | |
| 3135 | </SelectItem> | |
| 3136 | ) : ( | |
| 3137 | desktopWslState.distros.map((distro) => ( | |
| 3138 | <SelectItem hideIndicator key={distro.name} value={distro.name}> | |
| 3139 | {distro.name} | |
| 3140 | {distro.isDefault ? " (default)" : ""} | |
| 3141 | </SelectItem> | |
| 3142 | )) | |
| 3143 | )} | |
| 3144 | </SelectPopup> | |
| 3145 | </Select> | |
| 3146 | } | |
| 3147 | /> | |
| 3148 | {desktopWslState.enabled ? ( | |
| 3149 | <SettingsRow | |
| 3150 | title="WSL only" | |
| 3151 | description="Stop the Windows backend and run only the WSL backend. Useful if you develop entirely inside WSL and don't want a second backend process. T3 Code restarts when you change this." | |
| 3152 | className="bg-muted/20 pl-7 sm:pl-8" | |
| 3153 | control={ | |
| 3154 | <Switch | |
| 3155 | checked={desktopWslState.wslOnly} | |
| 3156 | disabled={isUpdatingWslBackend} | |
| 3157 | onCheckedChange={(checked) => handleToggleWslOnly(checked)} | |
| 3158 | aria-label="Run WSL only" | |
| 3159 | /> | |
| 3160 | } | |
| 3161 | /> | |
| 3162 | ) : null} | |
| 3163 | </> | |
| 3164 | ); | |
| 3165 | }; | |
| 3166 | ||
| 2796 | 3167 | const renderTailscaleRow = () => ( |
| 2797 | 3168 | <SettingsRow |
| 2798 | 3169 | title="Tailscale HTTPS" |
| ... | ... | @@ -2928,6 +3299,7 @@ export function ConnectionsSettings() { |
| 2928 | 3299 | {renderNetworkAccessRow()} |
| 2929 | 3300 | {renderEndpointRows("endpoint-rail")} |
| 2930 | 3301 | {renderTailscaleRow()} |
| 3302 | {renderWslRow()} | |
| 2931 | 3303 | <CloudLinkRow canManageRelay={canManageRelay} /> |
| 2932 | 3304 | </> |
| 2933 | 3305 | ) : ( |
| ... | ... | @@ -3011,6 +3383,114 @@ export function ConnectionsSettings() { |
| 3011 | 3383 | </AlertDialogFooter> |
| 3012 | 3384 | </AlertDialogPopup> |
| 3013 | 3385 | </AlertDialog> |
| 3386 | <AlertDialog | |
| 3387 | open={isWslConfirmDialogOpen} | |
| 3388 | onOpenChange={(open) => { | |
| 3389 | if (isUpdatingWslBackend) return; | |
| 3390 | if (!open) setPendingWslChange(null); | |
| 3391 | }} | |
| 3392 | > | |
| 3393 | <AlertDialogPopup> | |
| 3394 | <AlertDialogHeader> | |
| 3395 | <AlertDialogTitle> | |
| 3396 | {pendingWslChange?.kind === "disable" | |
| 3397 | ? pendingWslChange.wasWslOnly | |
| 3398 | ? "Turn off WSL and switch back to Windows?" | |
| 3399 | : "Disable WSL backend?" | |
| 3400 | : pendingWslChange?.kind === "distro" | |
| 3401 | ? "Switch WSL distro?" | |
| 3402 | : pendingWslChange?.kind === "enable" | |
| 3403 | ? "Start the WSL backend" | |
| 3404 | : pendingWslChange?.nextValue | |
| 3405 | ? "Run only the WSL backend?" | |
| 3406 | : "Re-enable the Windows backend?"} | |
| 3407 | </AlertDialogTitle> | |
| 3408 | <AlertDialogDescription> | |
| 3409 | {pendingWslChange?.kind === "disable" | |
| 3410 | ? pendingWslChange.wasWslOnly | |
| 3411 | ? "T3 Code will restart on the Windows backend. Threads and projects opened against WSL stay safe inside the distro and become available again when you re-enable WSL." | |
| 3412 | : "The WSL backend will stop. Threads and projects opened against WSL stay safe inside the distro, but they'll be unavailable in T3 Code until you re-enable WSL." | |
| 3413 | : pendingWslChange?.kind === "distro" | |
| 3414 | ? "T3 Code will restart the WSL backend on the new distro. Sessions still running on the current distro will be interrupted." | |
| 3415 | : pendingWslChange?.kind === "enable" | |
| 3416 | ? "Run the WSL backend alongside the Windows one, or stop the Windows backend and use only WSL? You can change this later from Settings." | |
| 3417 | : pendingWslChange?.nextValue | |
| 3418 | ? "T3 Code will restart and start only the WSL backend. Your Windows-side projects won't be accessible until you turn this off again." | |
| 3419 | : "T3 Code will restart and bring the Windows backend back up alongside WSL."} | |
| 3420 | </AlertDialogDescription> | |
| 3421 | </AlertDialogHeader> | |
| 3422 | <AlertDialogFooter> | |
| 3423 | <AlertDialogClose | |
| 3424 | disabled={isUpdatingWslBackend} | |
| 3425 | render={<Button variant="outline" disabled={isUpdatingWslBackend} />} | |
| 3426 | > | |
| 3427 | Cancel | |
| 3428 | </AlertDialogClose> | |
| 3429 | {pendingWslChange?.kind === "enable" ? ( | |
| 3430 | <> | |
| 3431 | <Button | |
| 3432 | variant="outline" | |
| 3433 | onClick={() => handleConfirmEnableWsl("wsl-only")} | |
| 3434 | disabled={isUpdatingWslBackend} | |
| 3435 | > | |
| 3436 | {isUpdatingWslBackend ? ( | |
| 3437 | <> | |
| 3438 | <Spinner className="size-3.5" /> | |
| 3439 | Applying… | |
| 3440 | </> | |
| 3441 | ) : ( | |
| 3442 | "Use only WSL" | |
| 3443 | )} | |
| 3444 | </Button> | |
| 3445 | <Button | |
| 3446 | variant="default" | |
| 3447 | onClick={() => handleConfirmEnableWsl("both")} | |
| 3448 | disabled={isUpdatingWslBackend} | |
| 3449 | > | |
| 3450 | {isUpdatingWslBackend ? ( | |
| 3451 | <> | |
| 3452 | <Spinner className="size-3.5" /> | |
| 3453 | Applying… | |
| 3454 | </> | |
| 3455 | ) : ( | |
| 3456 | "Run both backends" | |
| 3457 | )} | |
| 3458 | </Button> | |
| 3459 | </> | |
| 3460 | ) : ( | |
| 3461 | <Button | |
| 3462 | variant={ | |
| 3463 | pendingWslChange?.kind === "disable" || | |
| 3464 | (pendingWslChange?.kind === "wsl-only" && pendingWslChange.nextValue) | |
| 3465 | ? "destructive" | |
| 3466 | : "default" | |
| 3467 | } | |
| 3468 | onClick={handleConfirmWslChange} | |
| 3469 | disabled={isUpdatingWslBackend} | |
| 3470 | > | |
| 3471 | {isUpdatingWslBackend ? ( | |
| 3472 | <> | |
| 3473 | <Spinner className="size-3.5" /> | |
| 3474 | Applying… | |
| 3475 | </> | |
| 3476 | ) : pendingWslChange?.kind === "disable" ? ( | |
| 3477 | pendingWslChange.wasWslOnly ? ( | |
| 3478 | "Switch to Windows" | |
| 3479 | ) : ( | |
| 3480 | "Disable WSL" | |
| 3481 | ) | |
| 3482 | ) : pendingWslChange?.kind === "distro" ? ( | |
| 3483 | "Switch distro" | |
| 3484 | ) : pendingWslChange?.nextValue ? ( | |
| 3485 | "Restart and enable" | |
| 3486 | ) : ( | |
| 3487 | "Restart and disable" | |
| 3488 | )} | |
| 3489 | </Button> | |
| 3490 | )} | |
| 3491 | </AlertDialogFooter> | |
| 3492 | </AlertDialogPopup> | |
| 3493 | </AlertDialog> | |
| 3014 | 3494 | <AlertDialog |
| 3015 | 3495 | open={disableTailscaleServeDialogOpen} |
| 3016 | 3496 | onOpenChange={(open) => { |
apps/web/src/connection/desktopLocal.test.ts created+107| ... | ... | @@ -0,0 +1,107 @@ |
| 1 | import { | |
| 2 | BearerConnectionTarget, | |
| 3 | PrimaryConnectionTarget, | |
| 4 | } from "@t3tools/client-runtime/connection"; | |
| 5 | import { EnvironmentId, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | |
| 6 | import { describe, expect, it } from "vite-plus/test"; | |
| 7 | ||
| 8 | import { | |
| 9 | createDesktopSecondaryBootstrapsReader, | |
| 10 | desktopLocalBackendId, | |
| 11 | desktopLocalConnectionId, | |
| 12 | isDesktopLocalConnectionTarget, | |
| 13 | } from "./desktopLocal"; | |
| 14 | ||
| 15 | describe("desktop local connection identity", () => { | |
| 16 | it("preserves the desktop backend instance id", () => { | |
| 17 | const target = new BearerConnectionTarget({ | |
| 18 | connectionId: desktopLocalConnectionId("wsl:Ubuntu"), | |
| 19 | environmentId: EnvironmentId.make("environment-wsl"), | |
| 20 | label: "WSL (Ubuntu)", | |
| 21 | }); | |
| 22 | ||
| 23 | expect(isDesktopLocalConnectionTarget(target)).toBe(true); | |
| 24 | expect(desktopLocalBackendId(target)).toBe("wsl:Ubuntu"); | |
| 25 | }); | |
| 26 | ||
| 27 | it("does not classify the primary environment as desktop-local", () => { | |
| 28 | const target = new PrimaryConnectionTarget({ | |
| 29 | environmentId: EnvironmentId.make("environment-primary"), | |
| 30 | httpBaseUrl: "http://127.0.0.1:3773", | |
| 31 | label: "This device", | |
| 32 | wsBaseUrl: "ws://127.0.0.1:3773", | |
| 33 | }); | |
| 34 | ||
| 35 | expect(isDesktopLocalConnectionTarget(target)).toBe(false); | |
| 36 | expect(desktopLocalBackendId(target)).toBeNull(); | |
| 37 | }); | |
| 38 | }); | |
| 39 | ||
| 40 | describe("desktop local topology reads", () => { | |
| 41 | it("distinguishes a successful empty topology from a read failure", () => { | |
| 42 | let readBootstraps = () => []; | |
| 43 | const reader = createDesktopSecondaryBootstrapsReader(() => ({ | |
| 44 | getLocalEnvironmentBootstraps: () => readBootstraps(), | |
| 45 | })); | |
| 46 | ||
| 47 | expect(reader.readResult()).toEqual({ _tag: "Success", bootstraps: [] }); | |
| 48 | ||
| 49 | const cause = new Error("IPC unavailable"); | |
| 50 | readBootstraps = () => { | |
| 51 | throw cause; | |
| 52 | }; | |
| 53 | expect(reader.readResult()).toEqual({ _tag: "Failure", cause }); | |
| 54 | }); | |
| 55 | ||
| 56 | it("filters the primary bootstrap from successful topology reads", () => { | |
| 57 | const secondary = { | |
| 58 | id: "wsl:Ubuntu", | |
| 59 | label: "WSL: Ubuntu", | |
| 60 | httpBaseUrl: "http://127.0.0.1:4000", | |
| 61 | wsBaseUrl: "ws://127.0.0.1:4000", | |
| 62 | }; | |
| 63 | ||
| 64 | const reader = createDesktopSecondaryBootstrapsReader(() => ({ | |
| 65 | getLocalEnvironmentBootstraps: () => [ | |
| 66 | { | |
| 67 | ...secondary, | |
| 68 | id: PRIMARY_LOCAL_ENVIRONMENT_ID, | |
| 69 | label: "Windows", | |
| 70 | }, | |
| 71 | secondary, | |
| 72 | ], | |
| 73 | })); | |
| 74 | ||
| 75 | expect(reader.readResult()).toEqual({ _tag: "Success", bootstraps: [secondary] }); | |
| 76 | }); | |
| 77 | ||
| 78 | it("retains the last successful snapshot only until another read succeeds", () => { | |
| 79 | const secondary = { | |
| 80 | id: "wsl:Ubuntu", | |
| 81 | label: "WSL: Ubuntu", | |
| 82 | httpBaseUrl: "http://127.0.0.1:4000", | |
| 83 | wsBaseUrl: "ws://127.0.0.1:4000", | |
| 84 | }; | |
| 85 | let readBootstraps = () => [secondary]; | |
| 86 | const reader = createDesktopSecondaryBootstrapsReader(() => ({ | |
| 87 | getLocalEnvironmentBootstraps: () => readBootstraps(), | |
| 88 | })); | |
| 89 | ||
| 90 | const connectedSnapshot = reader.readSnapshot(); | |
| 91 | expect(connectedSnapshot).toEqual([secondary]); | |
| 92 | ||
| 93 | readBootstraps = () => { | |
| 94 | throw new Error("IPC unavailable"); | |
| 95 | }; | |
| 96 | expect(reader.readSnapshot()).toBe(connectedSnapshot); | |
| 97 | ||
| 98 | readBootstraps = () => []; | |
| 99 | const removedSnapshot = reader.readSnapshot(); | |
| 100 | expect(removedSnapshot).toEqual([]); | |
| 101 | ||
| 102 | readBootstraps = () => { | |
| 103 | throw new Error("IPC unavailable again"); | |
| 104 | }; | |
| 105 | expect(reader.readSnapshot()).toBe(removedSnapshot); | |
| 106 | }); | |
| 107 | }); |
apps/web/src/connection/desktopLocal.ts created+104| ... | ... | @@ -0,0 +1,104 @@ |
| 1 | import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; | |
| 2 | import { | |
| 3 | PRIMARY_LOCAL_ENVIRONMENT_ID, | |
| 4 | type DesktopBridge, | |
| 5 | type DesktopEnvironmentBootstrap, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | /** | |
| 9 | * Desktop-local secondary backends (e.g. a parallel WSL backend) are registered | |
| 10 | * by the connection platform source as bearer connections whose id carries this | |
| 11 | * prefix. It is the renderer's single signal that an environment is a | |
| 12 | * host-managed local backend rather than a user-saved remote, SSH, or relay | |
| 13 | * environment. | |
| 14 | * | |
| 15 | * Keep this the one source of truth: the producer (`connection/platform.ts`) | |
| 16 | * mints ids via {@link desktopLocalConnectionId} and every consumer classifies | |
| 17 | * via {@link isDesktopLocalConnectionTarget}, so the convention can never drift | |
| 18 | * between the two. | |
| 19 | */ | |
| 20 | export const DESKTOP_LOCAL_CONNECTION_ID_PREFIX = "local:"; | |
| 21 | ||
| 22 | export function desktopLocalConnectionId(backendId: string): string { | |
| 23 | return `${DESKTOP_LOCAL_CONNECTION_ID_PREFIX}${backendId}`; | |
| 24 | } | |
| 25 | ||
| 26 | export function isDesktopLocalConnectionTarget( | |
| 27 | target: ConnectionTarget, | |
| 28 | ): target is Extract<ConnectionTarget, { readonly _tag: "BearerConnectionTarget" }> { | |
| 29 | return ( | |
| 30 | target._tag === "BearerConnectionTarget" && | |
| 31 | target.connectionId.startsWith(DESKTOP_LOCAL_CONNECTION_ID_PREFIX) | |
| 32 | ); | |
| 33 | } | |
| 34 | ||
| 35 | export function desktopLocalBackendId(target: ConnectionTarget): string | null { | |
| 36 | return isDesktopLocalConnectionTarget(target) | |
| 37 | ? target.connectionId.slice(DESKTOP_LOCAL_CONNECTION_ID_PREFIX.length) | |
| 38 | : null; | |
| 39 | } | |
| 40 | ||
| 41 | export type DesktopSecondaryBootstrapsRead = | |
| 42 | | { | |
| 43 | readonly _tag: "Success"; | |
| 44 | readonly bootstraps: ReadonlyArray<DesktopEnvironmentBootstrap>; | |
| 45 | } | |
| 46 | | { | |
| 47 | readonly _tag: "Failure"; | |
| 48 | readonly cause: unknown; | |
| 49 | }; | |
| 50 | ||
| 51 | export interface DesktopSecondaryBootstrapsReader { | |
| 52 | readonly readResult: () => DesktopSecondaryBootstrapsRead; | |
| 53 | readonly readSnapshot: () => ReadonlyArray<DesktopEnvironmentBootstrap>; | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * Build a topology reader whose snapshot advances only after successful bridge | |
| 58 | * reads. A successful empty read is authoritative; a thrown read preserves the | |
| 59 | * previous snapshot so UI consumers cannot temporarily disagree with the | |
| 60 | * platform's retained registrations. | |
| 61 | */ | |
| 62 | export function createDesktopSecondaryBootstrapsReader( | |
| 63 | resolveBridge: () => Pick<DesktopBridge, "getLocalEnvironmentBootstraps"> | undefined, | |
| 64 | ): DesktopSecondaryBootstrapsReader { | |
| 65 | let snapshot: ReadonlyArray<DesktopEnvironmentBootstrap> = []; | |
| 66 | ||
| 67 | const readResult = (): DesktopSecondaryBootstrapsRead => { | |
| 68 | const bridge = resolveBridge(); | |
| 69 | if (bridge === undefined) { | |
| 70 | snapshot = []; | |
| 71 | return { _tag: "Success", bootstraps: snapshot }; | |
| 72 | } | |
| 73 | try { | |
| 74 | snapshot = bridge | |
| 75 | .getLocalEnvironmentBootstraps() | |
| 76 | .filter((entry) => entry.id !== PRIMARY_LOCAL_ENVIRONMENT_ID); | |
| 77 | return { _tag: "Success", bootstraps: snapshot }; | |
| 78 | } catch (cause) { | |
| 79 | return { _tag: "Failure", cause }; | |
| 80 | } | |
| 81 | }; | |
| 82 | ||
| 83 | return { | |
| 84 | readResult, | |
| 85 | readSnapshot: () => { | |
| 86 | const result = readResult(); | |
| 87 | return result._tag === "Success" ? result.bootstraps : snapshot; | |
| 88 | }, | |
| 89 | }; | |
| 90 | } | |
| 91 | ||
| 92 | const desktopSecondaryBootstrapsReader = createDesktopSecondaryBootstrapsReader( | |
| 93 | () => window.desktopBridge, | |
| 94 | ); | |
| 95 | ||
| 96 | /** Read the topology while preserving failures for platform cache policy. */ | |
| 97 | export function readDesktopSecondaryBootstrapsResult(): DesktopSecondaryBootstrapsRead { | |
| 98 | return desktopSecondaryBootstrapsReader.readResult(); | |
| 99 | } | |
| 100 | ||
| 101 | /** Read the latest successful topology snapshot for renderer consumers. */ | |
| 102 | export function readDesktopSecondaryBootstraps(): ReadonlyArray<DesktopEnvironmentBootstrap> { | |
| 103 | return desktopSecondaryBootstrapsReader.readSnapshot(); | |
| 104 | } |
apps/web/src/connection/platform.test.ts+138-1| ... | ... | @@ -1,13 +1,23 @@ |
| 1 | 1 | import { |
| 2 | 2 | AuthStandardClientScopes, |
| 3 | 3 | EnvironmentId, |
| 4 | PRIMARY_LOCAL_ENVIRONMENT_ID, | |
| 4 | 5 | type DesktopBridge, |
| 5 | 6 | type DesktopSshEnvironmentTarget, |
| 6 | 7 | } from "@t3tools/contracts"; |
| 7 | 8 | import { describe, expect, it } from "@effect/vitest"; |
| 8 | 9 | import * as Effect from "effect/Effect"; |
| 9 | 10 | |
| 10 | import { provisionDesktopSshEnvironment } from "./platform.ts"; | |
| 11 | import { | |
| 12 | canRetainCachedPlatformRegistrationAfterRefreshFailure, | |
| 13 | canReuseCachedPlatformRegistration, | |
| 14 | primaryRegistrationToRetainAfterTopologyRead, | |
| 15 | provisionDesktopSshEnvironment, | |
| 16 | readPrimaryEnvironmentTargetResult, | |
| 17 | secondaryRegistrationsToRetainAfterTopologyRead, | |
| 18 | secondaryBearerExpiresAtEpochMs, | |
| 19 | secondaryBearerRefreshAtEpochMs, | |
| 20 | } from "./platform.ts"; | |
| 11 | 21 | |
| 12 | 22 | const TARGET: DesktopSshEnvironmentTarget = { |
| 13 | 23 | alias: "devbox", |
| ... | ... | @@ -86,3 +96,130 @@ describe("desktop SSH pairing", () => { |
| 86 | 96 | }), |
| 87 | 97 | ); |
| 88 | 98 | }); |
| 99 | ||
| 100 | describe("desktop-local bearer cache", () => { | |
| 101 | const registration = {} as never; | |
| 102 | ||
| 103 | it("refreshes a secondary bearer before it expires", () => { | |
| 104 | const issuedAtEpochMs = 10_000; | |
| 105 | const refreshAtEpochMs = secondaryBearerRefreshAtEpochMs(issuedAtEpochMs, 60); | |
| 106 | const expiresAtEpochMs = secondaryBearerExpiresAtEpochMs(issuedAtEpochMs, 60); | |
| 107 | const cached = { | |
| 108 | expiresAtEpochMs, | |
| 109 | signature: "secondary-signature", | |
| 110 | registration, | |
| 111 | refreshAtEpochMs, | |
| 112 | }; | |
| 113 | ||
| 114 | expect(refreshAtEpochMs).toBe(65_000); | |
| 115 | expect(canReuseCachedPlatformRegistration(cached, cached.signature, 64_999)).toBe(true); | |
| 116 | expect(canReuseCachedPlatformRegistration(cached, cached.signature, 65_000)).toBe(false); | |
| 117 | expect( | |
| 118 | canRetainCachedPlatformRegistrationAfterRefreshFailure(cached, cached.signature, 69_999), | |
| 119 | ).toBe(true); | |
| 120 | expect( | |
| 121 | canRetainCachedPlatformRegistrationAfterRefreshFailure(cached, cached.signature, 70_000), | |
| 122 | ).toBe(false); | |
| 123 | }); | |
| 124 | ||
| 125 | it("does not cache credentials whose lifetime is shorter than the refresh skew", () => { | |
| 126 | const refreshAtEpochMs = secondaryBearerRefreshAtEpochMs(10_000, 3); | |
| 127 | const cached = { | |
| 128 | expiresAtEpochMs: secondaryBearerExpiresAtEpochMs(10_000, 3), | |
| 129 | signature: "secondary-signature", | |
| 130 | registration, | |
| 131 | refreshAtEpochMs, | |
| 132 | }; | |
| 133 | ||
| 134 | expect(refreshAtEpochMs).toBe(10_000); | |
| 135 | expect(canReuseCachedPlatformRegistration(cached, cached.signature, 10_000)).toBe(false); | |
| 136 | }); | |
| 137 | ||
| 138 | it("retains only unexpired secondaries after a topology read failure", () => { | |
| 139 | const valid = { | |
| 140 | expiresAtEpochMs: 20_000, | |
| 141 | signature: "valid-secondary", | |
| 142 | registration, | |
| 143 | refreshAtEpochMs: 15_000, | |
| 144 | }; | |
| 145 | const previous = new Map([ | |
| 146 | ["valid-secondary", valid], | |
| 147 | [ | |
| 148 | "expired-secondary", | |
| 149 | { | |
| 150 | expiresAtEpochMs: 10_000, | |
| 151 | signature: "expired-secondary", | |
| 152 | registration, | |
| 153 | refreshAtEpochMs: 5_000, | |
| 154 | }, | |
| 155 | ], | |
| 156 | ]); | |
| 157 | ||
| 158 | expect( | |
| 159 | secondaryRegistrationsToRetainAfterTopologyRead( | |
| 160 | previous, | |
| 161 | { _tag: "Failure", cause: new Error("IPC unavailable") }, | |
| 162 | 10_000, | |
| 163 | ), | |
| 164 | ).toEqual(new Map([["valid-secondary", valid]])); | |
| 165 | }); | |
| 166 | ||
| 167 | it("treats a successful empty topology as authoritative removal", () => { | |
| 168 | const previous = new Map([ | |
| 169 | [ | |
| 170 | "secondary", | |
| 171 | { | |
| 172 | expiresAtEpochMs: 20_000, | |
| 173 | signature: "secondary", | |
| 174 | registration, | |
| 175 | refreshAtEpochMs: 15_000, | |
| 176 | }, | |
| 177 | ], | |
| 178 | ]); | |
| 179 | ||
| 180 | expect( | |
| 181 | secondaryRegistrationsToRetainAfterTopologyRead( | |
| 182 | previous, | |
| 183 | { _tag: "Success", bootstraps: [] }, | |
| 184 | 10_000, | |
| 185 | ), | |
| 186 | ).toEqual(new Map()); | |
| 187 | }); | |
| 188 | }); | |
| 189 | ||
| 190 | describe("primary topology cache", () => { | |
| 191 | const registration = {} as never; | |
| 192 | const cached = { | |
| 193 | signature: "primary|http://127.0.0.1:3773/|ws://127.0.0.1:3773/", | |
| 194 | registration, | |
| 195 | }; | |
| 196 | const previous = new Map([[PRIMARY_LOCAL_ENVIRONMENT_ID, cached]]); | |
| 197 | ||
| 198 | it("captures synchronous primary target read failures", () => { | |
| 199 | const cause = new Error("invalid primary target"); | |
| 200 | ||
| 201 | expect( | |
| 202 | readPrimaryEnvironmentTargetResult(() => { | |
| 203 | throw cause; | |
| 204 | }), | |
| 205 | ).toEqual({ _tag: "Failure", cause }); | |
| 206 | }); | |
| 207 | ||
| 208 | it("retains the cached primary after a transient topology read failure", () => { | |
| 209 | expect( | |
| 210 | primaryRegistrationToRetainAfterTopologyRead(previous, { | |
| 211 | _tag: "Failure", | |
| 212 | cause: new Error("IPC unavailable"), | |
| 213 | }), | |
| 214 | ).toBe(cached); | |
| 215 | }); | |
| 216 | ||
| 217 | it("treats a successful primary absence as authoritative removal", () => { | |
| 218 | expect( | |
| 219 | primaryRegistrationToRetainAfterTopologyRead(previous, { | |
| 220 | _tag: "Success", | |
| 221 | target: null, | |
| 222 | }), | |
| 223 | ).toBeUndefined(); | |
| 224 | }); | |
| 225 | }); |
apps/web/src/connection/platform.ts+291-29| ... | ... | @@ -8,37 +8,55 @@ import { |
| 8 | 8 | SshEnvironmentGateway, |
| 9 | 9 | } from "@t3tools/client-runtime/platform"; |
| 10 | 10 | import { |
| 11 | BearerConnectionCredential, | |
| 12 | BearerConnectionProfile, | |
| 13 | BearerConnectionRegistration, | |
| 14 | BearerConnectionTarget, | |
| 11 | 15 | ConnectionBlockedError, |
| 12 | 16 | ConnectionTransientError, |
| 13 | 17 | Connectivity, |
| 14 | 18 | mapRemoteEnvironmentError, |
| 19 | type PlatformConnectionRegistration, | |
| 15 | 20 | PrimaryConnectionRegistration, |
| 16 | 21 | PrimaryConnectionTarget, |
| 17 | 22 | Wakeups, |
| 18 | 23 | } from "@t3tools/client-runtime/connection"; |
| 24 | import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; | |
| 19 | 25 | import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; |
| 20 | 26 | import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; |
| 21 | 27 | import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc"; |
| 22 | 28 | import { |
| 23 | 29 | AuthStandardClientScopes, |
| 24 | 30 | type DesktopBridge, |
| 31 | type DesktopEnvironmentBootstrap, | |
| 25 | 32 | type DesktopSshEnvironmentTarget, |
| 33 | PRIMARY_LOCAL_ENVIRONMENT_ID, | |
| 26 | 34 | } from "@t3tools/contracts"; |
| 35 | import * as Clock from "effect/Clock"; | |
| 27 | 36 | import * as Context from "effect/Context"; |
| 28 | 37 | import * as Effect from "effect/Effect"; |
| 29 | 38 | import * as Layer from "effect/Layer"; |
| 30 | 39 | import * as Option from "effect/Option"; |
| 31 | 40 | import * as Queue from "effect/Queue"; |
| 32 | import * as Schedule from "effect/Schedule"; | |
| 41 | import * as Ref from "effect/Ref"; | |
| 33 | 42 | import * as Stream from "effect/Stream"; |
| 43 | import { FetchHttpClient } from "effect/unstable/http"; | |
| 34 | 44 | |
| 35 | 45 | import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; |
| 36 | 46 | import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; |
| 37 | import { readPrimaryEnvironmentTarget } from "../environments/primary/target"; | |
| 47 | import { | |
| 48 | readPrimaryEnvironmentTarget, | |
| 49 | type PrimaryEnvironmentTarget, | |
| 50 | } from "../environments/primary/target"; | |
| 38 | 51 | import { clearComposerDraftsEnvironment } from "../composerDraftStore"; |
| 39 | 52 | import { isHostedStaticApp } from "../hostedPairing"; |
| 40 | 53 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 41 | 54 | import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; |
| 55 | import { | |
| 56 | desktopLocalConnectionId, | |
| 57 | readDesktopSecondaryBootstrapsResult, | |
| 58 | type DesktopSecondaryBootstrapsRead, | |
| 59 | } from "./desktopLocal"; | |
| 42 | 60 | import { connectionStorageLayer } from "./storage"; |
| 43 | 61 | |
| 44 | 62 | let nextObservedRpcRequestId = 0; |
| ... | ... | @@ -267,14 +285,7 @@ const capabilitiesLayer = Layer.effectContext( |
| 267 | 285 | |
| 268 | 286 | const loadPrimaryConnectionRegistration = Effect.fn( |
| 269 | 287 | "web.connectionPlatform.loadPrimaryConnectionRegistration", |
| 270 | )(function* () { | |
| 271 | const resolved = readPrimaryEnvironmentTarget(); | |
| 272 | if (resolved === null) { | |
| 273 | return yield* new ConnectionBlockedError({ | |
| 274 | reason: "configuration", | |
| 275 | detail: "Unable to resolve the primary environment endpoint.", | |
| 276 | }); | |
| 277 | } | |
| 288 | )(function* (resolved: PrimaryEnvironmentTarget) { | |
| 278 | 289 | const descriptor = yield* fetchRemoteEnvironmentDescriptor({ |
| 279 | 290 | httpBaseUrl: resolved.target.httpBaseUrl, |
| 280 | 291 | }).pipe(Effect.provide(primaryEnvironmentHttpLayer), Effect.mapError(mapRemoteEnvironmentError)); |
| ... | ... | @@ -288,29 +299,280 @@ const loadPrimaryConnectionRegistration = Effect.fn( |
| 288 | 299 | }); |
| 289 | 300 | }); |
| 290 | 301 | |
| 291 | const primaryRegistrationRetrySchedule = Schedule.exponential("1 second").pipe( | |
| 292 | Schedule.either(Schedule.spaced("16 seconds")), | |
| 293 | ); | |
| 294 | ||
| 295 | const platformConnectionSourceLayer = Layer.sync(PlatformConnectionSource, () => { | |
| 296 | if (isHostedStaticApp()) { | |
| 297 | return PlatformConnectionSource.of({ | |
| 298 | registrations: Stream.empty, | |
| 302 | // A desktop-local secondary backend (e.g. a parallel WSL backend) lives on its | |
| 303 | // own loopback origin, so — unlike the same-origin primary — it authenticates | |
| 304 | // with a bearer token minted from the bootstrap credential the desktop issues. | |
| 305 | const loadSecondaryConnectionRegistration = Effect.fn( | |
| 306 | "web.connectionPlatform.loadSecondaryConnectionRegistration", | |
| 307 | )(function* (entry: DesktopEnvironmentBootstrap) { | |
| 308 | if ( | |
| 309 | entry.httpBaseUrl === null || | |
| 310 | entry.wsBaseUrl === null || | |
| 311 | entry.bootstrapToken === undefined | |
| 312 | ) { | |
| 313 | return yield* new ConnectionTransientError({ | |
| 314 | reason: "endpoint-unavailable", | |
| 315 | detail: `Desktop-local backend ${entry.id} is not ready yet.`, | |
| 299 | 316 | }); |
| 300 | 317 | } |
| 301 | return PlatformConnectionSource.of({ | |
| 302 | registrations: Stream.fromEffect(loadPrimaryConnectionRegistration()).pipe( | |
| 303 | Stream.tapError((error) => | |
| 304 | Effect.logWarning("Could not discover the primary environment.", { | |
| 305 | error, | |
| 306 | }), | |
| 307 | ), | |
| 308 | Stream.retry(primaryRegistrationRetrySchedule), | |
| 309 | Stream.catchCause(() => Stream.empty), | |
| 310 | ), | |
| 311 | }); | |
| 318 | const httpBaseUrl = entry.httpBaseUrl; | |
| 319 | const wsBaseUrl = entry.wsBaseUrl; | |
| 320 | const descriptor = yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( | |
| 321 | Effect.mapError(mapRemoteEnvironmentError), | |
| 322 | ); | |
| 323 | const issuedAtEpochMs = yield* Clock.currentTimeMillis; | |
| 324 | const access = yield* bootstrapRemoteBearerSession({ | |
| 325 | httpBaseUrl, | |
| 326 | credential: entry.bootstrapToken, | |
| 327 | scopes: AuthStandardClientScopes, | |
| 328 | clientMetadata: clientMetadata(), | |
| 329 | }).pipe(Effect.mapError(mapRemoteEnvironmentError)); | |
| 330 | // Keep the desktop pool's stable backend id in the connection id. The | |
| 331 | // descriptor environment id still scopes projects and RPC state, while the | |
| 332 | // backend id lets desktop-only operations (notably the WSL folder picker) | |
| 333 | // route back to the instance that owns the environment. | |
| 334 | const connectionId = desktopLocalConnectionId(entry.id); | |
| 335 | // Prefer the desktop's bootstrap label (it identifies the backend and distro, | |
| 336 | // e.g. "WSL: Ubuntu") over the generic descriptor label, so consumers can show | |
| 337 | // a meaningful name without recovering it from the bootstrap list later. | |
| 338 | const label = entry.label || descriptor.label; | |
| 339 | return { | |
| 340 | registration: new BearerConnectionRegistration({ | |
| 341 | target: new BearerConnectionTarget({ | |
| 342 | environmentId: descriptor.environmentId, | |
| 343 | label, | |
| 344 | connectionId, | |
| 345 | }), | |
| 346 | profile: new BearerConnectionProfile({ | |
| 347 | connectionId, | |
| 348 | environmentId: descriptor.environmentId, | |
| 349 | label, | |
| 350 | httpBaseUrl, | |
| 351 | wsBaseUrl, | |
| 352 | }), | |
| 353 | credential: new BearerConnectionCredential({ token: access.access_token }), | |
| 354 | }), | |
| 355 | expiresAtEpochMs: secondaryBearerExpiresAtEpochMs(issuedAtEpochMs, access.expires_in), | |
| 356 | refreshAtEpochMs: secondaryBearerRefreshAtEpochMs(issuedAtEpochMs, access.expires_in), | |
| 357 | }; | |
| 312 | 358 | }); |
| 313 | 359 | |
| 360 | // Poll cadence for the desktop bootstrap topology. There is no change event on | |
| 361 | // the bridge, so the renderer polls; successful registrations are cached by a | |
| 362 | // signature of their endpoint + token until bearer credentials approach expiry. | |
| 363 | const PLATFORM_POLL_INTERVAL = "3 seconds"; | |
| 364 | const SECONDARY_BEARER_REFRESH_SKEW_MS = 5_000; | |
| 365 | ||
| 366 | export function secondaryBearerExpiresAtEpochMs( | |
| 367 | issuedAtEpochMs: number, | |
| 368 | expiresInSeconds: number, | |
| 369 | ): number { | |
| 370 | return issuedAtEpochMs + Math.max(0, expiresInSeconds * 1_000); | |
| 371 | } | |
| 372 | ||
| 373 | export function secondaryBearerRefreshAtEpochMs( | |
| 374 | issuedAtEpochMs: number, | |
| 375 | expiresInSeconds: number, | |
| 376 | ): number { | |
| 377 | return Math.max( | |
| 378 | issuedAtEpochMs, | |
| 379 | secondaryBearerExpiresAtEpochMs(issuedAtEpochMs, expiresInSeconds) - | |
| 380 | SECONDARY_BEARER_REFRESH_SKEW_MS, | |
| 381 | ); | |
| 382 | } | |
| 383 | ||
| 384 | interface CachedPlatformRegistration { | |
| 385 | readonly signature: string; | |
| 386 | readonly registration: PlatformConnectionRegistration; | |
| 387 | readonly expiresAtEpochMs?: number; | |
| 388 | readonly refreshAtEpochMs?: number; | |
| 389 | } | |
| 390 | ||
| 391 | export type PrimaryEnvironmentTargetRead = | |
| 392 | | { | |
| 393 | readonly _tag: "Success"; | |
| 394 | readonly target: PrimaryEnvironmentTarget | null; | |
| 395 | } | |
| 396 | | { | |
| 397 | readonly _tag: "Failure"; | |
| 398 | readonly cause: unknown; | |
| 399 | }; | |
| 400 | ||
| 401 | export function readPrimaryEnvironmentTargetResult( | |
| 402 | readTarget: () => PrimaryEnvironmentTarget | null = readPrimaryEnvironmentTarget, | |
| 403 | ): PrimaryEnvironmentTargetRead { | |
| 404 | try { | |
| 405 | return { _tag: "Success", target: readTarget() }; | |
| 406 | } catch (cause) { | |
| 407 | return { _tag: "Failure", cause }; | |
| 408 | } | |
| 409 | } | |
| 410 | ||
| 411 | export function primaryRegistrationToRetainAfterTopologyRead( | |
| 412 | previous: ReadonlyMap<string, CachedPlatformRegistration>, | |
| 413 | topologyRead: PrimaryEnvironmentTargetRead, | |
| 414 | ): CachedPlatformRegistration | undefined { | |
| 415 | return topologyRead._tag === "Failure" ? previous.get(PRIMARY_LOCAL_ENVIRONMENT_ID) : undefined; | |
| 416 | } | |
| 417 | ||
| 418 | export function canReuseCachedPlatformRegistration( | |
| 419 | cached: CachedPlatformRegistration, | |
| 420 | signature: string, | |
| 421 | nowEpochMs: number, | |
| 422 | ): boolean { | |
| 423 | return ( | |
| 424 | cached.signature === signature && | |
| 425 | (cached.refreshAtEpochMs === undefined || nowEpochMs < cached.refreshAtEpochMs) | |
| 426 | ); | |
| 427 | } | |
| 428 | ||
| 429 | export function canRetainCachedPlatformRegistrationAfterRefreshFailure( | |
| 430 | cached: CachedPlatformRegistration, | |
| 431 | signature: string, | |
| 432 | nowEpochMs: number, | |
| 433 | ): boolean { | |
| 434 | return ( | |
| 435 | cached.signature === signature && | |
| 436 | cached.expiresAtEpochMs !== undefined && | |
| 437 | nowEpochMs < cached.expiresAtEpochMs | |
| 438 | ); | |
| 439 | } | |
| 440 | ||
| 441 | export function secondaryRegistrationsToRetainAfterTopologyRead( | |
| 442 | previous: ReadonlyMap<string, CachedPlatformRegistration>, | |
| 443 | topologyRead: DesktopSecondaryBootstrapsRead, | |
| 444 | nowEpochMs: number, | |
| 445 | ): ReadonlyMap<string, CachedPlatformRegistration> { | |
| 446 | if (topologyRead._tag === "Success") { | |
| 447 | return new Map(); | |
| 448 | } | |
| 449 | return new Map( | |
| 450 | [...previous].filter( | |
| 451 | ([, cached]) => cached.expiresAtEpochMs !== undefined && nowEpochMs < cached.expiresAtEpochMs, | |
| 452 | ), | |
| 453 | ); | |
| 454 | } | |
| 455 | ||
| 456 | const platformConnectionSourceLayer = Layer.effect( | |
| 457 | PlatformConnectionSource, | |
| 458 | Effect.gen(function* () { | |
| 459 | if (isHostedStaticApp()) { | |
| 460 | return PlatformConnectionSource.of({ | |
| 461 | registrations: Stream.empty, | |
| 462 | }); | |
| 463 | } | |
| 464 | const cacheRef = yield* Ref.make(new Map<string, CachedPlatformRegistration>()); | |
| 465 | ||
| 466 | // Resolve the full set of platform-managed environments the host currently | |
| 467 | // reports: the primary (same-origin cookie auth) plus any desktop-local | |
| 468 | // backends running alongside it (bearer auth). Reused registrations come | |
| 469 | // from the cache; a failed entry is skipped and retried on the next poll. | |
| 470 | const buildPlatformRegistrations = Effect.gen(function* () { | |
| 471 | const previous = yield* Ref.get(cacheRef); | |
| 472 | const nowEpochMs = yield* Clock.currentTimeMillis; | |
| 473 | const next = new Map<string, CachedPlatformRegistration>(); | |
| 474 | const registrations: Array<PlatformConnectionRegistration> = []; | |
| 475 | ||
| 476 | const primaryTopologyRead = readPrimaryEnvironmentTargetResult(); | |
| 477 | const retainedPrimary = primaryRegistrationToRetainAfterTopologyRead( | |
| 478 | previous, | |
| 479 | primaryTopologyRead, | |
| 480 | ); | |
| 481 | if (retainedPrimary !== undefined) { | |
| 482 | next.set(PRIMARY_LOCAL_ENVIRONMENT_ID, retainedPrimary); | |
| 483 | registrations.push(retainedPrimary.registration); | |
| 484 | } | |
| 485 | ||
| 486 | if (primaryTopologyRead._tag === "Failure") { | |
| 487 | yield* Effect.logWarning("Could not read the primary environment topology.", { | |
| 488 | cause: primaryTopologyRead.cause, | |
| 489 | }); | |
| 490 | } else if (primaryTopologyRead.target !== null) { | |
| 491 | const primaryTarget = primaryTopologyRead.target; | |
| 492 | const signature = `primary|${primaryTarget.target.httpBaseUrl}|${primaryTarget.target.wsBaseUrl}`; | |
| 493 | const cached = previous.get(PRIMARY_LOCAL_ENVIRONMENT_ID); | |
| 494 | if ( | |
| 495 | cached !== undefined && | |
| 496 | canReuseCachedPlatformRegistration(cached, signature, nowEpochMs) | |
| 497 | ) { | |
| 498 | next.set(PRIMARY_LOCAL_ENVIRONMENT_ID, cached); | |
| 499 | registrations.push(cached.registration); | |
| 500 | } else { | |
| 501 | const built = yield* loadPrimaryConnectionRegistration(primaryTarget).pipe( | |
| 502 | Effect.tapError((error) => | |
| 503 | Effect.logWarning("Could not discover the primary environment.", { error }), | |
| 504 | ), | |
| 505 | Effect.option, | |
| 506 | ); | |
| 507 | if (Option.isSome(built)) { | |
| 508 | const cacheEntry = { signature, registration: built.value }; | |
| 509 | next.set(PRIMARY_LOCAL_ENVIRONMENT_ID, cacheEntry); | |
| 510 | registrations.push(built.value); | |
| 511 | } | |
| 512 | } | |
| 513 | } | |
| 514 | ||
| 515 | const topologyRead = readDesktopSecondaryBootstrapsResult(); | |
| 516 | for (const [id, cached] of secondaryRegistrationsToRetainAfterTopologyRead( | |
| 517 | previous, | |
| 518 | topologyRead, | |
| 519 | nowEpochMs, | |
| 520 | )) { | |
| 521 | next.set(id, cached); | |
| 522 | registrations.push(cached.registration); | |
| 523 | } | |
| 524 | ||
| 525 | if (topologyRead._tag === "Failure") { | |
| 526 | yield* Effect.logWarning("Could not read the desktop-local backend topology.", { | |
| 527 | cause: topologyRead.cause, | |
| 528 | }); | |
| 529 | } else { | |
| 530 | for (const bootstrap of topologyRead.bootstraps) { | |
| 531 | const signature = `${bootstrap.httpBaseUrl}|${bootstrap.wsBaseUrl}|${bootstrap.bootstrapToken ?? ""}`; | |
| 532 | const cached = previous.get(bootstrap.id); | |
| 533 | if ( | |
| 534 | cached !== undefined && | |
| 535 | canReuseCachedPlatformRegistration(cached, signature, nowEpochMs) | |
| 536 | ) { | |
| 537 | next.set(bootstrap.id, cached); | |
| 538 | registrations.push(cached.registration); | |
| 539 | continue; | |
| 540 | } | |
| 541 | const built = yield* loadSecondaryConnectionRegistration(bootstrap).pipe( | |
| 542 | Effect.tapError((error) => | |
| 543 | Effect.logWarning("Could not connect a desktop-local backend.", { | |
| 544 | id: bootstrap.id, | |
| 545 | error, | |
| 546 | }), | |
| 547 | ), | |
| 548 | Effect.option, | |
| 549 | ); | |
| 550 | if (Option.isSome(built)) { | |
| 551 | const cacheEntry = { signature, ...built.value }; | |
| 552 | next.set(bootstrap.id, cacheEntry); | |
| 553 | registrations.push(built.value.registration); | |
| 554 | } else if ( | |
| 555 | cached !== undefined && | |
| 556 | canRetainCachedPlatformRegistrationAfterRefreshFailure(cached, signature, nowEpochMs) | |
| 557 | ) { | |
| 558 | next.set(bootstrap.id, cached); | |
| 559 | registrations.push(cached.registration); | |
| 560 | } | |
| 561 | } | |
| 562 | } | |
| 563 | ||
| 564 | yield* Ref.set(cacheRef, next); | |
| 565 | return registrations as ReadonlyArray<PlatformConnectionRegistration>; | |
| 566 | }).pipe(Effect.provide(FetchHttpClient.layer)); | |
| 567 | ||
| 568 | return PlatformConnectionSource.of({ | |
| 569 | registrations: Stream.tick(PLATFORM_POLL_INTERVAL).pipe( | |
| 570 | Stream.mapEffect(() => buildPlatformRegistrations), | |
| 571 | ), | |
| 572 | }); | |
| 573 | }), | |
| 574 | ); | |
| 575 | ||
| 314 | 576 | const environmentOwnedDataCleanupLayer = Layer.succeed( |
| 315 | 577 | EnvironmentOwnedDataCleanup, |
| 316 | 578 | EnvironmentOwnedDataCleanup.of({ |
apps/web/src/connection/useDesktopLocalBootstraps.ts created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; | |
| 2 | import { useEffect, useState } from "react"; | |
| 3 | ||
| 4 | import { readDesktopSecondaryBootstraps } from "./desktopLocal"; | |
| 5 | ||
| 6 | const DESKTOP_LOCAL_BOOTSTRAP_POLL_MS = 2_000; | |
| 7 | ||
| 8 | /** | |
| 9 | * Reactively track the desktop's secondary local backends (e.g. a parallel WSL | |
| 10 | * backend). The bridge exposes no change event, so we re-read on an interval; | |
| 11 | * failed reads retain the latest successful snapshot, while a successful empty | |
| 12 | * read clears it. Use this instead of polling the bridge ad hoc so every | |
| 13 | * renderer consumer reads the same topology. | |
| 14 | */ | |
| 15 | export function useDesktopLocalBootstraps(): ReadonlyArray<DesktopEnvironmentBootstrap> { | |
| 16 | const [bootstraps, setBootstraps] = useState<ReadonlyArray<DesktopEnvironmentBootstrap>>( | |
| 17 | readDesktopSecondaryBootstraps, | |
| 18 | ); | |
| 19 | ||
| 20 | useEffect(() => { | |
| 21 | const read = () => setBootstraps(readDesktopSecondaryBootstraps()); | |
| 22 | read(); | |
| 23 | const interval = setInterval(read, DESKTOP_LOCAL_BOOTSTRAP_POLL_MS); | |
| 24 | return () => clearInterval(interval); | |
| 25 | }, []); | |
| 26 | ||
| 27 | return bootstraps; | |
| 28 | } |
apps/web/src/environments/primary/auth.ts+18-4| ... | ... | @@ -7,7 +7,7 @@ import type { |
| 7 | 7 | AuthSessionId, |
| 8 | 8 | AuthSessionState, |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | import { EnvironmentHttpCommonError } from "@t3tools/contracts"; | |
| 10 | import { EnvironmentHttpCommonError, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | |
| 11 | 11 | import type { EnvironmentHttpCommonError as EnvironmentHttpCommonErrorType } from "@t3tools/contracts"; |
| 12 | 12 | import * as DateTime from "effect/DateTime"; |
| 13 | 13 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -176,9 +176,13 @@ export function takePairingTokenFromUrl(): string | null { |
| 176 | 176 | } |
| 177 | 177 | |
| 178 | 178 | function getDesktopBootstrapCredential(): string | null { |
| 179 | const bootstrap = window.desktopBridge?.getLocalEnvironmentBootstrap(); | |
| 180 | return typeof bootstrap?.bootstrapToken === "string" && bootstrap.bootstrapToken.length > 0 | |
| 181 | ? bootstrap.bootstrapToken | |
| 179 | // Both backends share the same bootstrap token (DesktopBackendConfiguration | |
| 180 | // mints one tokenRef and feeds it to both resolvers), so picking the | |
| 181 | // primary entry is fine even when the WSL backend is also registered. | |
| 182 | const bootstraps = window.desktopBridge?.getLocalEnvironmentBootstraps() ?? []; | |
| 183 | const primary = bootstraps.find((entry) => entry.id === PRIMARY_LOCAL_ENVIRONMENT_ID); | |
| 184 | return typeof primary?.bootstrapToken === "string" && primary.bootstrapToken.length > 0 | |
| 185 | ? primary.bootstrapToken | |
| 182 | 186 | : null; |
| 183 | 187 | } |
| 184 | 188 | |
| ... | ... | @@ -526,6 +530,16 @@ export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGat |
| 526 | 530 | }); |
| 527 | 531 | } |
| 528 | 532 | |
| 533 | // Used by the WSL backend swap: invalidate the cached authenticated state | |
| 534 | // (the new backend signs sessions with a different key) and re-bootstrap | |
| 535 | // against the desktop bootstrap credential so the next WS reconnect doesn't | |
| 536 | // hit 401 and start a reauth loop in the renderer. | |
| 537 | export async function reauthenticatePrimaryEnvironment(): Promise<ServerAuthGateState> { | |
| 538 | resolvedAuthenticatedGateState = null; | |
| 539 | bootstrapPromise = null; | |
| 540 | return resolveInitialServerAuthGateState(); | |
| 541 | } | |
| 542 | ||
| 529 | 543 | export function __resetServerAuthBootstrapForTests() { |
| 530 | 544 | bootstrapPromise = null; |
| 531 | 545 | resolvedAuthenticatedGateState = null; |
apps/web/src/environments/primary/bootstrap.test.ts+17-11| ... | ... | @@ -173,12 +173,15 @@ describe("environmentBootstrap", () => { |
| 173 | 173 | replaceState: vi.fn(), |
| 174 | 174 | }, |
| 175 | 175 | desktopBridge: { |
| 176 | getLocalEnvironmentBootstrap: () => ({ | |
| 177 | label: "Local environment", | |
| 178 | httpBaseUrl: "http://127.0.0.1:3773", | |
| 179 | wsBaseUrl: "ws://127.0.0.1:3773", | |
| 180 | bootstrapToken: "desktop-bootstrap-token", | |
| 181 | }), | |
| 176 | getLocalEnvironmentBootstraps: () => [ | |
| 177 | { | |
| 178 | id: "primary", | |
| 179 | label: "Windows", | |
| 180 | httpBaseUrl: "http://127.0.0.1:3773", | |
| 181 | wsBaseUrl: "ws://127.0.0.1:3773", | |
| 182 | bootstrapToken: "desktop-bootstrap-token", | |
| 183 | }, | |
| 184 | ], | |
| 182 | 185 | }, |
| 183 | 186 | }); |
| 184 | 187 | await installDescriptorApi(); |
| ... | ... | @@ -212,11 +215,14 @@ describe("environmentBootstrap", () => { |
| 212 | 215 | location: new URL("http://127.0.0.1:5733/"), |
| 213 | 216 | history: { replaceState: vi.fn() }, |
| 214 | 217 | desktopBridge: { |
| 215 | getLocalEnvironmentBootstrap: () => ({ | |
| 216 | label: "Local environment", | |
| 217 | httpBaseUrl: "http://127.0.0.1:3773", | |
| 218 | bootstrapToken: "desktop-bootstrap-token", | |
| 219 | }), | |
| 218 | getLocalEnvironmentBootstraps: () => [ | |
| 219 | { | |
| 220 | id: "primary", | |
| 221 | label: "Local environment", | |
| 222 | httpBaseUrl: "http://127.0.0.1:3773", | |
| 223 | bootstrapToken: "desktop-bootstrap-token", | |
| 224 | }, | |
| 225 | ], | |
| 220 | 226 | }, |
| 221 | 227 | }); |
| 222 | 228 |
apps/web/src/environments/primary/index.ts+1| ... | ... | @@ -23,6 +23,7 @@ export { |
| 23 | 23 | peekPairingTokenFromUrl, |
| 24 | 24 | PrimaryEnvironmentPairingCredentialRejectedError, |
| 25 | 25 | PrimaryEnvironmentRequestError, |
| 26 | reauthenticatePrimaryEnvironment, | |
| 26 | 27 | resolveInitialServerAuthGateState, |
| 27 | 28 | revokeOtherServerClientSessions, |
| 28 | 29 | revokeServerClientSession, |
apps/web/src/environments/primary/target.ts+6-2| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; | |
| 1 | import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts"; | |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | |
| 4 | 4 | const PrimaryEnvironmentTargetSource = Schema.Literals([ |
| ... | ... | @@ -76,7 +76,11 @@ export interface PrimaryEnvironmentTarget { |
| 76 | 76 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
| 77 | 77 | |
| 78 | 78 | function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | null { |
| 79 | return window.desktopBridge?.getLocalEnvironmentBootstrap() ?? null; | |
| 79 | // The primary (Windows-native) backend keeps the "primary" id. The | |
| 80 | // plural list may include a second WSL entry; the primary-target | |
| 81 | // resolver only cares about the primary, so just find it. | |
| 82 | const bootstraps = window.desktopBridge?.getLocalEnvironmentBootstraps() ?? []; | |
| 83 | return bootstraps.find((entry) => entry.id === PRIMARY_LOCAL_ENVIRONMENT_ID) ?? null; | |
| 80 | 84 | } |
| 81 | 85 | |
| 82 | 86 | function parseTargetUrl(input: { |
apps/web/src/sidebarProjectGrouping.ts+21-6| ... | ... | @@ -20,6 +20,12 @@ export interface SidebarProjectSnapshot extends Project { |
| 20 | 20 | displayName: string; |
| 21 | 21 | groupedProjectCount: number; |
| 22 | 22 | environmentPresence: EnvironmentPresence; |
| 23 | // True iff every non-primary member of this group lives in a | |
| 24 | // desktopLocal env (today: the WSL backend). The sidebar uses this | |
| 25 | // to differentiate "lives on this machine but in a sandbox" from | |
| 26 | // "lives on a real remote" so the project header can pick a | |
| 27 | // container icon instead of the generic cloud icon. | |
| 28 | allRemoteMembersAreDesktopLocal: boolean; | |
| 23 | 29 | memberProjects: readonly SidebarProjectGroupMember[]; |
| 24 | 30 | memberProjectRefs: readonly ScopedProjectRef[]; |
| 25 | 31 | remoteEnvironmentLabels: readonly string[]; |
| ... | ... | @@ -44,6 +50,11 @@ export function buildSidebarProjectSnapshots(input: { |
| 44 | 50 | settings: ProjectGroupingSettings; |
| 45 | 51 | primaryEnvironmentId: EnvironmentId | null; |
| 46 | 52 | resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; |
| 53 | // Returns true when an env id maps to a desktopLocal saved-env | |
| 54 | // record (today: the WSL backend). Defaults to "false for every | |
| 55 | // env" so callers that don't care about the distinction get the | |
| 56 | // legacy behavior. | |
| 57 | isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean; | |
| 47 | 58 | }): SidebarProjectSnapshot[] { |
| 48 | 59 | const groupedMembers = new Map<string, SidebarProjectGroupMember[]>(); |
| 49 | 60 | for (const project of input.projects) { |
| ... | ... | @@ -86,14 +97,17 @@ export function buildSidebarProjectSnapshots(input: { |
| 86 | 97 | input.primaryEnvironmentId !== null |
| 87 | 98 | ? members.some((member) => member.environmentId !== input.primaryEnvironmentId) |
| 88 | 99 | : false; |
| 89 | const remoteEnvironmentLabels = members | |
| 90 | .filter( | |
| 91 | (member) => | |
| 92 | input.primaryEnvironmentId !== null && | |
| 93 | member.environmentId !== input.primaryEnvironmentId, | |
| 94 | ) | |
| 100 | const remoteMembers = members.filter( | |
| 101 | (member) => | |
| 102 | input.primaryEnvironmentId !== null && member.environmentId !== input.primaryEnvironmentId, | |
| 103 | ); | |
| 104 | const remoteEnvironmentLabels = remoteMembers | |
| 95 | 105 | .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : [])) |
| 96 | 106 | .filter((label, index, labels) => labels.indexOf(label) === index); |
| 107 | const isDesktopLocal = input.isDesktopLocalEnvironment ?? (() => false); | |
| 108 | const allRemoteMembersAreDesktopLocal = | |
| 109 | remoteMembers.length > 0 && | |
| 110 | remoteMembers.every((member) => isDesktopLocal(member.environmentId)); | |
| 97 | 111 | |
| 98 | 112 | result.push({ |
| 99 | 113 | ...representative, |
| ... | ... | @@ -108,6 +122,7 @@ export function buildSidebarProjectSnapshots(input: { |
| 108 | 122 | groupedProjectCount: members.length, |
| 109 | 123 | environmentPresence: |
| 110 | 124 | hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", |
| 125 | allRemoteMembersAreDesktopLocal, | |
| 111 | 126 | memberProjects: members, |
| 112 | 127 | memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)), |
| 113 | 128 | remoteEnvironmentLabels, |
apps/web/src/state/desktopWslState.test.ts created+97| ... | ... | @@ -0,0 +1,97 @@ |
| 1 | import type { DesktopWslState } from "@t3tools/contracts"; | |
| 2 | import * as Cause from "effect/Cause"; | |
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 4 | import { AtomRegistry } from "effect/unstable/reactivity"; | |
| 5 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 6 | ||
| 7 | import { createDesktopWslStateAtom } from "./desktopWslState"; | |
| 8 | ||
| 9 | const wslState: DesktopWslState = { | |
| 10 | available: true, | |
| 11 | distro: null, | |
| 12 | distros: [ | |
| 13 | { | |
| 14 | isDefault: true, | |
| 15 | name: "Ubuntu", | |
| 16 | version: 2, | |
| 17 | }, | |
| 18 | ], | |
| 19 | enabled: true, | |
| 20 | preflightError: null, | |
| 21 | wslOnly: false, | |
| 22 | }; | |
| 23 | ||
| 24 | describe("desktopWslState", () => { | |
| 25 | it("retains the loaded snapshot when the settings screen remounts", async () => { | |
| 26 | const getWslState = vi.fn(async () => wslState); | |
| 27 | const atom = createDesktopWslStateAtom(() => ({ getWslState })); | |
| 28 | const registry = AtomRegistry.make(); | |
| 29 | ||
| 30 | const unmount = registry.mount(atom); | |
| 31 | await vi.waitFor(() => { | |
| 32 | expect(AsyncResult.value(registry.get(atom))).toEqual( | |
| 33 | expect.objectContaining({ _tag: "Some", value: wslState }), | |
| 34 | ); | |
| 35 | }); | |
| 36 | unmount(); | |
| 37 | ||
| 38 | const remount = registry.mount(atom); | |
| 39 | expect(AsyncResult.value(registry.get(atom))).toEqual( | |
| 40 | expect.objectContaining({ _tag: "Some", value: wslState }), | |
| 41 | ); | |
| 42 | expect(getWslState).toHaveBeenCalledTimes(1); | |
| 43 | ||
| 44 | remount(); | |
| 45 | registry.dispose(); | |
| 46 | }); | |
| 47 | ||
| 48 | it("retains the desktop bridge failure as the load error cause", async () => { | |
| 49 | const cause = new Error("wsl unavailable"); | |
| 50 | const atom = createDesktopWslStateAtom(() => ({ | |
| 51 | getWslState: async () => Promise.reject(cause), | |
| 52 | })); | |
| 53 | const registry = AtomRegistry.make(); | |
| 54 | registry.mount(atom); | |
| 55 | ||
| 56 | await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true)); | |
| 57 | const result = registry.get(atom); | |
| 58 | if (!AsyncResult.isFailure(result)) throw new Error("Expected WSL state load to fail."); | |
| 59 | ||
| 60 | expect(Cause.squash(result.cause)).toEqual( | |
| 61 | expect.objectContaining({ | |
| 62 | _tag: "DesktopWslStateLoadError", | |
| 63 | cause, | |
| 64 | }), | |
| 65 | ); | |
| 66 | registry.dispose(); | |
| 67 | }); | |
| 68 | ||
| 69 | it("replaces cached state with refreshed live desktop state", async () => { | |
| 70 | const refreshedState: DesktopWslState = { | |
| 71 | ...wslState, | |
| 72 | preflightError: "WSL backend stopped unexpectedly.", | |
| 73 | }; | |
| 74 | let currentState = wslState; | |
| 75 | const getWslState = vi.fn(async () => currentState); | |
| 76 | const atom = createDesktopWslStateAtom(() => ({ getWslState })); | |
| 77 | const registry = AtomRegistry.make(); | |
| 78 | registry.mount(atom); | |
| 79 | ||
| 80 | await vi.waitFor(() => { | |
| 81 | expect(AsyncResult.value(registry.get(atom))).toEqual( | |
| 82 | expect.objectContaining({ _tag: "Some", value: wslState }), | |
| 83 | ); | |
| 84 | }); | |
| 85 | ||
| 86 | currentState = refreshedState; | |
| 87 | registry.refresh(atom); | |
| 88 | ||
| 89 | await vi.waitFor(() => { | |
| 90 | expect(AsyncResult.value(registry.get(atom))).toEqual( | |
| 91 | expect.objectContaining({ _tag: "Some", value: refreshedState }), | |
| 92 | ); | |
| 93 | }); | |
| 94 | expect(getWslState).toHaveBeenCalledTimes(2); | |
| 95 | registry.dispose(); | |
| 96 | }); | |
| 97 | }); |
apps/web/src/state/desktopWslState.ts created+60| ... | ... | @@ -0,0 +1,60 @@ |
| 1 | import type { DesktopBridge, DesktopWslState } from "@t3tools/contracts"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Schema from "effect/Schema"; | |
| 4 | import { Atom } from "effect/unstable/reactivity"; | |
| 5 | ||
| 6 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | |
| 7 | ||
| 8 | const DESKTOP_WSL_STATE_STALE_TIME_MS = 30_000; | |
| 9 | ||
| 10 | type DesktopWslStateBridge = Pick<DesktopBridge, "getWslState">; | |
| 11 | ||
| 12 | class DesktopWslStateUnavailableError extends Schema.TaggedErrorClass<DesktopWslStateUnavailableError>()( | |
| 13 | "DesktopWslStateUnavailableError", | |
| 14 | {}, | |
| 15 | ) { | |
| 16 | override get message(): string { | |
| 17 | return "Desktop WSL state is unavailable."; | |
| 18 | } | |
| 19 | } | |
| 20 | ||
| 21 | class DesktopWslStateLoadError extends Schema.TaggedErrorClass<DesktopWslStateLoadError>()( | |
| 22 | "DesktopWslStateLoadError", | |
| 23 | { cause: Schema.Defect() }, | |
| 24 | ) { | |
| 25 | override get message(): string { | |
| 26 | return "Failed to load WSL state."; | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | function getDesktopWslStateBridge(): DesktopWslStateBridge | undefined { | |
| 31 | return typeof window === "undefined" ? undefined : window.desktopBridge; | |
| 32 | } | |
| 33 | ||
| 34 | export function createDesktopWslStateAtom(getBridge: () => DesktopWslStateBridge | undefined) { | |
| 35 | const loadDesktopWslState = Effect.fn("loadDesktopWslState")(function* () { | |
| 36 | const bridge = getBridge(); | |
| 37 | if (!bridge) { | |
| 38 | return yield* new DesktopWslStateUnavailableError(); | |
| 39 | } | |
| 40 | return yield* Effect.tryPromise({ | |
| 41 | try: (): Promise<DesktopWslState> => bridge.getWslState(), | |
| 42 | catch: (cause) => new DesktopWslStateLoadError({ cause }), | |
| 43 | }); | |
| 44 | }); | |
| 45 | ||
| 46 | return Atom.make(loadDesktopWslState()).pipe( | |
| 47 | Atom.swr({ | |
| 48 | staleTime: DESKTOP_WSL_STATE_STALE_TIME_MS, | |
| 49 | revalidateOnMount: true, | |
| 50 | }), | |
| 51 | Atom.keepAlive, | |
| 52 | Atom.withLabel("desktop:wsl-state:load"), | |
| 53 | ); | |
| 54 | } | |
| 55 | ||
| 56 | export const desktopWslStateAtom = createDesktopWslStateAtom(getDesktopWslStateBridge); | |
| 57 | ||
| 58 | export function refreshDesktopWslState(): void { | |
| 59 | appAtomRegistry.refresh(desktopWslStateAtom); | |
| 60 | } |
apps/web/src/wslPaths.test.ts created+297| ... | ... | @@ -0,0 +1,297 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | applyWslEnvironmentConfiguration, | |
| 5 | parseWslUncPath, | |
| 6 | resolveProjectPickerTarget, | |
| 7 | resolveWslProjectSelection, | |
| 8 | } from "./wslPaths"; | |
| 9 | ||
| 10 | describe("parseWslUncPath", () => { | |
| 11 | it("parses wsl.localhost UNC paths into distro and POSIX path", () => { | |
| 12 | expect(parseWslUncPath("\\\\wsl.localhost\\Ubuntu-22.04\\home\\josh\\repo")).toEqual({ | |
| 13 | distro: "Ubuntu-22.04", | |
| 14 | linuxPath: "/home/josh/repo", | |
| 15 | }); | |
| 16 | }); | |
| 17 | ||
| 18 | it("parses wsl$ UNC roots as distro root", () => { | |
| 19 | expect(parseWslUncPath("\\\\wsl$\\Debian")).toEqual({ | |
| 20 | distro: "Debian", | |
| 21 | linuxPath: "/", | |
| 22 | }); | |
| 23 | }); | |
| 24 | ||
| 25 | it("rejects non-WSL paths and invalid distro names", () => { | |
| 26 | expect(parseWslUncPath("C:\\Users\\Josh\\repo")).toBeNull(); | |
| 27 | expect(parseWslUncPath("\\\\wsl.localhost\\bad!name\\home")).toBeNull(); | |
| 28 | }); | |
| 29 | }); | |
| 30 | ||
| 31 | describe("resolveWslProjectSelection", () => { | |
| 32 | it("routes a UNC path to the matching WSL backend", () => { | |
| 33 | expect( | |
| 34 | resolveWslProjectSelection("\\\\wsl.localhost\\Ubuntu\\home\\theo\\repo", [ | |
| 35 | { environmentId: "env-debian", backendId: "wsl:Debian", runningDistro: null }, | |
| 36 | { environmentId: "env-ubuntu", backendId: "wsl:Ubuntu", runningDistro: null }, | |
| 37 | ]), | |
| 38 | ).toEqual({ | |
| 39 | distro: "Ubuntu", | |
| 40 | environmentId: "env-ubuntu", | |
| 41 | linuxPath: "/home/theo/repo", | |
| 42 | }); | |
| 43 | }); | |
| 44 | ||
| 45 | it("does not route to the only WSL backend when its distro is unknown", () => { | |
| 46 | expect( | |
| 47 | resolveWslProjectSelection("\\\\wsl.localhost\\Ubuntu\\home\\theo\\repo", [ | |
| 48 | { environmentId: "env-wsl", backendId: "wsl:default", runningDistro: null }, | |
| 49 | ]), | |
| 50 | ).toBeNull(); | |
| 51 | }); | |
| 52 | ||
| 53 | it("does not route to a sole WSL backend for a different distro", () => { | |
| 54 | expect( | |
| 55 | resolveWslProjectSelection("\\\\wsl.localhost\\Debian\\home\\theo\\repo", [ | |
| 56 | { environmentId: "env-ubuntu", backendId: "wsl:Ubuntu", runningDistro: null }, | |
| 57 | ]), | |
| 58 | ).toBeNull(); | |
| 59 | }); | |
| 60 | ||
| 61 | it("does not guess when multiple WSL backends fail to match", () => { | |
| 62 | expect( | |
| 63 | resolveWslProjectSelection("\\\\wsl.localhost\\Fedora\\home\\theo\\repo", [ | |
| 64 | { environmentId: "env-debian", backendId: "wsl:Debian", runningDistro: null }, | |
| 65 | { environmentId: "env-ubuntu", backendId: "wsl:Ubuntu", runningDistro: null }, | |
| 66 | ]), | |
| 67 | ).toBeNull(); | |
| 68 | }); | |
| 69 | ||
| 70 | it("routes a default backend only to the distro used by its running process", () => { | |
| 71 | const candidates = [ | |
| 72 | { environmentId: "env-wsl", backendId: "wsl:default", runningDistro: "Debian" }, | |
| 73 | ]; | |
| 74 | ||
| 75 | expect( | |
| 76 | resolveWslProjectSelection("\\\\wsl.localhost\\Debian\\home\\theo\\repo", candidates), | |
| 77 | ).toEqual({ | |
| 78 | distro: "Debian", | |
| 79 | environmentId: "env-wsl", | |
| 80 | linuxPath: "/home/theo/repo", | |
| 81 | }); | |
| 82 | expect( | |
| 83 | resolveWslProjectSelection("\\\\wsl.localhost\\Ubuntu\\home\\theo\\repo", candidates), | |
| 84 | ).toBeNull(); | |
| 85 | }); | |
| 86 | }); | |
| 87 | ||
| 88 | describe("applyWslEnvironmentConfiguration", () => { | |
| 89 | const ubuntuConfiguration = { | |
| 90 | enabled: true, | |
| 91 | wslOnly: false, | |
| 92 | distro: null, | |
| 93 | distros: [ | |
| 94 | { name: "Debian", isDefault: false }, | |
| 95 | { name: "Ubuntu", isDefault: true }, | |
| 96 | ], | |
| 97 | }; | |
| 98 | ||
| 99 | it("preserves a live default-distro backend instance id", () => { | |
| 100 | expect( | |
| 101 | applyWslEnvironmentConfiguration( | |
| 102 | [ | |
| 103 | { | |
| 104 | environmentId: "env-wsl", | |
| 105 | backendId: "wsl:default", | |
| 106 | runningDistro: "Debian", | |
| 107 | }, | |
| 108 | ], | |
| 109 | "env-primary", | |
| 110 | ubuntuConfiguration, | |
| 111 | ), | |
| 112 | ).toEqual([{ environmentId: "env-wsl", backendId: "wsl:default", runningDistro: "Debian" }]); | |
| 113 | }); | |
| 114 | ||
| 115 | it("does not replace a live default backend's running distro from current configuration", () => { | |
| 116 | const candidates = applyWslEnvironmentConfiguration( | |
| 117 | [{ environmentId: "env-wsl", backendId: "wsl:default", runningDistro: "Debian" }], | |
| 118 | "env-primary", | |
| 119 | ubuntuConfiguration, | |
| 120 | ); | |
| 121 | ||
| 122 | expect( | |
| 123 | resolveWslProjectSelection("\\\\wsl.localhost\\Ubuntu\\home\\theo\\repo", candidates), | |
| 124 | ).toBeNull(); | |
| 125 | expect( | |
| 126 | resolveWslProjectSelection("\\\\wsl.localhost\\Debian\\home\\theo\\repo", candidates), | |
| 127 | ).toEqual({ | |
| 128 | distro: "Debian", | |
| 129 | environmentId: "env-wsl", | |
| 130 | linuxPath: "/home/theo/repo", | |
| 131 | }); | |
| 132 | }); | |
| 133 | ||
| 134 | it("represents an explicitly configured WSL-only primary by its distro", () => { | |
| 135 | expect( | |
| 136 | applyWslEnvironmentConfiguration([], "env-primary", { | |
| 137 | ...ubuntuConfiguration, | |
| 138 | wslOnly: true, | |
| 139 | distro: "ubuntu", | |
| 140 | }), | |
| 141 | ).toEqual([{ environmentId: "env-primary", backendId: "wsl:Ubuntu", runningDistro: "Ubuntu" }]); | |
| 142 | }); | |
| 143 | ||
| 144 | it("preserves default tracking for a WSL-only primary", () => { | |
| 145 | expect( | |
| 146 | applyWslEnvironmentConfiguration([], "env-primary", { | |
| 147 | ...ubuntuConfiguration, | |
| 148 | wslOnly: true, | |
| 149 | }), | |
| 150 | ).toEqual([{ environmentId: "env-primary", backendId: "wsl:default", runningDistro: null }]); | |
| 151 | }); | |
| 152 | ||
| 153 | it("uses the live primary distro for a default-tracking WSL-only primary", () => { | |
| 154 | const candidates = applyWslEnvironmentConfiguration( | |
| 155 | [], | |
| 156 | "env-primary", | |
| 157 | { | |
| 158 | ...ubuntuConfiguration, | |
| 159 | wslOnly: true, | |
| 160 | distros: [], | |
| 161 | }, | |
| 162 | "Ubuntu", | |
| 163 | ); | |
| 164 | ||
| 165 | expect(candidates).toEqual([ | |
| 166 | { environmentId: "env-primary", backendId: "wsl:default", runningDistro: "Ubuntu" }, | |
| 167 | ]); | |
| 168 | expect( | |
| 169 | resolveWslProjectSelection("\\\\wsl.localhost\\Ubuntu\\home\\theo\\repo", candidates), | |
| 170 | ).toEqual({ | |
| 171 | distro: "Ubuntu", | |
| 172 | environmentId: "env-primary", | |
| 173 | linuxPath: "/home/theo/repo", | |
| 174 | }); | |
| 175 | }); | |
| 176 | ||
| 177 | it("keeps a configured distro authoritative when discovery does not contain it", () => { | |
| 178 | expect( | |
| 179 | applyWslEnvironmentConfiguration([], "env-primary", { | |
| 180 | ...ubuntuConfiguration, | |
| 181 | wslOnly: true, | |
| 182 | distro: "Fedora", | |
| 183 | }), | |
| 184 | ).toEqual([{ environmentId: "env-primary", backendId: "wsl:Fedora", runningDistro: "Fedora" }]); | |
| 185 | }); | |
| 186 | ||
| 187 | it("does not synthesize a backend for an empty configured distro name", () => { | |
| 188 | expect( | |
| 189 | applyWslEnvironmentConfiguration([], "env-primary", { | |
| 190 | ...ubuntuConfiguration, | |
| 191 | wslOnly: true, | |
| 192 | distro: " ", | |
| 193 | }), | |
| 194 | ).toEqual([]); | |
| 195 | }); | |
| 196 | }); | |
| 197 | ||
| 198 | describe("resolveProjectPickerTarget", () => { | |
| 199 | const ubuntuConfiguration = { | |
| 200 | enabled: true, | |
| 201 | wslOnly: true, | |
| 202 | distro: "Ubuntu-22.04", | |
| 203 | distros: [ | |
| 204 | { name: "Debian", isDefault: true }, | |
| 205 | { name: "Ubuntu-22.04", isDefault: false }, | |
| 206 | ], | |
| 207 | }; | |
| 208 | ||
| 209 | it("routes a WSL-only primary picker to its configured distro", () => { | |
| 210 | expect( | |
| 211 | resolveProjectPickerTarget({ | |
| 212 | browseEnvironmentId: "env-primary", | |
| 213 | primaryEnvironmentId: "env-primary", | |
| 214 | desktopInstanceId: null, | |
| 215 | wslConfiguration: ubuntuConfiguration, | |
| 216 | }), | |
| 217 | ).toBe("wsl:Ubuntu-22.04"); | |
| 218 | }); | |
| 219 | ||
| 220 | it("routes a configured distro while discovery is temporarily empty", () => { | |
| 221 | expect( | |
| 222 | resolveProjectPickerTarget({ | |
| 223 | browseEnvironmentId: "env-primary", | |
| 224 | primaryEnvironmentId: "env-primary", | |
| 225 | desktopInstanceId: null, | |
| 226 | wslConfiguration: { ...ubuntuConfiguration, distro: "ubuntu-22.04", distros: [] }, | |
| 227 | }), | |
| 228 | ).toBe("wsl:ubuntu-22.04"); | |
| 229 | }); | |
| 230 | ||
| 231 | it("uses installed casing when discovery finds the configured distro", () => { | |
| 232 | expect( | |
| 233 | resolveProjectPickerTarget({ | |
| 234 | browseEnvironmentId: "env-primary", | |
| 235 | primaryEnvironmentId: "env-primary", | |
| 236 | desktopInstanceId: null, | |
| 237 | wslConfiguration: { ...ubuntuConfiguration, distro: "ubuntu-22.04" }, | |
| 238 | }), | |
| 239 | ).toBe("wsl:Ubuntu-22.04"); | |
| 240 | }); | |
| 241 | ||
| 242 | it("routes a default-tracking WSL-only primary picker through the live sentinel", () => { | |
| 243 | expect( | |
| 244 | resolveProjectPickerTarget({ | |
| 245 | browseEnvironmentId: "env-primary", | |
| 246 | primaryEnvironmentId: "env-primary", | |
| 247 | desktopInstanceId: null, | |
| 248 | wslConfiguration: { ...ubuntuConfiguration, distro: null }, | |
| 249 | }), | |
| 250 | ).toBe("wsl:default"); | |
| 251 | }); | |
| 252 | ||
| 253 | it("routes a default-tracking picker when the distro catalog has no default", () => { | |
| 254 | expect( | |
| 255 | resolveProjectPickerTarget({ | |
| 256 | browseEnvironmentId: "env-primary", | |
| 257 | primaryEnvironmentId: "env-primary", | |
| 258 | desktopInstanceId: null, | |
| 259 | wslConfiguration: { | |
| 260 | ...ubuntuConfiguration, | |
| 261 | distro: null, | |
| 262 | distros: [{ name: "Ubuntu-22.04", isDefault: false }], | |
| 263 | }, | |
| 264 | }), | |
| 265 | ).toBe("wsl:default"); | |
| 266 | ||
| 267 | expect( | |
| 268 | resolveProjectPickerTarget({ | |
| 269 | browseEnvironmentId: "env-primary", | |
| 270 | primaryEnvironmentId: "env-primary", | |
| 271 | desktopInstanceId: null, | |
| 272 | wslConfiguration: { ...ubuntuConfiguration, distro: null, distros: [] }, | |
| 273 | }), | |
| 274 | ).toBe("wsl:default"); | |
| 275 | }); | |
| 276 | ||
| 277 | it("preserves combo-mode routing for primary and WSL backends", () => { | |
| 278 | const comboConfiguration = { ...ubuntuConfiguration, wslOnly: false }; | |
| 279 | ||
| 280 | expect( | |
| 281 | resolveProjectPickerTarget({ | |
| 282 | browseEnvironmentId: "env-primary", | |
| 283 | primaryEnvironmentId: "env-primary", | |
| 284 | desktopInstanceId: null, | |
| 285 | wslConfiguration: comboConfiguration, | |
| 286 | }), | |
| 287 | ).toBeNull(); | |
| 288 | expect( | |
| 289 | resolveProjectPickerTarget({ | |
| 290 | browseEnvironmentId: "env-wsl", | |
| 291 | primaryEnvironmentId: "env-primary", | |
| 292 | desktopInstanceId: "wsl:Ubuntu-22.04", | |
| 293 | wslConfiguration: comboConfiguration, | |
| 294 | }), | |
| 295 | ).toBe("wsl:Ubuntu-22.04"); | |
| 296 | }); | |
| 297 | }); |
apps/web/src/wslPaths.ts created+159| ... | ... | @@ -0,0 +1,159 @@ |
| 1 | export interface WslUncPath { | |
| 2 | readonly distro: string; | |
| 3 | readonly linuxPath: string; | |
| 4 | } | |
| 5 | ||
| 6 | export interface WslEnvironmentCandidate<TEnvironmentId extends string = string> { | |
| 7 | readonly environmentId: TEnvironmentId; | |
| 8 | readonly backendId: string; | |
| 9 | readonly runningDistro: string | null; | |
| 10 | } | |
| 11 | ||
| 12 | export interface WslEnvironmentConfiguration { | |
| 13 | readonly enabled: boolean; | |
| 14 | readonly wslOnly: boolean; | |
| 15 | readonly distro: string | null; | |
| 16 | readonly distros: ReadonlyArray<{ | |
| 17 | readonly name: string; | |
| 18 | readonly isDefault: boolean; | |
| 19 | }>; | |
| 20 | } | |
| 21 | ||
| 22 | export interface WslProjectSelection<TEnvironmentId extends string = string> extends WslUncPath { | |
| 23 | readonly environmentId: TEnvironmentId; | |
| 24 | } | |
| 25 | ||
| 26 | const WSL_UNC_PREFIXES = ["\\\\wsl.localhost\\", "\\\\wsl$\\"] as const; | |
| 27 | const WSL_DISTRO_NAME_PATTERN = /^[A-Za-z0-9._-]+$/; | |
| 28 | const WSL_DEFAULT_BACKEND_ID = "wsl:default"; | |
| 29 | ||
| 30 | export function parseWslUncPath(input: string): WslUncPath | null { | |
| 31 | const normalized = input.trim().replaceAll("/", "\\"); | |
| 32 | const prefix = WSL_UNC_PREFIXES.find((candidate) => | |
| 33 | normalized.toLowerCase().startsWith(candidate.toLowerCase()), | |
| 34 | ); | |
| 35 | if (!prefix) { | |
| 36 | return null; | |
| 37 | } | |
| 38 | ||
| 39 | const rest = normalized.slice(prefix.length); | |
| 40 | const segments = rest.split("\\").filter((segment) => segment.length > 0); | |
| 41 | const distro = segments.shift(); | |
| 42 | if (!distro || !WSL_DISTRO_NAME_PATTERN.test(distro)) { | |
| 43 | return null; | |
| 44 | } | |
| 45 | ||
| 46 | return { | |
| 47 | distro, | |
| 48 | linuxPath: segments.length === 0 ? "/" : `/${segments.join("/")}`, | |
| 49 | }; | |
| 50 | } | |
| 51 | ||
| 52 | export function resolveWslProjectSelection<TEnvironmentId extends string>( | |
| 53 | input: string, | |
| 54 | candidates: ReadonlyArray<WslEnvironmentCandidate<TEnvironmentId>>, | |
| 55 | ): WslProjectSelection<TEnvironmentId> | null { | |
| 56 | const parsed = parseWslUncPath(input); | |
| 57 | if (!parsed) { | |
| 58 | return null; | |
| 59 | } | |
| 60 | ||
| 61 | const exact = candidates.find((candidate) => { | |
| 62 | if (!candidate.backendId.startsWith("wsl:")) { | |
| 63 | return false; | |
| 64 | } | |
| 65 | ||
| 66 | const backendDistro = candidate.backendId.slice("wsl:".length); | |
| 67 | const runningDistro = | |
| 68 | candidate.runningDistro ?? | |
| 69 | (backendDistro.length > 0 && backendDistro.toLowerCase() !== "default" | |
| 70 | ? backendDistro | |
| 71 | : null); | |
| 72 | return runningDistro?.toLowerCase() === parsed.distro.toLowerCase(); | |
| 73 | }); | |
| 74 | return exact ? { ...parsed, environmentId: exact.environmentId } : null; | |
| 75 | } | |
| 76 | ||
| 77 | interface ConfiguredWslBackend { | |
| 78 | readonly backendId: string; | |
| 79 | readonly runningDistro: string | null; | |
| 80 | } | |
| 81 | ||
| 82 | function resolveConfiguredWslBackend( | |
| 83 | configuration: WslEnvironmentConfiguration | null, | |
| 84 | ): ConfiguredWslBackend | null { | |
| 85 | if (!configuration) { | |
| 86 | return null; | |
| 87 | } | |
| 88 | ||
| 89 | if (configuration.distro === null) { | |
| 90 | return { backendId: WSL_DEFAULT_BACKEND_ID, runningDistro: null }; | |
| 91 | } | |
| 92 | ||
| 93 | const configuredDistro = configuration.distro.trim(); | |
| 94 | if (configuredDistro.length === 0) { | |
| 95 | return null; | |
| 96 | } | |
| 97 | ||
| 98 | const installedDistro = configuration.distros.find( | |
| 99 | (distro) => distro.name.toLowerCase() === configuredDistro.toLowerCase(), | |
| 100 | ); | |
| 101 | const resolvedDistro = installedDistro?.name ?? configuredDistro; | |
| 102 | return { backendId: `wsl:${resolvedDistro}`, runningDistro: resolvedDistro }; | |
| 103 | } | |
| 104 | ||
| 105 | export function resolveProjectPickerTarget<TEnvironmentId extends string>(input: { | |
| 106 | readonly browseEnvironmentId: TEnvironmentId | null; | |
| 107 | readonly primaryEnvironmentId: TEnvironmentId | null; | |
| 108 | readonly desktopInstanceId: string | null; | |
| 109 | readonly wslConfiguration: WslEnvironmentConfiguration | null; | |
| 110 | }): string | null { | |
| 111 | if (input.desktopInstanceId !== null) { | |
| 112 | return input.desktopInstanceId; | |
| 113 | } | |
| 114 | ||
| 115 | if ( | |
| 116 | input.browseEnvironmentId === null || | |
| 117 | input.browseEnvironmentId !== input.primaryEnvironmentId || | |
| 118 | !input.wslConfiguration?.enabled || | |
| 119 | !input.wslConfiguration.wslOnly | |
| 120 | ) { | |
| 121 | return null; | |
| 122 | } | |
| 123 | ||
| 124 | return resolveConfiguredWslBackend(input.wslConfiguration)?.backendId ?? null; | |
| 125 | } | |
| 126 | ||
| 127 | export function applyWslEnvironmentConfiguration<TEnvironmentId extends string>( | |
| 128 | candidates: ReadonlyArray<WslEnvironmentCandidate<TEnvironmentId>>, | |
| 129 | primaryEnvironmentId: TEnvironmentId | null, | |
| 130 | configuration: WslEnvironmentConfiguration | null, | |
| 131 | primaryRunningDistro: string | null = null, | |
| 132 | ): ReadonlyArray<WslEnvironmentCandidate<TEnvironmentId>> { | |
| 133 | if (!configuration) { | |
| 134 | return candidates; | |
| 135 | } | |
| 136 | ||
| 137 | const configuredBackend = resolveConfiguredWslBackend(configuration); | |
| 138 | if (!configuredBackend) { | |
| 139 | return candidates; | |
| 140 | } | |
| 141 | ||
| 142 | if ( | |
| 143 | configuration.enabled && | |
| 144 | configuration.wslOnly && | |
| 145 | primaryEnvironmentId !== null && | |
| 146 | !candidates.some((candidate) => candidate.environmentId === primaryEnvironmentId) | |
| 147 | ) { | |
| 148 | return [ | |
| 149 | ...candidates, | |
| 150 | { | |
| 151 | environmentId: primaryEnvironmentId, | |
| 152 | ...configuredBackend, | |
| 153 | runningDistro: primaryRunningDistro ?? configuredBackend.runningDistro, | |
| 154 | }, | |
| 155 | ]; | |
| 156 | } | |
| 157 | ||
| 158 | return candidates; | |
| 159 | } |
packages/client-runtime/src/connection/catalog.ts+15| ... | ... | @@ -89,6 +89,21 @@ export const ConnectionRegistration = Schema.Union([ |
| 89 | 89 | ]); |
| 90 | 90 | export type ConnectionRegistration = typeof ConnectionRegistration.Type; |
| 91 | 91 | |
| 92 | /** | |
| 93 | * Platform-managed registrations are reconciled from the host (the desktop | |
| 94 | * bootstrap IPC) rather than persisted by the user. They cover the primary | |
| 95 | * local environment plus any additional desktop-local backends running | |
| 96 | * alongside it (e.g. a parallel WSL backend). The primary stays on same-origin | |
| 97 | * cookie auth (`PrimaryConnectionRegistration`); secondary local backends live | |
| 98 | * on a separate loopback origin and authenticate with a bearer token minted | |
| 99 | * from their bootstrap credential (`BearerConnectionRegistration`). | |
| 100 | */ | |
| 101 | export const PlatformConnectionRegistration = Schema.Union([ | |
| 102 | PrimaryConnectionRegistration, | |
| 103 | BearerConnectionRegistration, | |
| 104 | ]); | |
| 105 | export type PlatformConnectionRegistration = typeof PlatformConnectionRegistration.Type; | |
| 106 | ||
| 92 | 107 | export function connectionRegistrationTarget( |
| 93 | 108 | registration: ConnectionRegistration | PrimaryConnectionRegistration, |
| 94 | 109 | ): ConnectionTarget { |
packages/client-runtime/src/connection/layer.ts+1-1| ... | ... | @@ -35,7 +35,7 @@ const connectionStartupLayer = Layer.effectDiscard( |
| 35 | 35 | const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; |
| 36 | 36 | yield* registry.start; |
| 37 | 37 | yield* platformSource.registrations.pipe( |
| 38 | Stream.runForEach(registry.registerPlatform), | |
| 38 | Stream.runForEach(registry.reconcilePlatform), | |
| 39 | 39 | Effect.forkScoped, |
| 40 | 40 | ); |
| 41 | 41 | }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), |
packages/client-runtime/src/connection/registry.ts+131-34| ... | ... | @@ -16,10 +16,12 @@ import * as ClientCapabilities from "../platform/capabilities.ts"; |
| 16 | 16 | import { |
| 17 | 17 | type ConnectionCatalogEntry, |
| 18 | 18 | type ConnectionRegistration, |
| 19 | type PlatformConnectionRegistration, | |
| 19 | 20 | type PrimaryConnectionRegistration, |
| 20 | 21 | SshConnectionProfile, |
| 21 | 22 | connectionRegistrationCatalogEntry, |
| 22 | 23 | } from "./catalog.ts"; |
| 24 | import * as ConnectionCredentialStore from "./credentialStore.ts"; | |
| 23 | 25 | import * as ConnectionProfileStore from "./profileStore.ts"; |
| 24 | 26 | import * as Connectivity from "./connectivity.ts"; |
| 25 | 27 | import type { |
| ... | ... | @@ -69,6 +71,9 @@ export class EnvironmentRegistry extends Context.Service< |
| 69 | 71 | registration: ConnectionRegistration, |
| 70 | 72 | ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>; |
| 71 | 73 | readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect<void>; |
| 74 | readonly reconcilePlatform: ( | |
| 75 | registrations: ReadonlyArray<PlatformConnectionRegistration>, | |
| 76 | ) => Effect.Effect<void>; | |
| 72 | 77 | readonly remove: ( |
| 73 | 78 | environmentId: EnvironmentId, |
| 74 | 79 | ) => Effect.Effect< |
| ... | ... | @@ -126,6 +131,7 @@ export const make = Effect.gen(function* () { |
| 126 | 131 | const cache = yield* Persistence.EnvironmentCacheStore; |
| 127 | 132 | const ownedDataCleanup = yield* Persistence.EnvironmentOwnedDataCleanup; |
| 128 | 133 | const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; |
| 134 | const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; | |
| 129 | 135 | const connectivity = yield* Connectivity.Connectivity; |
| 130 | 136 | const driver = yield* ConnectionDriver.ConnectionDriver; |
| 131 | 137 | const wakeups = yield* ConnectionWakeups.ConnectionWakeups; |
| ... | ... | @@ -402,47 +408,137 @@ export const make = Effect.gen(function* () { |
| 402 | 408 | ); |
| 403 | 409 | }); |
| 404 | 410 | |
| 405 | const registerPlatform = Effect.fn("EnvironmentRegistry.registerPlatform")(function* ( | |
| 406 | registration: PrimaryConnectionRegistration, | |
| 407 | ) { | |
| 408 | const entry = connectionRegistrationCatalogEntry(registration); | |
| 409 | const target = entry.target; | |
| 410 | yield* withLeaseLock( | |
| 411 | target.environmentId, | |
| 412 | Effect.gen(function* () { | |
| 413 | yield* Ref.update(platformEnvironmentIds, (current) => { | |
| 414 | const next = new Set(current); | |
| 415 | next.add(target.environmentId); | |
| 416 | return next; | |
| 417 | }); | |
| 411 | const installPlatformRegistration = Effect.fn("EnvironmentRegistry.installPlatformRegistration")( | |
| 412 | function* (registration: PlatformConnectionRegistration) { | |
| 413 | const entry = connectionRegistrationCatalogEntry(registration); | |
| 414 | const target = entry.target; | |
| 415 | yield* withLeaseLock( | |
| 416 | target.environmentId, | |
| 417 | Effect.gen(function* () { | |
| 418 | yield* Ref.update(platformEnvironmentIds, (current) => { | |
| 419 | const next = new Set(current); | |
| 420 | next.add(target.environmentId); | |
| 421 | return next; | |
| 422 | }); | |
| 418 | 423 | |
| 419 | const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get( | |
| 420 | target.environmentId, | |
| 421 | ); | |
| 422 | if (persistedTarget !== undefined) { | |
| 423 | yield* registrations.remove(persistedTarget).pipe( | |
| 424 | Effect.tap(() => | |
| 425 | Ref.update(persistedTargetsByEnvironment, (current) => { | |
| 426 | const next = new Map(current); | |
| 427 | next.delete(target.environmentId); | |
| 428 | return next; | |
| 429 | }), | |
| 430 | ), | |
| 431 | Effect.catch((error) => | |
| 432 | Effect.logWarning( | |
| 433 | "Could not remove a persisted registration shadowed by the primary environment.", | |
| 434 | { | |
| 424 | // Secondary desktop-local backends (e.g. a parallel WSL backend) live | |
| 425 | // on their own loopback origin, so they authenticate with a bearer | |
| 426 | // token instead of the primary's same-origin cookie. Stash it where | |
| 427 | // the resolver's bearer broker looks it up. | |
| 428 | if (registration._tag === "BearerConnectionRegistration") { | |
| 429 | yield* credentials.put(registration.target.connectionId, registration.credential).pipe( | |
| 430 | Effect.catch((error) => | |
| 431 | Effect.logWarning("Could not store the platform bearer credential.", { | |
| 435 | 432 | environmentId: target.environmentId, |
| 436 | 433 | error, |
| 437 | }, | |
| 434 | }), | |
| 438 | 435 | ), |
| 439 | ), | |
| 436 | ); | |
| 437 | } | |
| 438 | ||
| 439 | const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get( | |
| 440 | target.environmentId, | |
| 440 | 441 | ); |
| 441 | } | |
| 442 | if (persistedTarget !== undefined) { | |
| 443 | yield* registrations.remove(persistedTarget).pipe( | |
| 444 | Effect.tap(() => | |
| 445 | Ref.update(persistedTargetsByEnvironment, (current) => { | |
| 446 | const next = new Map(current); | |
| 447 | next.delete(target.environmentId); | |
| 448 | return next; | |
| 449 | }), | |
| 450 | ), | |
| 451 | Effect.catch((error) => | |
| 452 | Effect.logWarning( | |
| 453 | "Could not remove a persisted registration shadowed by a platform environment.", | |
| 454 | { | |
| 455 | environmentId: target.environmentId, | |
| 456 | error, | |
| 457 | }, | |
| 458 | ), | |
| 459 | ), | |
| 460 | ); | |
| 461 | } | |
| 442 | 462 | |
| 443 | yield* installEntryLocked(entry, { retainEquivalentRuntime: true }); | |
| 444 | }), | |
| 463 | yield* installEntryLocked(entry, { retainEquivalentRuntime: true }); | |
| 464 | }), | |
| 465 | ); | |
| 466 | }, | |
| 467 | ); | |
| 468 | ||
| 469 | // Tear down a platform-managed environment that the host no longer reports | |
| 470 | // (e.g. the user turned the parallel WSL backend off). Platform environments | |
| 471 | // bypass the user-facing `remove` guard since they are reconciled from the | |
| 472 | // bootstrap rather than removed by hand. | |
| 473 | const removePlatformEnvironment = Effect.fn("EnvironmentRegistry.removePlatformEnvironment")( | |
| 474 | function* (environmentId: EnvironmentId) { | |
| 475 | yield* withLeaseLock( | |
| 476 | environmentId, | |
| 477 | Effect.gen(function* () { | |
| 478 | const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); | |
| 479 | yield* Ref.update(platformEnvironmentIds, (current) => { | |
| 480 | const next = new Set(current); | |
| 481 | next.delete(environmentId); | |
| 482 | return next; | |
| 483 | }); | |
| 484 | yield* closeServiceScope(environmentId); | |
| 485 | yield* SubscriptionRef.update(entries, (current) => { | |
| 486 | const next = new Map(current); | |
| 487 | next.delete(environmentId); | |
| 488 | return next; | |
| 489 | }); | |
| 490 | if (entry !== undefined && entry.target._tag === "BearerConnectionTarget") { | |
| 491 | yield* credentials.remove(entry.target.connectionId).pipe( | |
| 492 | Effect.catch((error) => | |
| 493 | Effect.logWarning("Could not clear the platform bearer credential.", { | |
| 494 | environmentId, | |
| 495 | error, | |
| 496 | }), | |
| 497 | ), | |
| 498 | ); | |
| 499 | } | |
| 500 | yield* Effect.all( | |
| 501 | [ | |
| 502 | cache.clear(environmentId).pipe( | |
| 503 | Effect.catch((error) => | |
| 504 | Effect.logWarning("Could not clear cached environment data after removal.", { | |
| 505 | environmentId, | |
| 506 | error, | |
| 507 | }), | |
| 508 | ), | |
| 509 | ), | |
| 510 | ownedDataCleanup.clear(environmentId), | |
| 511 | ], | |
| 512 | { concurrency: "unbounded", discard: true }, | |
| 513 | ); | |
| 514 | }), | |
| 515 | ); | |
| 516 | }, | |
| 517 | ); | |
| 518 | ||
| 519 | const registerPlatform = Effect.fn("EnvironmentRegistry.registerPlatform")(function* ( | |
| 520 | registration: PrimaryConnectionRegistration, | |
| 521 | ) { | |
| 522 | yield* installPlatformRegistration(registration); | |
| 523 | }); | |
| 524 | ||
| 525 | // Reconcile the full set of platform-managed environments against what the | |
| 526 | // host currently reports: add/refresh the desired ones and tear down any | |
| 527 | // platform environment that disappeared (WSL toggled off, distro switched). | |
| 528 | const reconcilePlatform = Effect.fn("EnvironmentRegistry.reconcilePlatform")(function* ( | |
| 529 | platformRegistrations: ReadonlyArray<PlatformConnectionRegistration>, | |
| 530 | ) { | |
| 531 | const desiredIds = new Set( | |
| 532 | platformRegistrations.map((registration) => registration.target.environmentId), | |
| 533 | ); | |
| 534 | const currentPlatformIds = yield* Ref.get(platformEnvironmentIds); | |
| 535 | yield* Effect.forEach( | |
| 536 | currentPlatformIds, | |
| 537 | (environmentId) => | |
| 538 | desiredIds.has(environmentId) ? Effect.void : removePlatformEnvironment(environmentId), | |
| 539 | { discard: true }, | |
| 445 | 540 | ); |
| 541 | yield* Effect.forEach(platformRegistrations, installPlatformRegistration, { discard: true }); | |
| 446 | 542 | }); |
| 447 | 543 | |
| 448 | 544 | const remove = Effect.fn("EnvironmentRegistry.remove")(function* (environmentId: EnvironmentId) { |
| ... | ... | @@ -567,6 +663,7 @@ export const make = Effect.gen(function* () { |
| 567 | 663 | start, |
| 568 | 664 | register, |
| 569 | 665 | registerPlatform, |
| 666 | reconcilePlatform, | |
| 570 | 667 | remove, |
| 571 | 668 | removeRelayEnvironments, |
| 572 | 669 | retryNow, |
packages/client-runtime/src/platform/source.ts+6-2| ... | ... | @@ -1,11 +1,15 @@ |
| 1 | 1 | import * as Context from "effect/Context"; |
| 2 | 2 | import type * as Stream from "effect/Stream"; |
| 3 | 3 | |
| 4 | import type { PrimaryConnectionRegistration } from "../connection/catalog.ts"; | |
| 4 | import type { PlatformConnectionRegistration } from "../connection/catalog.ts"; | |
| 5 | 5 | |
| 6 | 6 | export class PlatformConnectionSource extends Context.Service< |
| 7 | 7 | PlatformConnectionSource, |
| 8 | 8 | { |
| 9 | readonly registrations: Stream.Stream<PrimaryConnectionRegistration>; | |
| 9 | // Each emission is the full current set of platform-managed environments | |
| 10 | // (the primary local environment plus any desktop-local backends running | |
| 11 | // alongside it). The registry reconciles the set, so the source can drive | |
| 12 | // both additions and removals by re-emitting. | |
| 13 | readonly registrations: Stream.Stream<ReadonlyArray<PlatformConnectionRegistration>>; | |
| 10 | 14 | } |
| 11 | 15 | >()("@t3tools/client-runtime/platform/source/PlatformConnectionSource") {} |
packages/contracts/src/desktopBootstrap.ts+4-1| ... | ... | @@ -6,7 +6,10 @@ export const DesktopBackendBootstrap = Schema.Struct({ |
| 6 | 6 | mode: Schema.Literal("desktop"), |
| 7 | 7 | noBrowser: Schema.Boolean, |
| 8 | 8 | port: PortSchema, |
| 9 | t3Home: Schema.String, | |
| 9 | // Omitted when the desktop launches the backend inside WSL, since the | |
| 10 | // Windows-side baseDir maps to /mnt/c/... and the Linux side should use its | |
| 11 | // own home directory instead. | |
| 12 | t3Home: Schema.optional(Schema.String), | |
| 10 | 13 | host: Schema.String, |
| 11 | 14 | desktopBootstrapToken: Schema.String, |
| 12 | 15 | tailscaleServeEnabled: Schema.Boolean, |
packages/contracts/src/ipc.test.ts created+38| ... | ... | @@ -0,0 +1,38 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { DesktopEnvironmentBootstrapSchema } from "./ipc.ts"; | |
| 5 | ||
| 6 | describe("DesktopEnvironmentBootstrapSchema", () => { | |
| 7 | const decode = Schema.decodeUnknownSync(DesktopEnvironmentBootstrapSchema); | |
| 8 | ||
| 9 | it("preserves the concrete running distro separately from the backend id", () => { | |
| 10 | expect( | |
| 11 | decode({ | |
| 12 | id: "wsl:default", | |
| 13 | label: "WSL (Ubuntu)", | |
| 14 | runningDistro: "Ubuntu", | |
| 15 | httpBaseUrl: "http://127.0.0.1:3774/", | |
| 16 | wsBaseUrl: "ws://127.0.0.1:3774/", | |
| 17 | }), | |
| 18 | ).toEqual({ | |
| 19 | id: "wsl:default", | |
| 20 | label: "WSL (Ubuntu)", | |
| 21 | runningDistro: "Ubuntu", | |
| 22 | httpBaseUrl: "http://127.0.0.1:3774/", | |
| 23 | wsBaseUrl: "ws://127.0.0.1:3774/", | |
| 24 | }); | |
| 25 | }); | |
| 26 | ||
| 27 | it("allows non-running and non-WSL bootstraps to report no running distro", () => { | |
| 28 | expect( | |
| 29 | decode({ | |
| 30 | id: "primary", | |
| 31 | label: "Windows", | |
| 32 | runningDistro: null, | |
| 33 | httpBaseUrl: null, | |
| 34 | wsBaseUrl: null, | |
| 35 | }).runningDistro, | |
| 36 | ).toBeNull(); | |
| 37 | }); | |
| 38 | }); |
packages/contracts/src/ipc.ts+73-1| ... | ... | @@ -259,15 +259,31 @@ export const DesktopUpdateCheckResultSchema = Schema.Struct({ |
| 259 | 259 | state: DesktopUpdateStateSchema, |
| 260 | 260 | }); |
| 261 | 261 | |
| 262 | // Stable id for the Windows-native primary backend. Desktop side wraps | |
| 263 | // this with a brand inside DesktopBackendManager; web side keeps it as | |
| 264 | // a plain string so the env-runtime can compare against it without | |
| 265 | // importing brand machinery from the desktop package. | |
| 266 | export const PRIMARY_LOCAL_ENVIRONMENT_ID = "primary"; | |
| 267 | ||
| 262 | 268 | export interface DesktopEnvironmentBootstrap { |
| 269 | // Stable backend instance id (e.g. "primary" or "wsl:ubuntu"). The | |
| 270 | // web env runtime keys local environments off this so projects | |
| 271 | // routed to a specific backend reopen against the same one. | |
| 272 | id: string; | |
| 263 | 273 | label: string; |
| 274 | // Concrete WSL distro used by the current backend run. This stays separate | |
| 275 | // from id because a default-tracking instance keeps the stable | |
| 276 | // "wsl:default" IPC target while each run launches a specific distro. | |
| 277 | runningDistro?: string | null; | |
| 264 | 278 | httpBaseUrl: string | null; |
| 265 | 279 | wsBaseUrl: string | null; |
| 266 | 280 | bootstrapToken?: string; |
| 267 | 281 | } |
| 268 | 282 | |
| 269 | 283 | export const DesktopEnvironmentBootstrapSchema = Schema.Struct({ |
| 284 | id: Schema.String, | |
| 270 | 285 | label: Schema.String, |
| 286 | runningDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), | |
| 271 | 287 | httpBaseUrl: Schema.NullOr(Schema.String), |
| 272 | 288 | wsBaseUrl: Schema.NullOr(Schema.String), |
| 273 | 289 | bootstrapToken: Schema.optionalKey(Schema.String), |
| ... | ... | @@ -411,10 +427,59 @@ export const DesktopServerExposureStateSchema = Schema.Struct({ |
| 411 | 427 | |
| 412 | 428 | export interface PickFolderOptions { |
| 413 | 429 | initialPath?: string | null; |
| 430 | // When set, the desktop dialog opens against the named backend's | |
| 431 | // filesystem instead of the primary's. Used by callers that already | |
| 432 | // know which local environment they're targeting (e.g. opening a | |
| 433 | // project that lives inside WSL). Omitting it keeps the historical | |
| 434 | // behavior so non-WSL users never see a different picker. | |
| 435 | targetEnvironmentId?: string; | |
| 414 | 436 | } |
| 415 | 437 | |
| 416 | 438 | export const PickFolderOptionsSchema = Schema.Struct({ |
| 417 | 439 | initialPath: Schema.optionalKey(Schema.NullOr(Schema.String)), |
| 440 | targetEnvironmentId: Schema.optionalKey(Schema.String), | |
| 441 | }); | |
| 442 | ||
| 443 | export interface DesktopWslDistro { | |
| 444 | name: string; | |
| 445 | isDefault: boolean; | |
| 446 | version: 1 | 2; | |
| 447 | } | |
| 448 | ||
| 449 | export const DesktopWslDistroSchema = Schema.Struct({ | |
| 450 | name: Schema.String, | |
| 451 | isDefault: Schema.Boolean, | |
| 452 | version: Schema.Literals([1, 2]), | |
| 453 | }); | |
| 454 | ||
| 455 | export interface DesktopWslState { | |
| 456 | // True when the user has opted the WSL backend in; the actual backend | |
| 457 | // process is registered with the desktop pool independently of this | |
| 458 | // flag and may take a moment to come up after the user enables it. | |
| 459 | enabled: boolean; | |
| 460 | // null means "track the current WSL default distro". | |
| 461 | distro: string | null; | |
| 462 | available: boolean; | |
| 463 | // When true (and `enabled` is also true) the desktop runs only the | |
| 464 | // WSL backend as the primary; the Windows-side Node backend is not | |
| 465 | // started. Toggling this requires an app restart because the | |
| 466 | // primary backend's spec is captured once at layer init. | |
| 467 | wslOnly: boolean; | |
| 468 | distros: readonly DesktopWslDistro[]; | |
| 469 | // Reason the dual-mode WSL backend last failed preflight (no node, wrong | |
| 470 | // version, missing build tools), or null. Surfaced inline in Connections | |
| 471 | // settings. Always null in wsl-only mode — that path shows a dialog and | |
| 472 | // falls back to Windows instead. | |
| 473 | preflightError: string | null; | |
| 474 | } | |
| 475 | ||
| 476 | export const DesktopWslStateSchema = Schema.Struct({ | |
| 477 | enabled: Schema.Boolean, | |
| 478 | distro: Schema.NullOr(Schema.String), | |
| 479 | available: Schema.Boolean, | |
| 480 | wslOnly: Schema.Boolean, | |
| 481 | distros: Schema.Array(DesktopWslDistroSchema), | |
| 482 | preflightError: Schema.NullOr(Schema.String), | |
| 418 | 483 | }); |
| 419 | 484 | |
| 420 | 485 | /** |
| ... | ... | @@ -881,7 +946,10 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ |
| 881 | 946 | |
| 882 | 947 | export interface DesktopBridge { |
| 883 | 948 | getAppBranding: () => DesktopAppBranding | null; |
| 884 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; | |
| 949 | // One bootstrap per pool instance currently registered with bootstrap | |
| 950 | // info (omits instances whose backend hasn't produced a config yet). | |
| 951 | // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID. | |
| 952 | getLocalEnvironmentBootstraps: () => readonly DesktopEnvironmentBootstrap[]; | |
| 885 | 953 | getLocalEnvironmentBearerToken: () => Promise<string>; |
| 886 | 954 | getClientSettings: () => Promise<ClientSettings | null>; |
| 887 | 955 | setClientSettings: (settings: ClientSettings) => Promise<void>; |
| ... | ... | @@ -913,6 +981,10 @@ export interface DesktopBridge { |
| 913 | 981 | readonly port?: number; |
| 914 | 982 | }) => Promise<DesktopServerExposureState>; |
| 915 | 983 | getAdvertisedEndpoints: () => Promise<readonly AdvertisedEndpoint[]>; |
| 984 | getWslState: () => Promise<DesktopWslState>; | |
| 985 | setWslBackendEnabled: (enabled: boolean) => Promise<DesktopWslState>; | |
| 986 | setWslDistro: (distro: string | null) => Promise<DesktopWslState>; | |
| 987 | setWslOnly: (enabled: boolean) => Promise<DesktopWslState>; | |
| 916 | 988 | pickFolder: (options?: PickFolderOptions) => Promise<string | null>; |
| 917 | 989 | confirm: (message: string) => Promise<boolean>; |
| 918 | 990 | setTheme: (theme: DesktopTheme) => Promise<void>; |
packages/shared/package.json+4| ... | ... | @@ -178,6 +178,10 @@ |
| 178 | 178 | "./hostProcess": { |
| 179 | 179 | "types": "./src/hostProcess.ts", |
| 180 | 180 | "import": "./src/hostProcess.ts" |
| 181 | }, | |
| 182 | "./httpReadiness": { | |
| 183 | "types": "./src/httpReadiness.ts", | |
| 184 | "import": "./src/httpReadiness.ts" | |
| 181 | 185 | } |
| 182 | 186 | }, |
| 183 | 187 | "scripts": { |
packages/shared/src/httpReadiness.ts created+168| ... | ... | @@ -0,0 +1,168 @@ |
| 1 | import * as Duration from "effect/Duration"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Option from "effect/Option"; | |
| 4 | import * as Ref from "effect/Ref"; | |
| 5 | import * as Schedule from "effect/Schedule"; | |
| 6 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | |
| 7 | ||
| 8 | export const DEFAULT_HTTP_READY_PROBE_TIMEOUT_MS = 1_000; | |
| 9 | ||
| 10 | /** | |
| 11 | * Normalizes an arbitrary readiness probe failure into a plain, structured value | |
| 12 | * suitable for diagnostic logging. Preserves the tagged-error `_tag` (and | |
| 13 | * message/cause) shape for Effect tagged errors while recursing through nested | |
| 14 | * `cause`/`reason` chains. | |
| 15 | */ | |
| 16 | export function describeReadinessCause(cause: unknown): unknown { | |
| 17 | if (cause instanceof Error) { | |
| 18 | const tag = (cause as { readonly _tag?: unknown })._tag; | |
| 19 | const nested = (cause as { readonly cause?: unknown }).cause; | |
| 20 | return { | |
| 21 | ...(typeof tag === "string" ? { _tag: tag } : { name: cause.name }), | |
| 22 | message: cause.message, | |
| 23 | ...(nested === undefined ? {} : { cause: describeReadinessCause(nested) }), | |
| 24 | }; | |
| 25 | } | |
| 26 | if (typeof cause !== "object" || cause === null) { | |
| 27 | return cause; | |
| 28 | } | |
| 29 | ||
| 30 | const record = cause as Readonly<Record<string, unknown>>; | |
| 31 | return { | |
| 32 | ...(typeof record._tag === "string" ? { _tag: record._tag } : {}), | |
| 33 | ...(typeof record.message === "string" ? { message: record.message } : {}), | |
| 34 | ...(record.reason === undefined ? {} : { reason: describeReadinessCause(record.reason) }), | |
| 35 | ...(record.cause === undefined ? {} : { cause: describeReadinessCause(record.cause) }), | |
| 36 | }; | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * Generic HTTP readiness probe shared by the SSH tunnel and the desktop backend | |
| 41 | * manager. Polls `baseUrl + path` until it returns a 2xx response or the overall | |
| 42 | * `timeoutMs` elapses. Each individual probe is bounded by `probeTimeoutMs` so a | |
| 43 | * single hung request cannot stall the retry loop, and the retry cadence is | |
| 44 | * `intervalMs` bounded to roughly `timeoutMs / intervalMs` attempts. | |
| 45 | * | |
| 46 | * The error type is left to the caller via `makeError`, so each consumer keeps | |
| 47 | * its own tagged error. `makeError` is called at every failure site; callers can | |
| 48 | * inspect `cause` (which carries a `kind` discriminator for the probe-timeout and | |
| 49 | * overall-timeout cases) to reproduce phase-specific messages, or ignore it. | |
| 50 | */ | |
| 51 | export const waitForHttpReady = Effect.fn("shared.httpReadiness.waitForHttpReady")(function* < | |
| 52 | E, | |
| 53 | >(input: { | |
| 54 | readonly baseUrl: string; | |
| 55 | readonly path?: string; | |
| 56 | readonly timeoutMs?: number; | |
| 57 | readonly intervalMs?: number; | |
| 58 | readonly probeTimeoutMs?: number; | |
| 59 | readonly makeError: (info: { | |
| 60 | readonly requestUrl: string; | |
| 61 | readonly probeTimeoutMs: number; | |
| 62 | readonly attempt: number; | |
| 63 | readonly cause: unknown; | |
| 64 | }) => E; | |
| 65 | }): Effect.fn.Return<void, E, HttpClient.HttpClient> { | |
| 66 | const timeoutMs = input.timeoutMs ?? 30_000; | |
| 67 | const intervalMs = input.intervalMs ?? 100; | |
| 68 | const probeTimeoutMs = input.probeTimeoutMs ?? DEFAULT_HTTP_READY_PROBE_TIMEOUT_MS; | |
| 69 | const retryPolicy = Schedule.spaced(Duration.millis(intervalMs)).pipe( | |
| 70 | Schedule.take(Math.max(0, Math.ceil(timeoutMs / intervalMs))), | |
| 71 | ); | |
| 72 | const requestUrl = new URL(input.path ?? "/", input.baseUrl).toString(); | |
| 73 | const client = yield* HttpClient.HttpClient; | |
| 74 | const lastProbeFailure = yield* Ref.make<unknown>(null); | |
| 75 | let attempt = 0; | |
| 76 | ||
| 77 | // Tracks errors this function itself produced via `makeError`, so the | |
| 78 | // pass-through guards below never double-wrap an already-constructed error | |
| 79 | // (mirrors the SSH original's `cause instanceof SshReadinessError` checks). | |
| 80 | const makeError = input.makeError; | |
| 81 | const madeErrors = new WeakSet<object>(); | |
| 82 | const fail = (cause: unknown): E => { | |
| 83 | const error = makeError({ requestUrl, probeTimeoutMs, attempt, cause }); | |
| 84 | if (typeof error === "object" && error !== null) { | |
| 85 | madeErrors.add(error); | |
| 86 | } | |
| 87 | return error; | |
| 88 | }; | |
| 89 | const isMadeError = (value: unknown): value is E => | |
| 90 | typeof value === "object" && value !== null && madeErrors.has(value); | |
| 91 | ||
| 92 | yield* Effect.logDebug("httpReadiness.start", { | |
| 93 | baseUrl: input.baseUrl, | |
| 94 | requestUrl, | |
| 95 | timeoutMs, | |
| 96 | intervalMs, | |
| 97 | probeTimeoutMs, | |
| 98 | }); | |
| 99 | ||
| 100 | const readinessClient = client.pipe( | |
| 101 | HttpClient.filterStatusOk, | |
| 102 | HttpClient.transform((effect) => | |
| 103 | Effect.gen(function* () { | |
| 104 | attempt += 1; | |
| 105 | const responseOption = yield* effect.pipe( | |
| 106 | Effect.timeoutOption(Duration.millis(probeTimeoutMs)), | |
| 107 | Effect.mapError((cause) => fail(cause)), | |
| 108 | ); | |
| 109 | return yield* Option.match(responseOption, { | |
| 110 | onSome: Effect.succeed, | |
| 111 | onNone: () => | |
| 112 | Effect.fail( | |
| 113 | fail({ | |
| 114 | kind: "probe-timeout", | |
| 115 | attempt, | |
| 116 | probeTimeoutMs, | |
| 117 | }), | |
| 118 | ), | |
| 119 | }); | |
| 120 | }).pipe( | |
| 121 | Effect.mapError((cause) => (isMadeError(cause) ? cause : fail(cause))), | |
| 122 | Effect.tapError((cause) => | |
| 123 | Ref.set(lastProbeFailure, { | |
| 124 | attempt, | |
| 125 | cause: describeReadinessCause(cause), | |
| 126 | }), | |
| 127 | ), | |
| 128 | ), | |
| 129 | ), | |
| 130 | HttpClient.tap((response) => response.text.pipe(Effect.ignore)), | |
| 131 | HttpClient.retry(retryPolicy), | |
| 132 | ); | |
| 133 | ||
| 134 | const result = yield* readinessClient.execute(HttpClientRequest.get(requestUrl)).pipe( | |
| 135 | Effect.mapError((cause) => (isMadeError(cause) ? cause : fail(cause))), | |
| 136 | Effect.timeoutOption(Duration.millis(timeoutMs)), | |
| 137 | ); | |
| 138 | ||
| 139 | return yield* Option.match(result, { | |
| 140 | onSome: () => | |
| 141 | Effect.logDebug("httpReadiness.succeeded", { | |
| 142 | baseUrl: input.baseUrl, | |
| 143 | requestUrl, | |
| 144 | attempts: attempt, | |
| 145 | }), | |
| 146 | onNone: () => | |
| 147 | Effect.gen(function* () { | |
| 148 | const lastFailure = yield* Ref.get(lastProbeFailure); | |
| 149 | yield* Effect.logWarning("httpReadiness.timedOut", { | |
| 150 | baseUrl: input.baseUrl, | |
| 151 | requestUrl, | |
| 152 | timeoutMs, | |
| 153 | intervalMs, | |
| 154 | probeTimeoutMs, | |
| 155 | attempts: attempt, | |
| 156 | lastFailure, | |
| 157 | }); | |
| 158 | return yield* Effect.fail( | |
| 159 | fail({ | |
| 160 | kind: "overall-timeout", | |
| 161 | baseUrl: input.baseUrl, | |
| 162 | timeoutMs, | |
| 163 | lastFailure, | |
| 164 | }), | |
| 165 | ); | |
| 166 | }), | |
| 167 | }); | |
| 168 | }); |
packages/ssh/src/tunnel.ts+41-139| ... | ... | @@ -2,24 +2,24 @@ import type { |
| 2 | 2 | DesktopSshEnvironmentBootstrap, |
| 3 | 3 | DesktopSshEnvironmentTarget, |
| 4 | 4 | } from "@t3tools/contracts"; |
| 5 | import { | |
| 6 | describeReadinessCause, | |
| 7 | waitForHttpReady as waitForHttpReadyShared, | |
| 8 | } from "@t3tools/shared/httpReadiness"; | |
| 5 | 9 | import * as NetService from "@t3tools/shared/Net"; |
| 6 | 10 | import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 7 | 11 | import { satisfiesSemverRange } from "@t3tools/shared/semver"; |
| 8 | 12 | import * as Context from "effect/Context"; |
| 9 | 13 | import * as Deferred from "effect/Deferred"; |
| 10 | import * as Duration from "effect/Duration"; | |
| 11 | 14 | import * as Effect from "effect/Effect"; |
| 12 | 15 | import * as Exit from "effect/Exit"; |
| 13 | 16 | import * as FileSystem from "effect/FileSystem"; |
| 14 | 17 | import * as Layer from "effect/Layer"; |
| 15 | import * as Option from "effect/Option"; | |
| 16 | 18 | import * as Path from "effect/Path"; |
| 17 | import * as Ref from "effect/Ref"; | |
| 18 | import * as Schedule from "effect/Schedule"; | |
| 19 | 19 | import * as Schema from "effect/Schema"; |
| 20 | 20 | import * as Scope from "effect/Scope"; |
| 21 | 21 | import * as Stream from "effect/Stream"; |
| 22 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | |
| 22 | import { HttpClient } from "effect/unstable/http"; | |
| 23 | 23 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 24 | 24 | |
| 25 | 25 | import { |
| ... | ... | @@ -232,33 +232,9 @@ function applyScriptPlaceholders( |
| 232 | 232 | return result; |
| 233 | 233 | } |
| 234 | 234 | |
| 235 | export function describeReadinessCause(cause: unknown): unknown { | |
| 236 | if (cause instanceof SshReadinessError) { | |
| 237 | return { | |
| 238 | _tag: cause._tag, | |
| 239 | message: cause.message, | |
| 240 | ...(cause.cause === undefined ? {} : { cause: describeReadinessCause(cause.cause) }), | |
| 241 | }; | |
| 242 | } | |
| 243 | if (cause instanceof Error) { | |
| 244 | return { | |
| 245 | name: cause.name, | |
| 246 | message: cause.message, | |
| 247 | ...(cause.cause === undefined ? {} : { cause: describeReadinessCause(cause.cause) }), | |
| 248 | }; | |
| 249 | } | |
| 250 | if (typeof cause !== "object" || cause === null) { | |
| 251 | return cause; | |
| 252 | } | |
| 253 | ||
| 254 | const record = cause as Readonly<Record<string, unknown>>; | |
| 255 | return { | |
| 256 | ...(typeof record._tag === "string" ? { _tag: record._tag } : {}), | |
| 257 | ...(typeof record.message === "string" ? { message: record.message } : {}), | |
| 258 | ...(record.reason === undefined ? {} : { reason: describeReadinessCause(record.reason) }), | |
| 259 | ...(record.cause === undefined ? {} : { cause: describeReadinessCause(record.cause) }), | |
| 260 | }; | |
| 261 | } | |
| 235 | // Re-exported from the shared HTTP readiness module so existing importers | |
| 236 | // (notably tunnel.test.ts) keep resolving it from here. | |
| 237 | export { describeReadinessCause }; | |
| 262 | 238 | |
| 263 | 239 | export const REMOTE_PICK_PORT_SCRIPT = `const fs = require("node:fs"); |
| 264 | 240 | const net = require("node:net"); |
| ... | ... | @@ -666,7 +642,7 @@ export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string |
| 666 | 642 | ); |
| 667 | 643 | } |
| 668 | 644 | |
| 669 | function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string { | |
| 645 | export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string { | |
| 670 | 646 | return stripTrailingNewlines( |
| 671 | 647 | applyScriptPlaceholders(REMOTE_NODE_ENV_SCRIPT, { |
| 672 | 648 | T3_NODE_ENGINE_RANGE: shellSingleQuote(input?.nodeEngineRange?.trim() || ""), |
| ... | ... | @@ -865,120 +841,46 @@ const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")( |
| 865 | 841 | return result.stdout.trim(); |
| 866 | 842 | }); |
| 867 | 843 | |
| 868 | export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(function* (input: { | |
| 844 | export const waitForHttpReady = (input: { | |
| 869 | 845 | readonly baseUrl: string; |
| 870 | 846 | readonly timeoutMs?: number; |
| 871 | 847 | readonly intervalMs?: number; |
| 872 | 848 | readonly probeTimeoutMs?: number; |
| 873 | 849 | readonly path?: string; |
| 874 | }): Effect.fn.Return<void, SshReadinessError, HttpClient.HttpClient> { | |
| 875 | const timeoutMs = input.timeoutMs ?? 30_000; | |
| 876 | const intervalMs = input.intervalMs ?? 100; | |
| 877 | const probeTimeoutMs = input.probeTimeoutMs ?? SSH_READY_PROBE_TIMEOUT_MS; | |
| 878 | const retryPolicy = Schedule.spaced(Duration.millis(intervalMs)).pipe( | |
| 879 | Schedule.take(Math.max(0, Math.ceil(timeoutMs / intervalMs))), | |
| 880 | ); | |
| 881 | const requestUrl = new URL(input.path ?? "/", input.baseUrl).toString(); | |
| 882 | const client = yield* HttpClient.HttpClient; | |
| 883 | const lastProbeFailure = yield* Ref.make<unknown>(null); | |
| 884 | let attempt = 0; | |
| 885 | ||
| 886 | yield* Effect.logDebug("ssh.tunnel.httpReady.start", { | |
| 850 | }): Effect.Effect<void, SshReadinessError, HttpClient.HttpClient> => | |
| 851 | waitForHttpReadyShared({ | |
| 887 | 852 | baseUrl: input.baseUrl, |
| 888 | requestUrl, | |
| 889 | timeoutMs, | |
| 890 | intervalMs, | |
| 891 | probeTimeoutMs, | |
| 892 | }); | |
| 893 | ||
| 894 | const readinessClient = client.pipe( | |
| 895 | HttpClient.filterStatusOk, | |
| 896 | HttpClient.transform((effect) => | |
| 897 | Effect.gen(function* () { | |
| 898 | attempt += 1; | |
| 899 | const responseOption = yield* effect.pipe( | |
| 900 | Effect.timeoutOption(Duration.millis(probeTimeoutMs)), | |
| 901 | Effect.mapError( | |
| 902 | (cause) => | |
| 903 | new SshReadinessError({ | |
| 904 | message: `Backend readiness probe failed at ${requestUrl}.`, | |
| 905 | cause, | |
| 906 | }), | |
| 907 | ), | |
| 908 | ); | |
| 909 | return yield* Option.match(responseOption, { | |
| 910 | onSome: Effect.succeed, | |
| 911 | onNone: () => | |
| 912 | Effect.fail( | |
| 913 | new SshReadinessError({ | |
| 914 | message: `Backend readiness probe exceeded ${probeTimeoutMs}ms at ${requestUrl}.`, | |
| 915 | cause: { | |
| 916 | kind: "probe-timeout", | |
| 917 | attempt, | |
| 918 | probeTimeoutMs, | |
| 919 | }, | |
| 920 | }), | |
| 921 | ), | |
| 922 | }); | |
| 923 | }).pipe( | |
| 924 | Effect.mapError((cause) => | |
| 925 | cause instanceof SshReadinessError | |
| 926 | ? cause | |
| 927 | : new SshReadinessError({ | |
| 928 | message: `Backend readiness probe failed at ${requestUrl}.`, | |
| 929 | cause, | |
| 930 | }), | |
| 931 | ), | |
| 932 | Effect.tapError((cause) => | |
| 933 | Ref.set(lastProbeFailure, { | |
| 934 | attempt, | |
| 935 | cause: describeReadinessCause(cause), | |
| 936 | }), | |
| 937 | ), | |
| 938 | ), | |
| 939 | ), | |
| 940 | HttpClient.tap((response) => response.text.pipe(Effect.ignore)), | |
| 941 | HttpClient.retry(retryPolicy), | |
| 942 | ); | |
| 943 | ||
| 944 | const result = yield* readinessClient.execute(HttpClientRequest.get(requestUrl)).pipe( | |
| 945 | Effect.mapError((cause) => | |
| 946 | cause instanceof SshReadinessError | |
| 947 | ? cause | |
| 948 | : new SshReadinessError({ | |
| 949 | message: `Backend readiness probe failed at ${requestUrl}.`, | |
| 853 | ...(input.path === undefined ? {} : { path: input.path }), | |
| 854 | ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), | |
| 855 | ...(input.intervalMs === undefined ? {} : { intervalMs: input.intervalMs }), | |
| 856 | probeTimeoutMs: input.probeTimeoutMs ?? SSH_READY_PROBE_TIMEOUT_MS, | |
| 857 | makeError: ({ requestUrl, probeTimeoutMs, cause }) => { | |
| 858 | if (typeof cause === "object" && cause !== null && "kind" in cause) { | |
| 859 | const kind = (cause as { readonly kind?: unknown }).kind; | |
| 860 | if (kind === "probe-timeout") { | |
| 861 | return new SshReadinessError({ | |
| 862 | message: `Backend readiness probe exceeded ${probeTimeoutMs}ms at ${requestUrl}.`, | |
| 950 | 863 | cause, |
| 951 | }), | |
| 952 | ), | |
| 953 | Effect.timeoutOption(Duration.millis(timeoutMs)), | |
| 954 | ); | |
| 955 | ||
| 956 | return yield* Option.match(result, { | |
| 957 | onSome: () => | |
| 958 | Effect.logDebug("ssh.tunnel.httpReady.succeeded", { | |
| 959 | baseUrl: input.baseUrl, | |
| 960 | requestUrl, | |
| 961 | attempts: attempt, | |
| 962 | }), | |
| 963 | onNone: () => | |
| 964 | Effect.gen(function* () { | |
| 965 | const lastFailure = yield* Ref.get(lastProbeFailure); | |
| 966 | yield* Effect.logWarning("ssh.tunnel.httpReady.timedOut", { | |
| 967 | baseUrl: input.baseUrl, | |
| 968 | requestUrl, | |
| 969 | timeoutMs, | |
| 970 | intervalMs, | |
| 971 | probeTimeoutMs, | |
| 972 | attempts: attempt, | |
| 973 | lastFailure, | |
| 974 | }); | |
| 975 | return yield* new SshReadinessError({ | |
| 976 | message: `Timed out waiting ${timeoutMs}ms for backend readiness at ${input.baseUrl}.`, | |
| 977 | cause: lastFailure, | |
| 978 | }); | |
| 979 | }), | |
| 864 | }); | |
| 865 | } | |
| 866 | if (kind === "overall-timeout") { | |
| 867 | const overall = cause as unknown as { | |
| 868 | readonly baseUrl: string; | |
| 869 | readonly timeoutMs: number; | |
| 870 | readonly lastFailure: unknown; | |
| 871 | }; | |
| 872 | return new SshReadinessError({ | |
| 873 | message: `Timed out waiting ${overall.timeoutMs}ms for backend readiness at ${overall.baseUrl}.`, | |
| 874 | cause: overall.lastFailure, | |
| 875 | }); | |
| 876 | } | |
| 877 | } | |
| 878 | return new SshReadinessError({ | |
| 879 | message: `Backend readiness probe failed at ${requestUrl}.`, | |
| 880 | cause, | |
| 881 | }); | |
| 882 | }, | |
| 980 | 883 | }); |
| 981 | }); | |
| 982 | 884 | |
| 983 | 885 | function isLoopbackHostname(hostname: string): boolean { |
| 984 | 886 | const normalized = hostname |
pnpm-lock.yaml+117-10| ... | ... | @@ -911,21 +911,25 @@ packages: |
| 911 | 911 | resolution: {integrity: sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg==} |
| 912 | 912 | cpu: [arm64] |
| 913 | 913 | os: [linux] |
| 914 | libc: [musl] | |
| 914 | 915 | |
| 915 | 916 | '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170': |
| 916 | 917 | resolution: {integrity: sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ==} |
| 917 | 918 | cpu: [arm64] |
| 918 | 919 | os: [linux] |
| 920 | libc: [glibc] | |
| 919 | 921 | |
| 920 | 922 | '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170': |
| 921 | 923 | resolution: {integrity: sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ==} |
| 922 | 924 | cpu: [x64] |
| 923 | 925 | os: [linux] |
| 926 | libc: [musl] | |
| 924 | 927 | |
| 925 | 928 | '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170': |
| 926 | 929 | resolution: {integrity: sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg==} |
| 927 | 930 | cpu: [x64] |
| 928 | 931 | os: [linux] |
| 932 | libc: [glibc] | |
| 929 | 933 | |
| 930 | 934 | '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170': |
| 931 | 935 | resolution: {integrity: sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg==} |
| ... | ... | @@ -977,24 +981,28 @@ packages: |
| 977 | 981 | engines: {node: ^20.19.0 || >=22.12.0} |
| 978 | 982 | cpu: [arm64] |
| 979 | 983 | os: [linux] |
| 984 | libc: [glibc] | |
| 980 | 985 | |
| 981 | 986 | '@astrojs/compiler-binding-linux-arm64-musl@0.2.3': |
| 982 | 987 | resolution: {integrity: sha512-O3e2CbN4yTsRguWYNnRd0p5YQ0H3fb7KpcR0W4R319q/gq5B1pJ7eqNbiO3b8g2AuiEcRTiUz5jeGT9j69cxOQ==} |
| 983 | 988 | engines: {node: ^20.19.0 || >=22.12.0} |
| 984 | 989 | cpu: [arm64] |
| 985 | 990 | os: [linux] |
| 991 | libc: [musl] | |
| 986 | 992 | |
| 987 | 993 | '@astrojs/compiler-binding-linux-x64-gnu@0.2.3': |
| 988 | 994 | resolution: {integrity: sha512-hbLBjXVp+96psMe7/7uqyrquGiULXANrq6REVxxPK/I5VzebZ7LHmSfykmByUbLyR1u+K6CTBKgvdQsK2L+2Xw==} |
| 989 | 995 | engines: {node: ^20.19.0 || >=22.12.0} |
| 990 | 996 | cpu: [x64] |
| 991 | 997 | os: [linux] |
| 998 | libc: [glibc] | |
| 992 | 999 | |
| 993 | 1000 | '@astrojs/compiler-binding-linux-x64-musl@0.2.3': |
| 994 | 1001 | resolution: {integrity: sha512-vIiEvOwrJfHZMaTmqUCrFTIwMYL0+PD3Rvy7kFDQgERyx3zhaw8CPa01MCCqa+/sj344BGrXKZ6ti37SgNLMhw==} |
| 995 | 1002 | engines: {node: ^20.19.0 || >=22.12.0} |
| 996 | 1003 | cpu: [x64] |
| 997 | 1004 | os: [linux] |
| 1005 | libc: [musl] | |
| 998 | 1006 | |
| 999 | 1007 | '@astrojs/compiler-binding-wasm32-wasi@0.2.3': |
| 1000 | 1008 | resolution: {integrity: sha512-p9S2X8z/mUR2SMzAVJRFMCt8YaalKR+pjl2DgpdjzCQc6ww4bo8kiy54tgKqxZeNF5c+/2tCDTQIxVSm9V1FsA==} |
| ... | ... | @@ -1583,21 +1591,25 @@ packages: |
| 1583 | 1591 | resolution: {integrity: sha512-A/pWy8Jb/PhDYc2/JFuYh06gFJcsfBUBDl81YydGYBrL/Z4nItDfhNDNOibyeSN/lKKDRlycIHEIajjErk00sQ==} |
| 1584 | 1592 | cpu: [arm64] |
| 1585 | 1593 | os: [linux] |
| 1594 | libc: [glibc] | |
| 1586 | 1595 | |
| 1587 | 1596 | '@bruits/satteri-linux-arm64-musl@0.9.3': |
| 1588 | 1597 | resolution: {integrity: sha512-L6YxmyOSickzo4pE5WmZfNTJnjX0MtgKOsuwQfNZECTx9Ir5vl2B37EIwnxe2AybuPPHl+FqVQtthNDUdH4Vgg==} |
| 1589 | 1598 | cpu: [arm64] |
| 1590 | 1599 | os: [linux] |
| 1600 | libc: [musl] | |
| 1591 | 1601 | |
| 1592 | 1602 | '@bruits/satteri-linux-x64-gnu@0.9.3': |
| 1593 | 1603 | resolution: {integrity: sha512-RgH6GPihg9Lzs2yHUsMjqiLxfLyOdmBty8sg9pBY9B4CBnvdOzvg8vklqN+C4qrEEdA9TwpbDpHr1AshLKyRpw==} |
| 1594 | 1604 | cpu: [x64] |
| 1595 | 1605 | os: [linux] |
| 1606 | libc: [glibc] | |
| 1596 | 1607 | |
| 1597 | 1608 | '@bruits/satteri-linux-x64-musl@0.9.3': |
| 1598 | 1609 | resolution: {integrity: sha512-BeWhVORjNTIomePznUKiMbHZTqC0j7sMXZFsISmbX+po5d33KLkqBqKh6K332CHJ8KUmCWx16FfPjwsoysttQg==} |
| 1599 | 1610 | cpu: [x64] |
| 1600 | 1611 | os: [linux] |
| 1612 | libc: [musl] | |
| 1601 | 1613 | |
| 1602 | 1614 | '@bruits/satteri-wasm32-wasi@0.9.3': |
| 1603 | 1615 | resolution: {integrity: sha512-dFNcOHKWV2cztCPnYTn7kZ9D7kNOt8N239z5ysFkNHLxJrfK7zaKIXQbfXYN32C+JoVFqAcTIOeWH2+VnsCOHg==} |
| ... | ... | @@ -2575,21 +2587,25 @@ packages: |
| 2575 | 2587 | resolution: {integrity: sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw==} |
| 2576 | 2588 | cpu: [arm64] |
| 2577 | 2589 | os: [linux] |
| 2590 | libc: [glibc] | |
| 2578 | 2591 | |
| 2579 | 2592 | '@ff-labs/fff-bin-linux-arm64-musl@0.9.4': |
| 2580 | 2593 | resolution: {integrity: sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA==} |
| 2581 | 2594 | cpu: [arm64] |
| 2582 | 2595 | os: [linux] |
| 2596 | libc: [musl] | |
| 2583 | 2597 | |
| 2584 | 2598 | '@ff-labs/fff-bin-linux-x64-gnu@0.9.4': |
| 2585 | 2599 | resolution: {integrity: sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q==} |
| 2586 | 2600 | cpu: [x64] |
| 2587 | 2601 | os: [linux] |
| 2602 | libc: [glibc] | |
| 2588 | 2603 | |
| 2589 | 2604 | '@ff-labs/fff-bin-linux-x64-musl@0.9.4': |
| 2590 | 2605 | resolution: {integrity: sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w==} |
| 2591 | 2606 | cpu: [x64] |
| 2592 | 2607 | os: [linux] |
| 2608 | libc: [musl] | |
| 2593 | 2609 | |
| 2594 | 2610 | '@ff-labs/fff-bin-win32-arm64@0.9.4': |
| 2595 | 2611 | resolution: {integrity: sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ==} |
| ... | ... | @@ -2676,89 +2692,105 @@ packages: |
| 2676 | 2692 | resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} |
| 2677 | 2693 | cpu: [arm64] |
| 2678 | 2694 | os: [linux] |
| 2695 | libc: [glibc] | |
| 2679 | 2696 | |
| 2680 | 2697 | '@img/sharp-libvips-linux-arm@1.2.4': |
| 2681 | 2698 | resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} |
| 2682 | 2699 | cpu: [arm] |
| 2683 | 2700 | os: [linux] |
| 2701 | libc: [glibc] | |
| 2684 | 2702 | |
| 2685 | 2703 | '@img/sharp-libvips-linux-ppc64@1.2.4': |
| 2686 | 2704 | resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} |
| 2687 | 2705 | cpu: [ppc64] |
| 2688 | 2706 | os: [linux] |
| 2707 | libc: [glibc] | |
| 2689 | 2708 | |
| 2690 | 2709 | '@img/sharp-libvips-linux-riscv64@1.2.4': |
| 2691 | 2710 | resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} |
| 2692 | 2711 | cpu: [riscv64] |
| 2693 | 2712 | os: [linux] |
| 2713 | libc: [glibc] | |
| 2694 | 2714 | |
| 2695 | 2715 | '@img/sharp-libvips-linux-s390x@1.2.4': |
| 2696 | 2716 | resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} |
| 2697 | 2717 | cpu: [s390x] |
| 2698 | 2718 | os: [linux] |
| 2719 | libc: [glibc] | |
| 2699 | 2720 | |
| 2700 | 2721 | '@img/sharp-libvips-linux-x64@1.2.4': |
| 2701 | 2722 | resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} |
| 2702 | 2723 | cpu: [x64] |
| 2703 | 2724 | os: [linux] |
| 2725 | libc: [glibc] | |
| 2704 | 2726 | |
| 2705 | 2727 | '@img/sharp-libvips-linuxmusl-arm64@1.2.4': |
| 2706 | 2728 | resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} |
| 2707 | 2729 | cpu: [arm64] |
| 2708 | 2730 | os: [linux] |
| 2731 | libc: [musl] | |
| 2709 | 2732 | |
| 2710 | 2733 | '@img/sharp-libvips-linuxmusl-x64@1.2.4': |
| 2711 | 2734 | resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} |
| 2712 | 2735 | cpu: [x64] |
| 2713 | 2736 | os: [linux] |
| 2737 | libc: [musl] | |
| 2714 | 2738 | |
| 2715 | 2739 | '@img/sharp-linux-arm64@0.34.5': |
| 2716 | 2740 | resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} |
| 2717 | 2741 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2718 | 2742 | cpu: [arm64] |
| 2719 | 2743 | os: [linux] |
| 2744 | libc: [glibc] | |
| 2720 | 2745 | |
| 2721 | 2746 | '@img/sharp-linux-arm@0.34.5': |
| 2722 | 2747 | resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} |
| 2723 | 2748 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2724 | 2749 | cpu: [arm] |
| 2725 | 2750 | os: [linux] |
| 2751 | libc: [glibc] | |
| 2726 | 2752 | |
| 2727 | 2753 | '@img/sharp-linux-ppc64@0.34.5': |
| 2728 | 2754 | resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} |
| 2729 | 2755 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2730 | 2756 | cpu: [ppc64] |
| 2731 | 2757 | os: [linux] |
| 2758 | libc: [glibc] | |
| 2732 | 2759 | |
| 2733 | 2760 | '@img/sharp-linux-riscv64@0.34.5': |
| 2734 | 2761 | resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} |
| 2735 | 2762 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2736 | 2763 | cpu: [riscv64] |
| 2737 | 2764 | os: [linux] |
| 2765 | libc: [glibc] | |
| 2738 | 2766 | |
| 2739 | 2767 | '@img/sharp-linux-s390x@0.34.5': |
| 2740 | 2768 | resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} |
| 2741 | 2769 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2742 | 2770 | cpu: [s390x] |
| 2743 | 2771 | os: [linux] |
| 2772 | libc: [glibc] | |
| 2744 | 2773 | |
| 2745 | 2774 | '@img/sharp-linux-x64@0.34.5': |
| 2746 | 2775 | resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} |
| 2747 | 2776 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2748 | 2777 | cpu: [x64] |
| 2749 | 2778 | os: [linux] |
| 2779 | libc: [glibc] | |
| 2750 | 2780 | |
| 2751 | 2781 | '@img/sharp-linuxmusl-arm64@0.34.5': |
| 2752 | 2782 | resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} |
| 2753 | 2783 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2754 | 2784 | cpu: [arm64] |
| 2755 | 2785 | os: [linux] |
| 2786 | libc: [musl] | |
| 2756 | 2787 | |
| 2757 | 2788 | '@img/sharp-linuxmusl-x64@0.34.5': |
| 2758 | 2789 | resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} |
| 2759 | 2790 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2760 | 2791 | cpu: [x64] |
| 2761 | 2792 | os: [linux] |
| 2793 | libc: [musl] | |
| 2762 | 2794 | |
| 2763 | 2795 | '@img/sharp-wasm32@0.34.5': |
| 2764 | 2796 | resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} |
| ... | ... | @@ -3217,48 +3249,56 @@ packages: |
| 3217 | 3249 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3218 | 3250 | cpu: [arm64] |
| 3219 | 3251 | os: [linux] |
| 3252 | libc: [glibc] | |
| 3220 | 3253 | |
| 3221 | 3254 | '@oxfmt/binding-linux-arm64-musl@0.55.0': |
| 3222 | 3255 | resolution: {integrity: sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA==} |
| 3223 | 3256 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3224 | 3257 | cpu: [arm64] |
| 3225 | 3258 | os: [linux] |
| 3259 | libc: [musl] | |
| 3226 | 3260 | |
| 3227 | 3261 | '@oxfmt/binding-linux-ppc64-gnu@0.55.0': |
| 3228 | 3262 | resolution: {integrity: sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ==} |
| 3229 | 3263 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3230 | 3264 | cpu: [ppc64] |
| 3231 | 3265 | os: [linux] |
| 3266 | libc: [glibc] | |
| 3232 | 3267 | |
| 3233 | 3268 | '@oxfmt/binding-linux-riscv64-gnu@0.55.0': |
| 3234 | 3269 | resolution: {integrity: sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A==} |
| 3235 | 3270 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3236 | 3271 | cpu: [riscv64] |
| 3237 | 3272 | os: [linux] |
| 3273 | libc: [glibc] | |
| 3238 | 3274 | |
| 3239 | 3275 | '@oxfmt/binding-linux-riscv64-musl@0.55.0': |
| 3240 | 3276 | resolution: {integrity: sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA==} |
| 3241 | 3277 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3242 | 3278 | cpu: [riscv64] |
| 3243 | 3279 | os: [linux] |
| 3280 | libc: [musl] | |
| 3244 | 3281 | |
| 3245 | 3282 | '@oxfmt/binding-linux-s390x-gnu@0.55.0': |
| 3246 | 3283 | resolution: {integrity: sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ==} |
| 3247 | 3284 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3248 | 3285 | cpu: [s390x] |
| 3249 | 3286 | os: [linux] |
| 3287 | libc: [glibc] | |
| 3250 | 3288 | |
| 3251 | 3289 | '@oxfmt/binding-linux-x64-gnu@0.55.0': |
| 3252 | 3290 | resolution: {integrity: sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA==} |
| 3253 | 3291 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3254 | 3292 | cpu: [x64] |
| 3255 | 3293 | os: [linux] |
| 3294 | libc: [glibc] | |
| 3256 | 3295 | |
| 3257 | 3296 | '@oxfmt/binding-linux-x64-musl@0.55.0': |
| 3258 | 3297 | resolution: {integrity: sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g==} |
| 3259 | 3298 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3260 | 3299 | cpu: [x64] |
| 3261 | 3300 | os: [linux] |
| 3301 | libc: [musl] | |
| 3262 | 3302 | |
| 3263 | 3303 | '@oxfmt/binding-openharmony-arm64@0.55.0': |
| 3264 | 3304 | resolution: {integrity: sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg==} |
| ... | ... | @@ -3361,48 +3401,56 @@ packages: |
| 3361 | 3401 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3362 | 3402 | cpu: [arm64] |
| 3363 | 3403 | os: [linux] |
| 3404 | libc: [glibc] | |
| 3364 | 3405 | |
| 3365 | 3406 | '@oxlint/binding-linux-arm64-musl@1.70.0': |
| 3366 | 3407 | resolution: {integrity: sha512-JiylyurlB0CLSedNtx1gzv3FvfWPF1h/2Y3BJszPLNt5XQFlBsH5ke0Jle3iJb3uqu5m2e7A/DwzpuCAHdiU+A==} |
| 3367 | 3408 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3368 | 3409 | cpu: [arm64] |
| 3369 | 3410 | os: [linux] |
| 3411 | libc: [musl] | |
| 3370 | 3412 | |
| 3371 | 3413 | '@oxlint/binding-linux-ppc64-gnu@1.70.0': |
| 3372 | 3414 | resolution: {integrity: sha512-J8VPG7I3/HmgaU4u8pNU2kFx2+0U+vPLS1dXFxXOaR/2TQ0f8AC7DRz0SRGRI1bfphnX2hVYTTtLuhL4nYKL+Q==} |
| 3373 | 3415 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3374 | 3416 | cpu: [ppc64] |
| 3375 | 3417 | os: [linux] |
| 3418 | libc: [glibc] | |
| 3376 | 3419 | |
| 3377 | 3420 | '@oxlint/binding-linux-riscv64-gnu@1.70.0': |
| 3378 | 3421 | resolution: {integrity: sha512-N2+4lV2KLN+oXTIIIwmWDhwkrnvqf5oX7Hw0zPjk+RuIVgiBQSOlJWF7uQoFx2siEYX0ZQ5cfSbEAHm+J3t7Wg==} |
| 3379 | 3422 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3380 | 3423 | cpu: [riscv64] |
| 3381 | 3424 | os: [linux] |
| 3425 | libc: [glibc] | |
| 3382 | 3426 | |
| 3383 | 3427 | '@oxlint/binding-linux-riscv64-musl@1.70.0': |
| 3384 | 3428 | resolution: {integrity: sha512-1e2L7cFCvx9QDzq6NPP+0tABKb5z6nWHyddWTNKprEsjO9xNrAtPowuCGpjNXxkTdsMiZ4jc8YQ5SstZd4XK6g==} |
| 3385 | 3429 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3386 | 3430 | cpu: [riscv64] |
| 3387 | 3431 | os: [linux] |
| 3432 | libc: [musl] | |
| 3388 | 3433 | |
| 3389 | 3434 | '@oxlint/binding-linux-s390x-gnu@1.70.0': |
| 3390 | 3435 | resolution: {integrity: sha512-Kwu/l/8GcYibCWA9m9N5pRXMIKVSsL/YbgpLzYkqDhWTiqdRfnNJ/+nqIKRKQiFbHWsdlHEhzMwruJK+qcEruA==} |
| 3391 | 3436 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3392 | 3437 | cpu: [s390x] |
| 3393 | 3438 | os: [linux] |
| 3439 | libc: [glibc] | |
| 3394 | 3440 | |
| 3395 | 3441 | '@oxlint/binding-linux-x64-gnu@1.70.0': |
| 3396 | 3442 | resolution: {integrity: sha512-tap04CsHYOl0nSAQJfPNIuBxqEPB2HnhQqwaOXLg1jnp2XfRo8Fa814dA4QC4zpvTWXCjAAaCY1W5LOORkEQuQ==} |
| 3397 | 3443 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3398 | 3444 | cpu: [x64] |
| 3399 | 3445 | os: [linux] |
| 3446 | libc: [glibc] | |
| 3400 | 3447 | |
| 3401 | 3448 | '@oxlint/binding-linux-x64-musl@1.70.0': |
| 3402 | 3449 | resolution: {integrity: sha512-hzJa/WgvtJpbBD9rgfy0qe+MjbxOXNUT0bfR1S6EQQzfTtBFA9xg5q8KSwRrQ2QfSS+TaP4j+4mVPQrfNc6UNg==} |
| 3403 | 3450 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3404 | 3451 | cpu: [x64] |
| 3405 | 3452 | os: [linux] |
| 3453 | libc: [musl] | |
| 3406 | 3454 | |
| 3407 | 3455 | '@oxlint/binding-openharmony-arm64@1.70.0': |
| 3408 | 3456 | resolution: {integrity: sha512-xbsaNSNzVSnaJACCUYr1HQMyY/Q/Q1LkePmHG3UvZPvGCYGNxrsZp9OmtA6ick8xH47ltRRbRrPCM1YXYcyC+A==} |
| ... | ... | @@ -3889,108 +3937,126 @@ packages: |
| 3889 | 3937 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3890 | 3938 | cpu: [arm64] |
| 3891 | 3939 | os: [linux] |
| 3940 | libc: [glibc] | |
| 3892 | 3941 | |
| 3893 | 3942 | '@rolldown/binding-linux-arm64-gnu@1.0.1': |
| 3894 | 3943 | resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} |
| 3895 | 3944 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3896 | 3945 | cpu: [arm64] |
| 3897 | 3946 | os: [linux] |
| 3947 | libc: [glibc] | |
| 3898 | 3948 | |
| 3899 | 3949 | '@rolldown/binding-linux-arm64-gnu@1.1.3': |
| 3900 | 3950 | resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} |
| 3901 | 3951 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3902 | 3952 | cpu: [arm64] |
| 3903 | 3953 | os: [linux] |
| 3954 | libc: [glibc] | |
| 3904 | 3955 | |
| 3905 | 3956 | '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': |
| 3906 | 3957 | resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} |
| 3907 | 3958 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3908 | 3959 | cpu: [arm64] |
| 3909 | 3960 | os: [linux] |
| 3961 | libc: [musl] | |
| 3910 | 3962 | |
| 3911 | 3963 | '@rolldown/binding-linux-arm64-musl@1.0.1': |
| 3912 | 3964 | resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} |
| 3913 | 3965 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3914 | 3966 | cpu: [arm64] |
| 3915 | 3967 | os: [linux] |
| 3968 | libc: [musl] | |
| 3916 | 3969 | |
| 3917 | 3970 | '@rolldown/binding-linux-arm64-musl@1.1.3': |
| 3918 | 3971 | resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} |
| 3919 | 3972 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3920 | 3973 | cpu: [arm64] |
| 3921 | 3974 | os: [linux] |
| 3975 | libc: [musl] | |
| 3922 | 3976 | |
| 3923 | 3977 | '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': |
| 3924 | 3978 | resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} |
| 3925 | 3979 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3926 | 3980 | cpu: [ppc64] |
| 3927 | 3981 | os: [linux] |
| 3982 | libc: [glibc] | |
| 3928 | 3983 | |
| 3929 | 3984 | '@rolldown/binding-linux-ppc64-gnu@1.0.1': |
| 3930 | 3985 | resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} |
| 3931 | 3986 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3932 | 3987 | cpu: [ppc64] |
| 3933 | 3988 | os: [linux] |
| 3989 | libc: [glibc] | |
| 3934 | 3990 | |
| 3935 | 3991 | '@rolldown/binding-linux-ppc64-gnu@1.1.3': |
| 3936 | 3992 | resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} |
| 3937 | 3993 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3938 | 3994 | cpu: [ppc64] |
| 3939 | 3995 | os: [linux] |
| 3996 | libc: [glibc] | |
| 3940 | 3997 | |
| 3941 | 3998 | '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': |
| 3942 | 3999 | resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} |
| 3943 | 4000 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3944 | 4001 | cpu: [s390x] |
| 3945 | 4002 | os: [linux] |
| 4003 | libc: [glibc] | |
| 3946 | 4004 | |
| 3947 | 4005 | '@rolldown/binding-linux-s390x-gnu@1.0.1': |
| 3948 | 4006 | resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} |
| 3949 | 4007 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3950 | 4008 | cpu: [s390x] |
| 3951 | 4009 | os: [linux] |
| 4010 | libc: [glibc] | |
| 3952 | 4011 | |
| 3953 | 4012 | '@rolldown/binding-linux-s390x-gnu@1.1.3': |
| 3954 | 4013 | resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} |
| 3955 | 4014 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3956 | 4015 | cpu: [s390x] |
| 3957 | 4016 | os: [linux] |
| 4017 | libc: [glibc] | |
| 3958 | 4018 | |
| 3959 | 4019 | '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': |
| 3960 | 4020 | resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} |
| 3961 | 4021 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3962 | 4022 | cpu: [x64] |
| 3963 | 4023 | os: [linux] |
| 4024 | libc: [glibc] | |
| 3964 | 4025 | |
| 3965 | 4026 | '@rolldown/binding-linux-x64-gnu@1.0.1': |
| 3966 | 4027 | resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} |
| 3967 | 4028 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3968 | 4029 | cpu: [x64] |
| 3969 | 4030 | os: [linux] |
| 4031 | libc: [glibc] | |
| 3970 | 4032 | |
| 3971 | 4033 | '@rolldown/binding-linux-x64-gnu@1.1.3': |
| 3972 | 4034 | resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} |
| 3973 | 4035 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3974 | 4036 | cpu: [x64] |
| 3975 | 4037 | os: [linux] |
| 4038 | libc: [glibc] | |
| 3976 | 4039 | |
| 3977 | 4040 | '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': |
| 3978 | 4041 | resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} |
| 3979 | 4042 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3980 | 4043 | cpu: [x64] |
| 3981 | 4044 | os: [linux] |
| 4045 | libc: [musl] | |
| 3982 | 4046 | |
| 3983 | 4047 | '@rolldown/binding-linux-x64-musl@1.0.1': |
| 3984 | 4048 | resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} |
| 3985 | 4049 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3986 | 4050 | cpu: [x64] |
| 3987 | 4051 | os: [linux] |
| 4052 | libc: [musl] | |
| 3988 | 4053 | |
| 3989 | 4054 | '@rolldown/binding-linux-x64-musl@1.1.3': |
| 3990 | 4055 | resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} |
| 3991 | 4056 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3992 | 4057 | cpu: [x64] |
| 3993 | 4058 | os: [linux] |
| 4059 | libc: [musl] | |
| 3994 | 4060 | |
| 3995 | 4061 | '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': |
| 3996 | 4062 | resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} |
| ... | ... | @@ -4127,66 +4193,79 @@ packages: |
| 4127 | 4193 | resolution: {integrity: sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==} |
| 4128 | 4194 | cpu: [arm] |
| 4129 | 4195 | os: [linux] |
| 4196 | libc: [glibc] | |
| 4130 | 4197 | |
| 4131 | 4198 | '@rollup/rollup-linux-arm-musleabihf@4.61.0': |
| 4132 | 4199 | resolution: {integrity: sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==} |
| 4133 | 4200 | cpu: [arm] |
| 4134 | 4201 | os: [linux] |
| 4202 | libc: [musl] | |
| 4135 | 4203 | |
| 4136 | 4204 | '@rollup/rollup-linux-arm64-gnu@4.61.0': |
| 4137 | 4205 | resolution: {integrity: sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==} |
| 4138 | 4206 | cpu: [arm64] |
| 4139 | 4207 | os: [linux] |
| 4208 | libc: [glibc] | |
| 4140 | 4209 | |
| 4141 | 4210 | '@rollup/rollup-linux-arm64-musl@4.61.0': |
| 4142 | 4211 | resolution: {integrity: sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==} |
| 4143 | 4212 | cpu: [arm64] |
| 4144 | 4213 | os: [linux] |
| 4214 | libc: [musl] | |
| 4145 | 4215 | |
| 4146 | 4216 | '@rollup/rollup-linux-loong64-gnu@4.61.0': |
| 4147 | 4217 | resolution: {integrity: sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==} |
| 4148 | 4218 | cpu: [loong64] |
| 4149 | 4219 | os: [linux] |
| 4220 | libc: [glibc] | |
| 4150 | 4221 | |
| 4151 | 4222 | '@rollup/rollup-linux-loong64-musl@4.61.0': |
| 4152 | 4223 | resolution: {integrity: sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==} |
| 4153 | 4224 | cpu: [loong64] |
| 4154 | 4225 | os: [linux] |
| 4226 | libc: [musl] | |
| 4155 | 4227 | |
| 4156 | 4228 | '@rollup/rollup-linux-ppc64-gnu@4.61.0': |
| 4157 | 4229 | resolution: {integrity: sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==} |
| 4158 | 4230 | cpu: [ppc64] |
| 4159 | 4231 | os: [linux] |
| 4232 | libc: [glibc] | |
| 4160 | 4233 | |
| 4161 | 4234 | '@rollup/rollup-linux-ppc64-musl@4.61.0': |
| 4162 | 4235 | resolution: {integrity: sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==} |
| 4163 | 4236 | cpu: [ppc64] |
| 4164 | 4237 | os: [linux] |
| 4238 | libc: [musl] | |
| 4165 | 4239 | |
| 4166 | 4240 | '@rollup/rollup-linux-riscv64-gnu@4.61.0': |
| 4167 | 4241 | resolution: {integrity: sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==} |
| 4168 | 4242 | cpu: [riscv64] |
| 4169 | 4243 | os: [linux] |
| 4244 | libc: [glibc] | |
| 4170 | 4245 | |
| 4171 | 4246 | '@rollup/rollup-linux-riscv64-musl@4.61.0': |
| 4172 | 4247 | resolution: {integrity: sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==} |
| 4173 | 4248 | cpu: [riscv64] |
| 4174 | 4249 | os: [linux] |
| 4250 | libc: [musl] | |
| 4175 | 4251 | |
| 4176 | 4252 | '@rollup/rollup-linux-s390x-gnu@4.61.0': |
| 4177 | 4253 | resolution: {integrity: sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==} |
| 4178 | 4254 | cpu: [s390x] |
| 4179 | 4255 | os: [linux] |
| 4256 | libc: [glibc] | |
| 4180 | 4257 | |
| 4181 | 4258 | '@rollup/rollup-linux-x64-gnu@4.61.0': |
| 4182 | 4259 | resolution: {integrity: sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==} |
| 4183 | 4260 | cpu: [x64] |
| 4184 | 4261 | os: [linux] |
| 4262 | libc: [glibc] | |
| 4185 | 4263 | |
| 4186 | 4264 | '@rollup/rollup-linux-x64-musl@4.61.0': |
| 4187 | 4265 | resolution: {integrity: sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==} |
| 4188 | 4266 | cpu: [x64] |
| 4189 | 4267 | os: [linux] |
| 4268 | libc: [musl] | |
| 4190 | 4269 | |
| 4191 | 4270 | '@rollup/rollup-openbsd-x64@4.61.0': |
| 4192 | 4271 | resolution: {integrity: sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==} |
| ... | ... | @@ -4421,48 +4500,56 @@ packages: |
| 4421 | 4500 | engines: {node: '>= 20'} |
| 4422 | 4501 | cpu: [arm64] |
| 4423 | 4502 | os: [linux] |
| 4503 | libc: [glibc] | |
| 4424 | 4504 | |
| 4425 | 4505 | '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': |
| 4426 | 4506 | resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} |
| 4427 | 4507 | engines: {node: '>= 20'} |
| 4428 | 4508 | cpu: [arm64] |
| 4429 | 4509 | os: [linux] |
| 4510 | libc: [glibc] | |
| 4430 | 4511 | |
| 4431 | 4512 | '@tailwindcss/oxide-linux-arm64-musl@4.2.1': |
| 4432 | 4513 | resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} |
| 4433 | 4514 | engines: {node: '>= 20'} |
| 4434 | 4515 | cpu: [arm64] |
| 4435 | 4516 | os: [linux] |
| 4517 | libc: [musl] | |
| 4436 | 4518 | |
| 4437 | 4519 | '@tailwindcss/oxide-linux-arm64-musl@4.3.0': |
| 4438 | 4520 | resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} |
| 4439 | 4521 | engines: {node: '>= 20'} |
| 4440 | 4522 | cpu: [arm64] |
| 4441 | 4523 | os: [linux] |
| 4524 | libc: [musl] | |
| 4442 | 4525 | |
| 4443 | 4526 | '@tailwindcss/oxide-linux-x64-gnu@4.2.1': |
| 4444 | 4527 | resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} |
| 4445 | 4528 | engines: {node: '>= 20'} |
| 4446 | 4529 | cpu: [x64] |
| 4447 | 4530 | os: [linux] |
| 4531 | libc: [glibc] | |
| 4448 | 4532 | |
| 4449 | 4533 | '@tailwindcss/oxide-linux-x64-gnu@4.3.0': |
| 4450 | 4534 | resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} |
| 4451 | 4535 | engines: {node: '>= 20'} |
| 4452 | 4536 | cpu: [x64] |
| 4453 | 4537 | os: [linux] |
| 4538 | libc: [glibc] | |
| 4454 | 4539 | |
| 4455 | 4540 | '@tailwindcss/oxide-linux-x64-musl@4.2.1': |
| 4456 | 4541 | resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} |
| 4457 | 4542 | engines: {node: '>= 20'} |
| 4458 | 4543 | cpu: [x64] |
| 4459 | 4544 | os: [linux] |
| 4545 | libc: [musl] | |
| 4460 | 4546 | |
| 4461 | 4547 | '@tailwindcss/oxide-linux-x64-musl@4.3.0': |
| 4462 | 4548 | resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} |
| 4463 | 4549 | engines: {node: '>= 20'} |
| 4464 | 4550 | cpu: [x64] |
| 4465 | 4551 | os: [linux] |
| 4552 | libc: [musl] | |
| 4466 | 4553 | |
| 4467 | 4554 | '@tailwindcss/oxide-wasm32-wasi@4.2.1': |
| 4468 | 4555 | resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} |
| ... | ... | @@ -4935,24 +5022,28 @@ packages: |
| 4935 | 5022 | engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} |
| 4936 | 5023 | cpu: [arm64] |
| 4937 | 5024 | os: [linux] |
| 5025 | libc: [glibc] | |
| 4938 | 5026 | |
| 4939 | 5027 | '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.1': |
| 4940 | 5028 | resolution: {integrity: sha512-vUY7hYycZW0qEevpl7ImzZJFnOEKRYCaCOX4TBW0vk6MJZ+zj/xW7e0LOggzJcz2wbYAgLDqp5h+b8wV9dguDA==} |
| 4941 | 5029 | engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} |
| 4942 | 5030 | cpu: [arm64] |
| 4943 | 5031 | os: [linux] |
| 5032 | libc: [musl] | |
| 4944 | 5033 | |
| 4945 | 5034 | '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.1': |
| 4946 | 5035 | resolution: {integrity: sha512-tFxpToEaykBGxMQHp8M/qmr1yruRRED+c9gA1h9kmplqot04OxuqzRCWu/IiIvMJ0v3JFdOP3gqkyjXLLJhxIA==} |
| 4947 | 5036 | engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} |
| 4948 | 5037 | cpu: [x64] |
| 4949 | 5038 | os: [linux] |
| 5039 | libc: [glibc] | |
| 4950 | 5040 | |
| 4951 | 5041 | '@voidzero-dev/vite-plus-linux-x64-musl@0.2.1': |
| 4952 | 5042 | resolution: {integrity: sha512-2scSS7wEbLO2758fqr1/bAULg7nLCFa5V8LO2b5w3g1CrTYdMTDt2WX1ghPesIi+70pYGydRbXo6iaaN43zfMg==} |
| 4953 | 5043 | engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} |
| 4954 | 5044 | cpu: [x64] |
| 4955 | 5045 | os: [linux] |
| 5046 | libc: [musl] | |
| 4956 | 5047 | |
| 4957 | 5048 | '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.1': |
| 4958 | 5049 | resolution: {integrity: sha512-3+5FJYhi9SqBszjngI2LBmvoiqEwxJWyQ5UsOUtNz6/d+yDrDw+tOgHLl4OKIh5aVNZeIGXzxvP6h24kcEqIyg==} |
| ... | ... | @@ -5035,24 +5126,28 @@ packages: |
| 5035 | 5126 | engines: {node: '>= 12'} |
| 5036 | 5127 | cpu: [arm64] |
| 5037 | 5128 | os: [linux] |
| 5129 | libc: [glibc] | |
| 5038 | 5130 | |
| 5039 | 5131 | '@yuuang/ffi-rs-linux-arm64-musl@1.3.2': |
| 5040 | 5132 | resolution: {integrity: sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA==} |
| 5041 | 5133 | engines: {node: '>= 12'} |
| 5042 | 5134 | cpu: [arm64] |
| 5043 | 5135 | os: [linux] |
| 5136 | libc: [musl] | |
| 5044 | 5137 | |
| 5045 | 5138 | '@yuuang/ffi-rs-linux-x64-gnu@1.3.2': |
| 5046 | 5139 | resolution: {integrity: sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g==} |
| 5047 | 5140 | engines: {node: '>= 12'} |
| 5048 | 5141 | cpu: [x64] |
| 5049 | 5142 | os: [linux] |
| 5143 | libc: [glibc] | |
| 5050 | 5144 | |
| 5051 | 5145 | '@yuuang/ffi-rs-linux-x64-musl@1.3.2': |
| 5052 | 5146 | resolution: {integrity: sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w==} |
| 5053 | 5147 | engines: {node: '>= 12'} |
| 5054 | 5148 | cpu: [x64] |
| 5055 | 5149 | os: [linux] |
| 5150 | libc: [glibc] | |
| 5056 | 5151 | |
| 5057 | 5152 | '@yuuang/ffi-rs-win32-arm64-msvc@1.3.2': |
| 5058 | 5153 | resolution: {integrity: sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg==} |
| ... | ... | @@ -7416,72 +7511,84 @@ packages: |
| 7416 | 7511 | engines: {node: '>= 12.0.0'} |
| 7417 | 7512 | cpu: [arm64] |
| 7418 | 7513 | os: [linux] |
| 7514 | libc: [glibc] | |
| 7419 | 7515 | |
| 7420 | 7516 | lightningcss-linux-arm64-gnu@1.31.1: |
| 7421 | 7517 | resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} |
| 7422 | 7518 | engines: {node: '>= 12.0.0'} |
| 7423 | 7519 | cpu: [arm64] |
| 7424 | 7520 | os: [linux] |
| 7521 | libc: [glibc] | |
| 7425 | 7522 | |
| 7426 | 7523 | lightningcss-linux-arm64-gnu@1.32.0: |
| 7427 | 7524 | resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} |
| 7428 | 7525 | engines: {node: '>= 12.0.0'} |
| 7429 | 7526 | cpu: [arm64] |
| 7430 | 7527 | os: [linux] |
| 7528 | libc: [glibc] | |
| 7431 | 7529 | |
| 7432 | 7530 | lightningcss-linux-arm64-musl@1.30.1: |
| 7433 | 7531 | resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} |
| 7434 | 7532 | engines: {node: '>= 12.0.0'} |
| 7435 | 7533 | cpu: [arm64] |
| 7436 | 7534 | os: [linux] |
| 7535 | libc: [musl] | |
| 7437 | 7536 | |
| 7438 | 7537 | lightningcss-linux-arm64-musl@1.31.1: |
| 7439 | 7538 | resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} |
| 7440 | 7539 | engines: {node: '>= 12.0.0'} |
| 7441 | 7540 | cpu: [arm64] |
| 7442 | 7541 | os: [linux] |
| 7542 | libc: [musl] | |
| 7443 | 7543 | |
| 7444 | 7544 | lightningcss-linux-arm64-musl@1.32.0: |
| 7445 | 7545 | resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} |
| 7446 | 7546 | engines: {node: '>= 12.0.0'} |
| 7447 | 7547 | cpu: [arm64] |
| 7448 | 7548 | os: [linux] |
| 7549 | libc: [musl] | |
| 7449 | 7550 | |
| 7450 | 7551 | lightningcss-linux-x64-gnu@1.30.1: |
| 7451 | 7552 | resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} |
| 7452 | 7553 | engines: {node: '>= 12.0.0'} |
| 7453 | 7554 | cpu: [x64] |
| 7454 | 7555 | os: [linux] |
| 7556 | libc: [glibc] | |
| 7455 | 7557 | |
| 7456 | 7558 | lightningcss-linux-x64-gnu@1.31.1: |
| 7457 | 7559 | resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} |
| 7458 | 7560 | engines: {node: '>= 12.0.0'} |
| 7459 | 7561 | cpu: [x64] |
| 7460 | 7562 | os: [linux] |
| 7563 | libc: [glibc] | |
| 7461 | 7564 | |
| 7462 | 7565 | lightningcss-linux-x64-gnu@1.32.0: |
| 7463 | 7566 | resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} |
| 7464 | 7567 | engines: {node: '>= 12.0.0'} |
| 7465 | 7568 | cpu: [x64] |
| 7466 | 7569 | os: [linux] |
| 7570 | libc: [glibc] | |
| 7467 | 7571 | |
| 7468 | 7572 | lightningcss-linux-x64-musl@1.30.1: |
| 7469 | 7573 | resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} |
| 7470 | 7574 | engines: {node: '>= 12.0.0'} |
| 7471 | 7575 | cpu: [x64] |
| 7472 | 7576 | os: [linux] |
| 7577 | libc: [musl] | |
| 7473 | 7578 | |
| 7474 | 7579 | lightningcss-linux-x64-musl@1.31.1: |
| 7475 | 7580 | resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} |
| 7476 | 7581 | engines: {node: '>= 12.0.0'} |
| 7477 | 7582 | cpu: [x64] |
| 7478 | 7583 | os: [linux] |
| 7584 | libc: [musl] | |
| 7479 | 7585 | |
| 7480 | 7586 | lightningcss-linux-x64-musl@1.32.0: |
| 7481 | 7587 | resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} |
| 7482 | 7588 | engines: {node: '>= 12.0.0'} |
| 7483 | 7589 | cpu: [x64] |
| 7484 | 7590 | os: [linux] |
| 7591 | libc: [musl] | |
| 7485 | 7592 | |
| 7486 | 7593 | lightningcss-win32-arm64-msvc@1.30.1: |
| 7487 | 7594 | resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} |
| ... | ... | @@ -14269,19 +14376,19 @@ snapshots: |
| 14269 | 14376 | '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3) |
| 14270 | 14377 | babel-plugin-react-compiler: 1.0.0 |
| 14271 | 14378 | |
| 14272 | '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))': | |
| 14379 | '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)': | |
| 14273 | 14380 | dependencies: |
| 14274 | 14381 | '@testing-library/dom': 10.4.1 |
| 14275 | 14382 | '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) |
| 14276 | '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))) | |
| 14277 | vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) | |
| 14383 | '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) | |
| 14384 | vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) | |
| 14278 | 14385 | transitivePeerDependencies: |
| 14279 | 14386 | - bufferutil |
| 14280 | 14387 | - msw |
| 14281 | 14388 | - utf-8-validate |
| 14282 | 14389 | - vite |
| 14283 | 14390 | |
| 14284 | '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))': | |
| 14391 | '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)': | |
| 14285 | 14392 | dependencies: |
| 14286 | 14393 | '@blazediff/core': 1.9.1 |
| 14287 | 14394 | '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) |
| ... | ... | @@ -14290,7 +14397,7 @@ snapshots: |
| 14290 | 14397 | pngjs: 7.0.0 |
| 14291 | 14398 | sirv: 3.0.2 |
| 14292 | 14399 | tinyrainbow: 3.1.0 |
| 14293 | vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) | |
| 14400 | vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) | |
| 14294 | 14401 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 14295 | 14402 | transitivePeerDependencies: |
| 14296 | 14403 | - bufferutil |
| ... | ... | @@ -19894,8 +20001,8 @@ snapshots: |
| 19894 | 20001 | dependencies: |
| 19895 | 20002 | '@oxc-project/types': 0.136.0 |
| 19896 | 20003 | '@oxlint/plugins': 1.68.0 |
| 19897 | '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))) | |
| 19898 | '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))) | |
| 20004 | '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) | |
| 20005 | '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) | |
| 19899 | 20006 | '@vitest/expect': 4.1.9 |
| 19900 | 20007 | '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) |
| 19901 | 20008 | '@vitest/pretty-format': 4.1.9 |
| ... | ... | @@ -19908,7 +20015,7 @@ snapshots: |
| 19908 | 20015 | oxlint: 1.70.0(oxlint-tsgolint@0.23.0)(vite-plus@0.2.1(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) |
| 19909 | 20016 | oxlint-tsgolint: 0.23.0 |
| 19910 | 20017 | vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' |
| 19911 | vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) | |
| 20018 | vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) | |
| 19912 | 20019 | optionalDependencies: |
| 19913 | 20020 | '@voidzero-dev/vite-plus-darwin-arm64': 0.2.1 |
| 19914 | 20021 | '@voidzero-dev/vite-plus-darwin-x64': 0.2.1 |
| ... | ... | @@ -19954,7 +20061,7 @@ snapshots: |
| 19954 | 20061 | optionalDependencies: |
| 19955 | 20062 | vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' |
| 19956 | 20063 | |
| 19957 | vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): | |
| 20064 | vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): | |
| 19958 | 20065 | dependencies: |
| 19959 | 20066 | '@vitest/expect': 4.1.9 |
| 19960 | 20067 | '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) |
| ... | ... | @@ -19978,7 +20085,7 @@ snapshots: |
| 19978 | 20085 | why-is-node-running: 2.3.0 |
| 19979 | 20086 | optionalDependencies: |
| 19980 | 20087 | '@types/node': 24.12.4 |
| 19981 | '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))) | |
| 20088 | '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) | |
| 19982 | 20089 | transitivePeerDependencies: |
| 19983 | 20090 | - msw |
| 19984 | 20091 |
pnpm-workspace.yaml+14| ... | ... | @@ -5,6 +5,20 @@ packages: |
| 5 | 5 | - packages/* |
| 6 | 6 | - scripts |
| 7 | 7 | |
| 8 | # Install both Windows and Linux native binaries so the WSL (Linux) backend | |
| 9 | # can load platform-gated optional deps (e.g. @yuuang/ffi-rs-linux-x64-gnu) | |
| 10 | # out of the same node_modules the Windows desktop uses. | |
| 11 | supportedArchitectures: | |
| 12 | os: | |
| 13 | - current | |
| 14 | - linux | |
| 15 | cpu: | |
| 16 | - current | |
| 17 | - x64 | |
| 18 | libc: | |
| 19 | - current | |
| 20 | - glibc | |
| 21 | ||
| 8 | 22 | catalog: |
| 9 | 23 | "@clerk/backend": 3.8.3-snapshot.v20260622234151 |
| 10 | 24 | "@clerk/clerk-js": 6.21.0-snapshot.v20260622234151 |
scripts/build-desktop-artifact.test.ts+36-1| ... | ... | @@ -210,10 +210,20 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 210 | 210 | cpu: ["x64"], |
| 211 | 211 | }, |
| 212 | 212 | }); |
| 213 | // Windows artifacts also bundle the same-architecture WSL (Linux, glibc) backend, so the | |
| 214 | // staged install must fetch its native optional deps (e.g. ffi-rs) too. | |
| 215 | assert.deepStrictEqual(createStageWorkspaceConfig("win", "x64"), { | |
| 216 | supportedArchitectures: { | |
| 217 | os: ["win32", "linux"], | |
| 218 | cpu: ["x64"], | |
| 219 | libc: ["glibc"], | |
| 220 | }, | |
| 221 | }); | |
| 213 | 222 | assert.deepStrictEqual(createStageWorkspaceConfig("win", "arm64"), { |
| 214 | 223 | supportedArchitectures: { |
| 215 | os: ["win32"], | |
| 224 | os: ["win32", "linux"], | |
| 216 | 225 | cpu: ["arm64"], |
| 226 | libc: ["glibc"], | |
| 217 | 227 | }, |
| 218 | 228 | }); |
| 219 | 229 | assert.deepStrictEqual(createStageWorkspaceConfig("mac", "universal"), { |
| ... | ... | @@ -400,6 +410,25 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 400 | 410 | }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), |
| 401 | 411 | ); |
| 402 | 412 | |
| 413 | it.effect("keeps executable resource editing enabled for unsigned Windows builds", () => | |
| 414 | Effect.gen(function* () { | |
| 415 | const config = yield* createBuildConfig( | |
| 416 | "win", | |
| 417 | "nsis", | |
| 418 | "1.2.3", | |
| 419 | false, | |
| 420 | false, | |
| 421 | undefined, | |
| 422 | undefined, | |
| 423 | ); | |
| 424 | ||
| 425 | const win = config.win as Record<string, unknown>; | |
| 426 | assert.equal(win.icon, "icon.ico"); | |
| 427 | assert.equal(win.signAndEditExecutable, true); | |
| 428 | assert.notProperty(win, "azureSignOptions"); | |
| 429 | }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), | |
| 430 | ); | |
| 431 | ||
| 403 | 432 | it("promotes target fff binaries to direct staged dependencies", () => { |
| 404 | 433 | assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), { |
| 405 | 434 | "@ff-labs/fff-bin-darwin-arm64": "0.9.4", |
| ... | ... | @@ -411,6 +440,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 411 | 440 | assert.deepStrictEqual(resolveFffNativeDependencies("win", "x64", "0.9.4"), { |
| 412 | 441 | "@ff-labs/fff-bin-win32-x64": "0.9.4", |
| 413 | 442 | }); |
| 443 | assert.deepStrictEqual(resolveFffNativeDependencies("linux", "x64", "0.9.4"), { | |
| 444 | "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4", | |
| 445 | "@ff-labs/fff-bin-linux-x64-musl": "0.9.4", | |
| 446 | }); | |
| 414 | 447 | assert.deepStrictEqual(resolveFffNativeDependencies("linux", "arm64", "0.9.4"), { |
| 415 | 448 | "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", |
| 416 | 449 | "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", |
| ... | ... | @@ -496,6 +529,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 496 | 529 | verbose: Option.none(), |
| 497 | 530 | mockUpdates: Option.none(), |
| 498 | 531 | mockUpdateServerPort: Option.none(), |
| 532 | wslPrebuild: Option.none(), | |
| 499 | 533 | }).pipe( |
| 500 | 534 | Effect.provide( |
| 501 | 535 | Layer.mergeAll( |
| ... | ... | @@ -533,6 +567,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 533 | 567 | verbose: Option.some(false), |
| 534 | 568 | mockUpdates: Option.some(false), |
| 535 | 569 | mockUpdateServerPort: Option.none(), |
| 570 | wslPrebuild: Option.none(), | |
| 536 | 571 | }).pipe( |
| 537 | 572 | Effect.provide( |
| 538 | 573 | ConfigProvider.layer( |
scripts/build-desktop-artifact.ts+171-5| ... | ... | @@ -47,6 +47,7 @@ const StageWorkspaceConfig = Schema.Struct({ |
| 47 | 47 | supportedArchitectures: Schema.Struct({ |
| 48 | 48 | os: Schema.Array(Schema.String), |
| 49 | 49 | cpu: Schema.Array(Schema.String), |
| 50 | libc: Schema.optional(Schema.Array(Schema.String)), | |
| 50 | 51 | }), |
| 51 | 52 | }); |
| 52 | 53 | |
| ... | ... | @@ -55,6 +56,9 @@ const RepoRoot = Effect.service(Path.Path).pipe( |
| 55 | 56 | ); |
| 56 | 57 | const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); |
| 57 | 58 | const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); |
| 59 | const decodeNodePtyManifest = Schema.decodeUnknownEffect( | |
| 60 | Schema.fromJsonString(Schema.Struct({ version: Schema.String })), | |
| 61 | ); | |
| 58 | 62 | const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig)); |
| 59 | 63 | |
| 60 | 64 | const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { |
| ... | ... | @@ -107,6 +111,7 @@ interface BuildCliInput { |
| 107 | 111 | readonly verbose: Option.Option<boolean>; |
| 108 | 112 | readonly mockUpdates: Option.Option<boolean>; |
| 109 | 113 | readonly mockUpdateServerPort: Option.Option<number>; |
| 114 | readonly wslPrebuild: Option.Option<string>; | |
| 110 | 115 | } |
| 111 | 116 | |
| 112 | 117 | function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { |
| ... | ... | @@ -360,6 +365,29 @@ export class DesktopBuildNoArtifactsProducedError extends Schema.TaggedErrorClas |
| 360 | 365 | } |
| 361 | 366 | } |
| 362 | 367 | |
| 368 | export class WslNodePtyPrebuildMissingError extends Schema.TaggedErrorClass<WslNodePtyPrebuildMissingError>()( | |
| 369 | "WslNodePtyPrebuildMissingError", | |
| 370 | { | |
| 371 | prebuildPath: Schema.String, | |
| 372 | }, | |
| 373 | ) { | |
| 374 | override get message(): string { | |
| 375 | return `WSL node-pty prebuild not found at ${this.prebuildPath}.`; | |
| 376 | } | |
| 377 | } | |
| 378 | ||
| 379 | export class WslNodePtyManifestReadError extends Schema.TaggedErrorClass<WslNodePtyManifestReadError>()( | |
| 380 | "WslNodePtyManifestReadError", | |
| 381 | { | |
| 382 | manifestPath: Schema.String, | |
| 383 | cause: Schema.Defect(), | |
| 384 | }, | |
| 385 | ) { | |
| 386 | override get message(): string { | |
| 387 | return `Could not read node-pty version from ${this.manifestPath}.`; | |
| 388 | } | |
| 389 | } | |
| 390 | ||
| 363 | 391 | export class LinuxIconResizeError extends Schema.TaggedErrorClass<LinuxIconResizeError>()( |
| 364 | 392 | "LinuxIconResizeError", |
| 365 | 393 | { |
| ... | ... | @@ -517,6 +545,7 @@ interface ResolvedBuildOptions { |
| 517 | 545 | readonly verbose: boolean; |
| 518 | 546 | readonly mockUpdates: boolean; |
| 519 | 547 | readonly mockUpdateServerPort: number | undefined; |
| 548 | readonly wslPrebuild: string | undefined; | |
| 520 | 549 | } |
| 521 | 550 | |
| 522 | 551 | interface StagePackageJson { |
| ... | ... | @@ -851,10 +880,26 @@ export function createStageWorkspaceConfig( |
| 851 | 880 | platform: typeof BuildPlatform.Type, |
| 852 | 881 | arch: typeof BuildArch.Type, |
| 853 | 882 | ): typeof StageWorkspaceConfig.Type { |
| 883 | const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"; | |
| 884 | const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch]; | |
| 885 | // Windows artifacts also bundle the same-architecture WSL Linux backend, which loads | |
| 886 | // Linux-native optional deps at runtime (e.g. @yuuang/ffi-rs-linux-x64-gnu). | |
| 887 | // Pull the Linux (glibc) variants in addition to the host platform's so | |
| 888 | // they ship in the asar; without them the WSL backend crash-loops on require | |
| 889 | // ("Cannot find module '@yuuang/ffi-rs-linux-x64-gnu'"). | |
| 890 | if (platform === "win") { | |
| 891 | return { | |
| 892 | supportedArchitectures: { | |
| 893 | os: Array.from(new Set([hostOs, "linux"])), | |
| 894 | cpu: hostCpu, | |
| 895 | libc: ["glibc"], | |
| 896 | }, | |
| 897 | }; | |
| 898 | } | |
| 854 | 899 | return { |
| 855 | 900 | supportedArchitectures: { |
| 856 | os: [platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"], | |
| 857 | cpu: arch === "universal" ? ["arm64", "x64"] : [arch], | |
| 901 | os: [hostOs], | |
| 902 | cpu: hostCpu, | |
| 858 | 903 | }, |
| 859 | 904 | }; |
| 860 | 905 | } |
| ... | ... | @@ -905,6 +950,11 @@ const BuildEnvConfig = Config.all({ |
| 905 | 950 | verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)), |
| 906 | 951 | mockUpdates: Config.boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)), |
| 907 | 952 | mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option), |
| 953 | // Path to a prebuilt Linux node-pty binary (pty.node) for the target arch, | |
| 954 | // produced by the Linux CI job and handed to the Windows packaging job. Placed | |
| 955 | // into the staged node-pty so the WSL backend ships a ready binary and never | |
| 956 | // compiles on the user's machine. | |
| 957 | wslPrebuild: Config.string("T3CODE_DESKTOP_WSL_PREBUILD").pipe(Config.option), | |
| 908 | 958 | }); |
| 909 | 959 | |
| 910 | 960 | const MockUpdateServerPortSchema = Schema.NumberFromString.check( |
| ... | ... | @@ -988,6 +1038,9 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( |
| 988 | 1038 | ), |
| 989 | 1039 | )); |
| 990 | 1040 | |
| 1041 | const wslPrebuild = | |
| 1042 | Option.getOrUndefined(input.wslPrebuild) ?? Option.getOrUndefined(env.wslPrebuild); | |
| 1043 | ||
| 991 | 1044 | return { |
| 992 | 1045 | platform, |
| 993 | 1046 | target, |
| ... | ... | @@ -1000,6 +1053,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( |
| 1000 | 1053 | verbose, |
| 1001 | 1054 | mockUpdates, |
| 1002 | 1055 | mockUpdateServerPort, |
| 1056 | wslPrebuild, | |
| 1003 | 1057 | } satisfies ResolvedBuildOptions; |
| 1004 | 1058 | }); |
| 1005 | 1059 | |
| ... | ... | @@ -1300,10 +1354,23 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 1300 | 1354 | appId: DESKTOP_APP_ID, |
| 1301 | 1355 | productName: resolveDesktopProductName(version), |
| 1302 | 1356 | artifactName: "T3-Code-${version}-${arch}.${ext}", |
| 1303 | asarUnpack: [...DESKTOP_ASAR_UNPACK], | |
| 1304 | 1357 | directories: { |
| 1305 | 1358 | buildResources: "apps/desktop/resources", |
| 1306 | 1359 | }, |
| 1360 | // The Windows primary backend runs the server bundle through | |
| 1361 | // ELECTRON_RUN_AS_NODE (asar-aware), so it reads bin.mjs straight out of | |
| 1362 | // app.asar. The WSL backend instead launches plain `wsl.exe -- node`, which | |
| 1363 | // cannot read inside an asar archive, so everything it loads must be on the | |
| 1364 | // real filesystem. The server bundle externalizes its runtime dependencies | |
| 1365 | // (effect, @effect/*, node-pty, ...) to node_modules rather than inlining | |
| 1366 | // them, so unpacking just the bundle + node-pty isn't enough — the Linux Node | |
| 1367 | // fails with ERR_MODULE_NOT_FOUND (e.g. "Cannot find package 'effect'") before | |
| 1368 | // it even reaches node-pty. Unpack the server bundle AND the whole | |
| 1369 | // node_modules tree so every import resolves (this also covers the fff native | |
| 1370 | // binaries in DESKTOP_ASAR_UNPACK). The Windows primary keeps reading the same | |
| 1371 | // files through the asar (transparently redirected to the unpacked copy), so | |
| 1372 | // there's no duplication. | |
| 1373 | asarUnpack: [...DESKTOP_ASAR_UNPACK, "apps/server/dist/**", "**/node_modules/**"], | |
| 1307 | 1374 | }; |
| 1308 | 1375 | const updateChannel = resolveDesktopUpdateChannel(version); |
| 1309 | 1376 | const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); |
| ... | ... | @@ -1357,11 +1424,13 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 1357 | 1424 | const winConfig: Record<string, unknown> = { |
| 1358 | 1425 | target: [target], |
| 1359 | 1426 | icon: "icon.ico", |
| 1427 | // Resource editing applies the product metadata and icon independently | |
| 1428 | // of code signing. Disabling it for local unsigned builds leaves the | |
| 1429 | // packaged executable with Electron's stock icon. | |
| 1430 | signAndEditExecutable: true, | |
| 1360 | 1431 | }; |
| 1361 | 1432 | if (signed) { |
| 1362 | 1433 | winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig; |
| 1363 | } else { | |
| 1364 | winConfig.signAndEditExecutable = false; | |
| 1365 | 1434 | } |
| 1366 | 1435 | buildConfig.win = winConfig; |
| 1367 | 1436 | } |
| ... | ... | @@ -1390,6 +1459,76 @@ const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(f |
| 1390 | 1459 | } |
| 1391 | 1460 | }); |
| 1392 | 1461 | |
| 1462 | // Stage the prebuilt Linux node-pty binary into the packaged app so the WSL | |
| 1463 | // backend never compiles on the user's machine. node-pty publishes no Linux | |
| 1464 | // prebuilt and the WSL Linux Node can't load the Windows/Electron binary, so the | |
| 1465 | // Linux CI job builds pty.node and hands it here. We drop it into the staged | |
| 1466 | // node-pty's prebuilds/linux-<arch>/ with a t3code marker the WSL preflight | |
| 1467 | // checks (arch + node-pty version; the binary is N-API, hence ABI-stable across | |
| 1468 | // Node versions). A missing prebuild is a warning, not an error, so local and | |
| 1469 | // non-Windows builds still succeed — they just won't ship a working WSL backend. | |
| 1470 | const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* (input: { | |
| 1471 | readonly stageAppDir: string; | |
| 1472 | readonly arch: typeof BuildArch.Type; | |
| 1473 | readonly prebuildPath: string | undefined; | |
| 1474 | }) { | |
| 1475 | const fs = yield* FileSystem.FileSystem; | |
| 1476 | const path = yield* Path.Path; | |
| 1477 | ||
| 1478 | if (input.prebuildPath === undefined) { | |
| 1479 | yield* Effect.logWarning( | |
| 1480 | "[desktop-artifact] No WSL node-pty prebuild provided (--wsl-prebuild / T3CODE_DESKTOP_WSL_PREBUILD); the packaged WSL backend will not start until a Linux pty.node is bundled.", | |
| 1481 | ); | |
| 1482 | return; | |
| 1483 | } | |
| 1484 | ||
| 1485 | // WSL runs the same CPU arch as the Windows host; universal is mac-only. | |
| 1486 | const linuxArch = input.arch === "x64" ? "x64" : input.arch === "arm64" ? "arm64" : undefined; | |
| 1487 | if (linuxArch === undefined) { | |
| 1488 | yield* Effect.logWarning( | |
| 1489 | `[desktop-artifact] No WSL node-pty prebuild mapping for arch "${input.arch}"; skipping WSL backend bundling.`, | |
| 1490 | ); | |
| 1491 | return; | |
| 1492 | } | |
| 1493 | ||
| 1494 | const prebuildExists = yield* fs | |
| 1495 | .exists(input.prebuildPath) | |
| 1496 | .pipe(Effect.orElseSucceed(() => false)); | |
| 1497 | if (!prebuildExists) { | |
| 1498 | return yield* new WslNodePtyPrebuildMissingError({ | |
| 1499 | prebuildPath: input.prebuildPath, | |
| 1500 | }); | |
| 1501 | } | |
| 1502 | ||
| 1503 | // Resolve through the (pnpm) symlink so we write into the stage's own node-pty | |
| 1504 | // copy, never a shared content-addressable store. | |
| 1505 | const nodePtyLink = path.join(input.stageAppDir, "node_modules", "node-pty"); | |
| 1506 | const nodePtyDir = yield* fs.realPath(nodePtyLink).pipe(Effect.orElseSucceed(() => nodePtyLink)); | |
| 1507 | ||
| 1508 | const manifestPath = path.join(nodePtyDir, "package.json"); | |
| 1509 | const pkgRaw = yield* fs.readFileString(manifestPath); | |
| 1510 | const manifest = yield* decodeNodePtyManifest(pkgRaw).pipe( | |
| 1511 | Effect.mapError( | |
| 1512 | (cause) => | |
| 1513 | new WslNodePtyManifestReadError({ | |
| 1514 | manifestPath, | |
| 1515 | cause, | |
| 1516 | }), | |
| 1517 | ), | |
| 1518 | ); | |
| 1519 | const nodePtyVersion = manifest.version; | |
| 1520 | ||
| 1521 | const prebuildDir = path.join(nodePtyDir, "prebuilds", `linux-${linuxArch}`); | |
| 1522 | yield* fs.makeDirectory(prebuildDir, { recursive: true }); | |
| 1523 | yield* fs.copyFile(input.prebuildPath, path.join(prebuildDir, "pty.node")); | |
| 1524 | const markerJson = yield* encodeJsonString({ arch: linuxArch, nodePtyVersion }); | |
| 1525 | yield* fs.writeFileString(path.join(prebuildDir, "t3code-wsl-node-pty.json"), `${markerJson}\n`); | |
| 1526 | ||
| 1527 | yield* Effect.log( | |
| 1528 | `[desktop-artifact] Staged WSL node-pty prebuild (linux-${linuxArch}, node-pty ${nodePtyVersion}).`, | |
| 1529 | ); | |
| 1530 | }); | |
| 1531 | ||
| 1393 | 1532 | const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 1394 | 1533 | options: ResolvedBuildOptions, |
| 1395 | 1534 | ) { |
| ... | ... | @@ -1558,6 +1697,17 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 1558 | 1697 | options.arch, |
| 1559 | 1698 | serverPackageJson.dependencies["@ff-labs/fff-node"], |
| 1560 | 1699 | ), |
| 1700 | // Windows artifacts also bundle the same-architecture WSL Linux backend, which loads the | |
| 1701 | // fff native binary through ffi-rs. The platform fff binary above is the | |
| 1702 | // host's (win32), so promote the matching Linux fff binaries too; without | |
| 1703 | // them file-finding in WSL fails to load its Linux native package. | |
| 1704 | ...(options.platform === "win" | |
| 1705 | ? resolveFffNativeDependencies( | |
| 1706 | "linux", | |
| 1707 | options.arch, | |
| 1708 | serverPackageJson.dependencies["@ff-labs/fff-node"], | |
| 1709 | ) | |
| 1710 | : {}), | |
| 1561 | 1711 | }; |
| 1562 | 1712 | const stagePnpmConfig = createStagePnpmConfig(workspacePatchedDependencies, stageDependencies); |
| 1563 | 1713 | const stagePackageJson: StagePackageJson = { |
| ... | ... | @@ -1616,6 +1766,16 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 1616 | 1766 | ); |
| 1617 | 1767 | yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch); |
| 1618 | 1768 | |
| 1769 | // WSL is Windows-only, so only the Windows artifact carries the Linux backend | |
| 1770 | // binary; other platforms ignore the prebuild input. | |
| 1771 | if (options.platform === "win") { | |
| 1772 | yield* stageWslNodePtyPrebuild({ | |
| 1773 | stageAppDir, | |
| 1774 | arch: options.arch, | |
| 1775 | prebuildPath: options.wslPrebuild, | |
| 1776 | }); | |
| 1777 | } | |
| 1778 | ||
| 1619 | 1779 | // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") |
| 1620 | 1780 | // as enabled, so copy the host env and scrub empty values instead of relying |
| 1621 | 1781 | // on `extendEnv` merging. |
| ... | ... | @@ -1769,6 +1929,12 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { |
| 1769 | 1929 | Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."), |
| 1770 | 1930 | Flag.optional, |
| 1771 | 1931 | ), |
| 1932 | wslPrebuild: Flag.string("wsl-prebuild").pipe( | |
| 1933 | Flag.withDescription( | |
| 1934 | "Path to a prebuilt Linux node-pty (pty.node) for the target arch, staged for the WSL backend (env: T3CODE_DESKTOP_WSL_PREBUILD).", | |
| 1935 | ), | |
| 1936 | Flag.optional, | |
| 1937 | ), | |
| 1772 | 1938 | }).pipe( |
| 1773 | 1939 | Command.withDescription("Build a desktop artifact for T3 Code."), |
| 1774 | 1940 | Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)), |