| 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,10 +252,64 @@ jobs: |
| 252 | echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" | 252 | echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" |
| 253 | echo "relay_url=https://$relay_domain" >> "$GITHUB_OUTPUT" | 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 | build: | 302 | build: |
| 256 | name: Build ${{ matrix.label }} | 303 | name: Build ${{ matrix.label }} |
| 257 | needs: [preflight, relay_public_config] | 304 | # build_wsl_node_pty stays in `needs` so it runs first and its artifact is |
| 258 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} | 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 | runs-on: ${{ matrix.runner }} | 313 | runs-on: ${{ matrix.runner }} |
| 260 | timeout-minutes: 30 | 314 | timeout-minutes: 30 |
| 261 | env: | 315 | env: |
| ... | @@ -323,6 +377,13 @@ jobs: | ... | @@ -323,6 +377,13 @@ jobs: |
| 323 | - name: Align package versions to release version | 377 | - name: Align package versions to release version |
| 324 | run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" | 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 | - name: Install Spectre-mitigated MSVC libs | 387 | - name: Install Spectre-mitigated MSVC libs |
| 327 | if: matrix.platform == 'win' | 388 | if: matrix.platform == 'win' |
| 328 | shell: pwsh | 389 | shell: pwsh |
| ... | @@ -476,6 +537,10 @@ jobs: | ... | @@ -476,6 +537,10 @@ jobs: |
| 476 | echo "macOS signing disabled (missing one or more Apple signing secrets)." | 537 | echo "macOS signing disabled (missing one or more Apple signing secrets)." |
| 477 | fi | 538 | fi |
| 478 | elif [[ "${{ matrix.platform }}" == "win" ]]; then | 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 | if has_all \ | 544 | if has_all \ |
| 480 | "$AZURE_TENANT_ID" \ | 545 | "$AZURE_TENANT_ID" \ |
| 481 | "$AZURE_CLIENT_ID" \ | 546 | "$AZURE_CLIENT_ID" \ |
apps/desktop/src/app/DesktopApp.ts+32-5| ... | @@ -13,7 +13,8 @@ import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; | ... | @@ -13,7 +13,8 @@ import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; |
| 13 | import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; | 13 | import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; |
| 14 | import * as DesktopClerk from "./DesktopClerk.ts"; | 14 | import * as DesktopClerk from "./DesktopClerk.ts"; |
| 15 | import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; | 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 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | 18 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 18 | import * as DesktopLifecycle from "./DesktopLifecycle.ts"; | 19 | import * as DesktopLifecycle from "./DesktopLifecycle.ts"; |
| 19 | import * as DesktopObservability from "./DesktopObservability.ts"; | 20 | import * as DesktopObservability from "./DesktopObservability.ts"; |
| ... | @@ -23,6 +24,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | ... | @@ -23,6 +24,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 23 | import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; | 24 | import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; |
| 24 | import * as DesktopState from "./DesktopState.ts"; | 25 | import * as DesktopState from "./DesktopState.ts"; |
| 25 | import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; | 26 | import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; |
| 27 | import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; | ||
| 26 | 28 | ||
| 27 | const DEFAULT_DESKTOP_BACKEND_PORT = 3773; | 29 | const DEFAULT_DESKTOP_BACKEND_PORT = 3773; |
| 28 | const MAX_TCP_PORT = 65_535; | 30 | const MAX_TCP_PORT = 65_535; |
| ... | @@ -135,11 +137,14 @@ const fatalStartupCause = <E>(stage: string, cause: Cause.Cause<E>) => | ... | @@ -135,11 +137,14 @@ const fatalStartupCause = <E>(stage: string, cause: Cause.Cause<E>) => |
| 135 | handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); | 137 | handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); |
| 136 | 138 | ||
| 137 | const bootstrap = Effect.gen(function* () { | 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 | const state = yield* DesktopState.DesktopState; | 142 | const state = yield* DesktopState.DesktopState; |
| 140 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | 143 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 141 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; | 144 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| 142 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | 145 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; |
| 146 | const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; | ||
| 147 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 143 | yield* logBootstrapInfo("bootstrap start"); | 148 | yield* logBootstrapInfo("bootstrap start"); |
| 144 | 149 | ||
| 145 | if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { | 150 | if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { |
| ... | @@ -193,8 +198,20 @@ const bootstrap = Effect.gen(function* () { | ... | @@ -193,8 +198,20 @@ const bootstrap = Effect.gen(function* () { |
| 193 | yield* logBootstrapInfo("bootstrap ipc handlers registered"); | 198 | yield* logBootstrapInfo("bootstrap ipc handlers registered"); |
| 194 | 199 | ||
| 195 | if (!(yield* Ref.get(state.quitting))) { | 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 | yield* logBootstrapInfo("bootstrap backend start requested"); | 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 | }).pipe(Effect.withSpan("desktop.bootstrap")); | 216 | }).pipe(Effect.withSpan("desktop.bootstrap")); |
| 200 | 217 | ||
| ... | @@ -241,10 +258,20 @@ const scopedProgram = Effect.scoped( | ... | @@ -241,10 +258,20 @@ const scopedProgram = Effect.scoped( |
| 241 | yield* Effect.annotateCurrentSpan({ scope: "desktop", runId }); | 258 | yield* Effect.annotateCurrentSpan({ scope: "desktop", runId }); |
| 242 | 259 | ||
| 243 | const shutdown = yield* DesktopShutdown.DesktopShutdown; | 260 | const shutdown = yield* DesktopShutdown.DesktopShutdown; |
| 244 | const backendManager = yield* DesktopBackendManager.DesktopBackendManager; | ||
| 245 | 261 | ||
| 246 | yield* Effect.addFinalizer(() => | 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 | yield* startup; | 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,7 +125,8 @@ describe("DesktopObservability", () => { |
| 125 | }).pipe(Effect.provide(environmentLayer)); | 125 | }).pipe(Effect.provide(environmentLayer)); |
| 126 | 126 | ||
| 127 | yield* Effect.gen(function* () { | 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 | yield* outputLog.writeSessionBoundary({ | 130 | yield* outputLog.writeSessionBoundary({ |
| 130 | phase: "START", | 131 | phase: "START", |
| 131 | details: "pid=123 port=3773 cwd=/repo", | 132 | details: "pid=123 port=3773 cwd=/repo", |
| ... | @@ -145,6 +146,7 @@ describe("DesktopObservability", () => { | ... | @@ -145,6 +146,7 @@ describe("DesktopObservability", () => { |
| 145 | assert.equal(boundary.level, "INFO"); | 146 | assert.equal(boundary.level, "INFO"); |
| 146 | assert.equal(boundary.annotations.component, "desktop-backend-child"); | 147 | assert.equal(boundary.annotations.component, "desktop-backend-child"); |
| 147 | assert.equal(boundary.annotations.runId, "test-run"); | 148 | assert.equal(boundary.annotations.runId, "test-run"); |
| 149 | assert.equal(boundary.annotations.instanceId, "primary"); | ||
| 148 | assert.equal(boundary.annotations.phase, "START"); | 150 | assert.equal(boundary.annotations.phase, "START"); |
| 149 | assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo"); | 151 | assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo"); |
| 150 | 152 | ||
| ... | @@ -152,6 +154,7 @@ describe("DesktopObservability", () => { | ... | @@ -152,6 +154,7 @@ describe("DesktopObservability", () => { |
| 152 | assert.equal(output.level, "INFO"); | 154 | assert.equal(output.level, "INFO"); |
| 153 | assert.equal(output.annotations.component, "desktop-backend-child"); | 155 | assert.equal(output.annotations.component, "desktop-backend-child"); |
| 154 | assert.equal(output.annotations.runId, "test-run"); | 156 | assert.equal(output.annotations.runId, "test-run"); |
| 157 | assert.equal(output.annotations.instanceId, "primary"); | ||
| 155 | assert.equal(output.annotations.stream, "stdout"); | 158 | assert.equal(output.annotations.stream, "stdout"); |
| 156 | assert.equal(output.annotations.text, "hello server\n"); | 159 | assert.equal(output.annotations.text, "hello server\n"); |
| 157 | }).pipe( | 160 | }).pipe( |
apps/desktop/src/app/DesktopObservability.ts+376-8| ... | @@ -1,20 +1,65 @@ | ... | @@ -1,20 +1,65 @@ |
| 1 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | ||
| 1 | import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; | 2 | import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; |
| 2 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; | 3 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 4 | import * as Context from "effect/Context"; | ||
| 5 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 7 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 6 | import * as Logger from "effect/Logger"; | 9 | import * as Logger from "effect/Logger"; |
| 7 | import * as Option from "effect/Option"; | 10 | import * as Option from "effect/Option"; |
| 11 | import * as Path from "effect/Path"; | ||
| 12 | import * as PlatformError from "effect/PlatformError"; | ||
| 8 | import * as References from "effect/References"; | 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 | import * as Tracer from "effect/Tracer"; | 19 | import * as Tracer from "effect/Tracer"; |
| 10 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | 20 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; |
| 11 | 21 | ||
| 12 | import * as DesktopBackendOutputLogModule from "./DesktopBackendOutputLog.ts"; | ||
| 13 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | 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 | const DESKTOP_TRACE_BATCH_WINDOW_MS = 200; | 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 | export type DesktopLogAnnotations = Record<string, unknown>; | 64 | export type DesktopLogAnnotations = Record<string, unknown>; |
| 20 | 65 | ||
| ... | @@ -50,7 +95,166 @@ export function makeComponentLogger(component: string): DesktopComponentLogger { | ... | @@ -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 | const fileSystem = yield* FileSystem.FileSystem; | 258 | const fileSystem = yield* FileSystem.FileSystem; |
| 55 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | 259 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 56 | const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); | 260 | const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); |
| ... | @@ -70,6 +274,170 @@ const resolveOtlpTracesUrl = Effect.gen(function* () { | ... | @@ -70,6 +274,170 @@ const resolveOtlpTracesUrl = Effect.gen(function* () { |
| 70 | return yield* readPersistedOtlpTracesUrl; | 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 | const desktopLoggerLayer = Layer.mergeAll( | 441 | const desktopLoggerLayer = Layer.mergeAll( |
| 74 | Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }), | 442 | Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }), |
| 75 | Layer.succeed(References.MinimumLogLevel, "Info"), | 443 | Layer.succeed(References.MinimumLogLevel, "Info"), |
| ... | @@ -82,8 +450,8 @@ const tracerLayer = Layer.unwrap( | ... | @@ -82,8 +450,8 @@ const tracerLayer = Layer.unwrap( |
| 82 | const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson"); | 450 | const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson"); |
| 83 | const sink = yield* makeTraceSink({ | 451 | const sink = yield* makeTraceSink({ |
| 84 | filePath: tracePath, | 452 | filePath: tracePath, |
| 85 | maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES, | 453 | maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, |
| 86 | maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES, | 454 | maxFiles: DESKTOP_LOG_FILE_MAX_FILES, |
| 87 | batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, | 455 | batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, |
| 88 | }); | 456 | }); |
| 89 | const delegate = Option.isNone(otlpTracesUrl) | 457 | const delegate = Option.isNone(otlpTracesUrl) |
| ... | @@ -101,8 +469,8 @@ const tracerLayer = Layer.unwrap( | ... | @@ -101,8 +469,8 @@ const tracerLayer = Layer.unwrap( |
| 101 | }); | 469 | }); |
| 102 | const tracer = yield* makeLocalFileTracer({ | 470 | const tracer = yield* makeLocalFileTracer({ |
| 103 | filePath: tracePath, | 471 | filePath: tracePath, |
| 104 | maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES, | 472 | maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, |
| 105 | maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES, | 473 | maxFiles: DESKTOP_LOG_FILE_MAX_FILES, |
| 106 | batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, | 474 | batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, |
| 107 | sink, | 475 | sink, |
| 108 | ...(delegate ? { delegate } : {}), | 476 | ...(delegate ? { delegate } : {}), |
| ... | @@ -113,7 +481,7 @@ const tracerLayer = Layer.unwrap( | ... | @@ -113,7 +481,7 @@ const tracerLayer = Layer.unwrap( |
| 113 | ).pipe(Layer.provideMerge(OtlpSerialization.layerJson)); | 481 | ).pipe(Layer.provideMerge(OtlpSerialization.layerJson)); |
| 114 | 482 | ||
| 115 | export const layer = Layer.mergeAll( | 483 | export const layer = Layer.mergeAll( |
| 116 | DesktopBackendOutputLogModule.layer, | 484 | backendOutputLogFactoryLayer, |
| 117 | desktopLoggerLayer, | 485 | desktopLoggerLayer, |
| 118 | tracerLayer, | 486 | tracerLayer, |
| 119 | Layer.succeed(Tracer.MinimumTraceLevel, "Info"), | 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,13 +4,19 @@ import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Logger from "effect/Logger"; | 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 | import * as PlatformError from "effect/PlatformError"; | 10 | import * as PlatformError from "effect/PlatformError"; |
| 8 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 12 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 9 | 13 | ||
| 10 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 14 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 11 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | 15 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; |
| 12 | import * as DesktopConfig from "../app/DesktopConfig.ts"; | 16 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 13 | import * as DesktopServerExposure from "./DesktopServerExposure.ts"; | 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 | const PersistedServerObservabilitySettingsDocument = Schema.Struct({ | 21 | const PersistedServerObservabilitySettingsDocument = Schema.Struct({ |
| 16 | observability: Schema.Struct({ | 22 | observability: Schema.Struct({ |
| ... | @@ -45,19 +51,22 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp | ... | @@ -45,19 +51,22 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp |
| 45 | function makeEnvironmentLayer( | 51 | function makeEnvironmentLayer( |
| 46 | baseDir: string, | 52 | baseDir: string, |
| 47 | options?: { | 53 | options?: { |
| 54 | readonly appPath?: string; | ||
| 48 | readonly isPackaged?: boolean; | 55 | readonly isPackaged?: boolean; |
| 49 | readonly devServerUrl?: string; | 56 | readonly devServerUrl?: string; |
| 57 | readonly platform?: NodeJS.Platform; | ||
| 58 | readonly resourcesPath?: string; | ||
| 50 | }, | 59 | }, |
| 51 | ) { | 60 | ) { |
| 52 | return DesktopEnvironment.layer({ | 61 | return DesktopEnvironment.layer({ |
| 53 | dirname: "/repo/apps/desktop/src", | 62 | dirname: "/repo/apps/desktop/src", |
| 54 | homeDirectory: baseDir, | 63 | homeDirectory: baseDir, |
| 55 | platform: "darwin", | 64 | platform: options?.platform ?? "darwin", |
| 56 | processArch: "x64", | 65 | processArch: "x64", |
| 57 | appVersion: "1.2.3", | 66 | appVersion: "1.2.3", |
| 58 | appPath: "/repo", | 67 | appPath: options?.appPath ?? "/repo", |
| 59 | isPackaged: options?.isPackaged ?? true, | 68 | isPackaged: options?.isPackaged ?? true, |
| 60 | resourcesPath: "/missing/resources", | 69 | resourcesPath: options?.resourcesPath ?? "/missing/resources", |
| 61 | runningUnderArm64Translation: false, | 70 | runningUnderArm64Translation: false, |
| 62 | }).pipe( | 71 | }).pipe( |
| 63 | Layer.provide( | 72 | Layer.provide( |
| ... | @@ -75,6 +84,14 @@ function makeEnvironmentLayer( | ... | @@ -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 | const withHarness = <A, E, R>( | 95 | const withHarness = <A, E, R>( |
| 79 | effect: Effect.Effect< | 96 | effect: Effect.Effect< |
| 80 | A, | 97 | A, |
| ... | @@ -95,6 +112,8 @@ const withHarness = <A, E, R>( | ... | @@ -95,6 +112,8 @@ const withHarness = <A, E, R>( |
| 95 | Effect.provide( | 112 | Effect.provide( |
| 96 | DesktopBackendConfiguration.layer.pipe( | 113 | DesktopBackendConfiguration.layer.pipe( |
| 97 | Layer.provideMerge(serverExposureLayer), | 114 | Layer.provideMerge(serverExposureLayer), |
| 115 | Layer.provideMerge(DesktopAppSettings.layerTest()), | ||
| 116 | Layer.provideMerge(DesktopWslEnvironment.layerTest()), | ||
| 98 | Layer.provideMerge(makeEnvironmentLayer(baseDir)), | 117 | Layer.provideMerge(makeEnvironmentLayer(baseDir)), |
| 99 | ), | 118 | ), |
| 100 | ), | 119 | ), |
| ... | @@ -102,14 +121,14 @@ const withHarness = <A, E, R>( | ... | @@ -102,14 +121,14 @@ const withHarness = <A, E, R>( |
| 102 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); | 121 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); |
| 103 | 122 | ||
| 104 | describe("DesktopBackendConfiguration", () => { | 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 | withHarness( | 125 | withHarness( |
| 107 | Effect.gen(function* () { | 126 | Effect.gen(function* () { |
| 108 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | 127 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 109 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | 128 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| 110 | 129 | ||
| 111 | const first = yield* configuration.resolve; | 130 | const first = yield* configuration.resolvePrimary; |
| 112 | const second = yield* configuration.resolve; | 131 | const second = yield* configuration.resolvePrimary; |
| 113 | 132 | ||
| 114 | assert.equal(first.executablePath, process.execPath); | 133 | assert.equal(first.executablePath, process.execPath); |
| 115 | assert.equal(first.entryPath, environment.backendEntryPath); | 134 | assert.equal(first.entryPath, environment.backendEntryPath); |
| ... | @@ -133,7 +152,167 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -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 | withHarness( | 316 | withHarness( |
| 138 | Effect.gen(function* () { | 317 | Effect.gen(function* () { |
| 139 | const fileSystem = yield* FileSystem.FileSystem; | 318 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | @@ -153,18 +332,18 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -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 | assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); | 336 | assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); |
| 158 | assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); | 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 | withHarness( | 343 | withHarness( |
| 165 | Effect.gen(function* () { | 344 | Effect.gen(function* () { |
| 166 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | 345 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| 167 | const config = yield* configuration.resolve; | 346 | const config = yield* configuration.resolvePrimary; |
| 168 | 347 | ||
| 169 | assert.isUndefined(config.bootstrap.otlpTracesUrl); | 348 | assert.isUndefined(config.bootstrap.otlpTracesUrl); |
| 170 | assert.isUndefined(config.bootstrap.otlpMetricsUrl); | 349 | assert.isUndefined(config.bootstrap.otlpMetricsUrl); |
| ... | @@ -175,10 +354,11 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -175,10 +354,11 @@ describe("DesktopBackendConfiguration", () => { |
| 175 | it.effect("logs structured context when persisted observability settings cannot be read", () => | 354 | it.effect("logs structured context when persisted observability settings cannot be read", () => |
| 176 | Effect.gen(function* () { | 355 | Effect.gen(function* () { |
| 177 | const fileSystem = yield* FileSystem.FileSystem; | 356 | const fileSystem = yield* FileSystem.FileSystem; |
| 357 | const path = yield* Path.Path; | ||
| 178 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | 358 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ |
| 179 | prefix: "t3-desktop-backend-config-test-", | 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 | const cause = PlatformError.systemError({ | 362 | const cause = PlatformError.systemError({ |
| 183 | _tag: "PermissionDenied", | 363 | _tag: "PermissionDenied", |
| 184 | module: "FileSystem", | 364 | module: "FileSystem", |
| ... | @@ -198,12 +378,14 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -198,12 +378,14 @@ describe("DesktopBackendConfiguration", () => { |
| 198 | 378 | ||
| 199 | const config = yield* Effect.gen(function* () { | 379 | const config = yield* Effect.gen(function* () { |
| 200 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | 380 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| 201 | return yield* configuration.resolve; | 381 | return yield* configuration.resolvePrimary; |
| 202 | }).pipe( | 382 | }).pipe( |
| 203 | Effect.provide( | 383 | Effect.provide( |
| 204 | Layer.mergeAll( | 384 | Layer.mergeAll( |
| 205 | DesktopBackendConfiguration.layer.pipe( | 385 | DesktopBackendConfiguration.layer.pipe( |
| 206 | Layer.provideMerge(serverExposureLayer), | 386 | Layer.provideMerge(serverExposureLayer), |
| 387 | Layer.provideMerge(DesktopAppSettings.layerTest()), | ||
| 388 | Layer.provideMerge(DesktopWslEnvironment.layerTest()), | ||
| 207 | Layer.provideMerge(makeEnvironmentLayer(baseDir)), | 389 | Layer.provideMerge(makeEnvironmentLayer(baseDir)), |
| 208 | Layer.provideMerge(failingFileSystemLayer), | 390 | Layer.provideMerge(failingFileSystemLayer), |
| 209 | ), | 391 | ), |
| ... | @@ -228,7 +410,7 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -228,7 +410,7 @@ describe("DesktopBackendConfiguration", () => { |
| 228 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 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 | Effect.gen(function* () { | 414 | Effect.gen(function* () { |
| 233 | const fileSystem = yield* FileSystem.FileSystem; | 415 | const fileSystem = yield* FileSystem.FileSystem; |
| 234 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | 416 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ |
| ... | @@ -237,12 +419,14 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -237,12 +419,14 @@ describe("DesktopBackendConfiguration", () => { |
| 237 | 419 | ||
| 238 | yield* Effect.gen(function* () { | 420 | yield* Effect.gen(function* () { |
| 239 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | 421 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| 240 | const config = yield* configuration.resolve; | 422 | const config = yield* configuration.resolvePrimary; |
| 241 | assert.equal(config.captureOutput, true); | 423 | assert.equal(config.captureOutput, true); |
| 242 | }).pipe( | 424 | }).pipe( |
| 243 | Effect.provide( | 425 | Effect.provide( |
| 244 | DesktopBackendConfiguration.layer.pipe( | 426 | DesktopBackendConfiguration.layer.pipe( |
| 245 | Layer.provideMerge(serverExposureLayer), | 427 | Layer.provideMerge(serverExposureLayer), |
| 428 | Layer.provideMerge(DesktopAppSettings.layerTest()), | ||
| 429 | Layer.provideMerge(DesktopWslEnvironment.layerTest()), | ||
| 246 | Layer.provideMerge( | 430 | Layer.provideMerge( |
| 247 | makeEnvironmentLayer(baseDir, { | 431 | makeEnvironmentLayer(baseDir, { |
| 248 | isPackaged: false, | 432 | isPackaged: false, |
| ... | @@ -254,4 +438,362 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -254,4 +438,362 @@ describe("DesktopBackendConfiguration", () => { |
| 254 | ); | 438 | ); |
| 255 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 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,3 +1,5 @@ |
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | |||
| 1 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; | 3 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 2 | import * as Context from "effect/Context"; | 4 | import * as Context from "effect/Context"; |
| 3 | import * as Crypto from "effect/Crypto"; | 5 | import * as Crypto from "effect/Crypto"; |
| ... | @@ -7,12 +9,16 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -7,12 +9,16 @@ import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 8 | import * as Option from "effect/Option"; | 10 | import * as Option from "effect/Option"; |
| 9 | import * as PlatformError from "effect/PlatformError"; | 11 | import * as PlatformError from "effect/PlatformError"; |
| 10 | import * as Ref from "effect/Ref"; | ||
| 11 | import * as Schema from "effect/Schema"; | 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 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; | 17 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 14 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 18 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 15 | import * as DesktopServerExposure from "./DesktopServerExposure.ts"; | 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 | export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass<DesktopBackendObservabilitySettingsReadError>()( | 23 | export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass<DesktopBackendObservabilitySettingsReadError>()( |
| 18 | "DesktopBackendObservabilitySettingsReadError", | 24 | "DesktopBackendObservabilitySettingsReadError", |
| ... | @@ -29,10 +35,31 @@ export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedE | ... | @@ -29,10 +35,31 @@ export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedE |
| 29 | export class DesktopBackendConfiguration extends Context.Service< | 35 | export class DesktopBackendConfiguration extends Context.Service< |
| 30 | DesktopBackendConfiguration, | 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 | DesktopBackendManager.DesktopBackendStartConfig, | 43 | DesktopBackendManager.DesktopBackendStartConfig, |
| 34 | PlatformError.PlatformError | 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 | >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {} | 64 | >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {} |
| 38 | 65 | ||
| ... | @@ -59,9 +86,48 @@ const DESKTOP_BACKEND_ENV_NAMES = [ | ... | @@ -59,9 +86,48 @@ const DESKTOP_BACKEND_ENV_NAMES = [ |
| 59 | "T3CODE_TAILSCALE_SERVE_PORT", | 86 | "T3CODE_TAILSCALE_SERVE_PORT", |
| 60 | ] as const; | 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 | const backendChildEnvPatch = (): Record<string, string | undefined> => | 97 | const backendChildEnvPatch = (): Record<string, string | undefined> => |
| 63 | Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); | 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 | const logBackendObservabilitySettingsReadFailure = ( | 131 | const logBackendObservabilitySettingsReadFailure = ( |
| 66 | settingsPath: string, | 132 | settingsPath: string, |
| 67 | cause: PlatformError.PlatformError, | 133 | cause: PlatformError.PlatformError, |
| ... | @@ -100,11 +166,168 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { | ... | @@ -100,11 +166,168 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { |
| 100 | }; | 166 | }; |
| 101 | }); | 167 | }); |
| 102 | 168 | ||
| 103 | const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolveStartConfig")( | 169 | interface SharedBootstrapInput { |
| 104 | function* (input: { | 170 | readonly bootstrapToken: string; |
| 105 | readonly bootstrapToken: string; | 171 | readonly observabilitySettings: BackendObservabilitySettings; |
| 106 | readonly observabilitySettings: BackendObservabilitySettings; | 172 | } |
| 107 | }): Effect.fn.Return< | 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 | DesktopBackendManager.DesktopBackendStartConfig, | 331 | DesktopBackendManager.DesktopBackendStartConfig, |
| 109 | never, | 332 | never, |
| 110 | DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure | 333 | DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure |
| ... | @@ -113,70 +336,349 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv | ... | @@ -113,70 +336,349 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv |
| 113 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | 336 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; |
| 114 | const backendExposure = yield* serverExposure.backendConfig; | 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 | return { | 351 | return { |
| 117 | executablePath: process.execPath, | 352 | executablePath: process.execPath, |
| 353 | args: [environment.backendEntryPath, "--bootstrap-fd", "3"], | ||
| 118 | entryPath: environment.backendEntryPath, | 354 | entryPath: environment.backendEntryPath, |
| 119 | cwd: environment.backendCwd, | 355 | cwd: environment.backendCwd, |
| 120 | env: { | 356 | env: { |
| 121 | ...backendChildEnvPatch(), | 357 | ...backendChildEnvPatch(), |
| 122 | ELECTRON_RUN_AS_NODE: "1", | 358 | ELECTRON_RUN_AS_NODE: "1", |
| 123 | }, | 359 | }, |
| 124 | bootstrap: { | 360 | // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). |
| 125 | mode: "desktop", | 361 | extendEnv: true, |
| 126 | noBrowser: true, | 362 | bootstrap, |
| 127 | port: backendExposure.port, | 363 | bootstrapDelivery: "fd3", |
| 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 | }, | ||
| 142 | httpBaseUrl: backendExposure.httpBaseUrl, | 364 | httpBaseUrl: backendExposure.httpBaseUrl, |
| 143 | captureOutput: true, | 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 | export const make = Effect.gen(function* () { | 558 | export const make = Effect.gen(function* () { |
| 149 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | 559 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 150 | const fileSystem = yield* FileSystem.FileSystem; | 560 | const fileSystem = yield* FileSystem.FileSystem; |
| 151 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | 561 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; |
| 562 | const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; | ||
| 563 | const settings = yield* DesktopAppSettings.DesktopAppSettings; | ||
| 152 | const crypto = yield* Crypto.Crypto; | 564 | const crypto = yield* Crypto.Crypto; |
| 153 | const tokenRef = yield* Ref.make(Option.none<string>()); | 565 | // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic. |
| 154 | const getOrCreateBootstrapToken = Effect.gen(function* () { | 566 | // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can |
| 155 | const existing = yield* Ref.get(tokenRef); | 567 | // resolve concurrently; with a plain Ref both could observe None, generate |
| 156 | if (Option.isSome(existing)) { | 568 | // distinct tokens, and one would overwrite the other — leaving the two |
| 157 | return existing.value; | 569 | // backends holding mismatched tokens and breaking the shared-token |
| 158 | } | 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)); | 600 | const buildWslPrimaryConfig = Effect.gen(function* () { |
| 161 | yield* Ref.set(tokenRef, Option.some(token)); | 601 | // wsl-only mode pipes the WSL backend through the same port the |
| 162 | return token; | 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 | return DesktopBackendConfiguration.of({ | 649 | return DesktopBackendConfiguration.of({ |
| 166 | resolve: Effect.gen(function* () { | 650 | resolvePrimary: Effect.gen(function* () { |
| 167 | const bootstrapToken = yield* getOrCreateBootstrapToken; | 651 | const { useWsl, wslRequested } = yield* describePrimary; |
| 168 | const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( | 652 | if (useWsl) { |
| 169 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 653 | return yield* buildWslPrimaryConfig; |
| 170 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | 654 | } |
| 171 | ); | 655 | if (wslRequested) { |
| 172 | return yield* resolveBackendStartConfig({ | 656 | yield* Effect.logWarning( |
| 173 | bootstrapToken, | 657 | "WSL-only backend requested but WSL is unavailable; starting the Windows primary instead.", |
| 174 | observabilitySettings, | 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 | }).pipe( | 677 | }).pipe( |
| 176 | Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), | 678 | Effect.withSpan("desktop.backendConfiguration.resolveWsl", { |
| 177 | Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), | 679 | attributes: { port: input.port, distro: input.distro ?? null }, |
| 178 | ); | 680 | }), |
| 179 | }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")), | 681 | ), |
| 180 | }); | 682 | }); |
| 181 | }); | 683 | }); |
| 182 | 684 |
apps/desktop/src/backend/DesktopBackendManager.test.ts+500-536| ... | @@ -3,15 +3,12 @@ import { | ... | @@ -3,15 +3,12 @@ import { |
| 3 | type DesktopBackendBootstrap as DesktopBackendBootstrapValue, | 3 | type DesktopBackendBootstrap as DesktopBackendBootstrapValue, |
| 4 | } from "@t3tools/contracts"; | 4 | } from "@t3tools/contracts"; |
| 5 | import { assert, describe, it } from "@effect/vitest"; | 5 | import { assert, describe, it } from "@effect/vitest"; |
| 6 | import * as Cause from "effect/Cause"; | ||
| 7 | import * as Deferred from "effect/Deferred"; | 6 | import * as Deferred from "effect/Deferred"; |
| 8 | import * as Duration from "effect/Duration"; | 7 | import * as Duration from "effect/Duration"; |
| 9 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 10 | import * as FileSystem from "effect/FileSystem"; | 9 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 12 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| 13 | import * as Option from "effect/Option"; | 11 | import * as Option from "effect/Option"; |
| 14 | import * as PlatformError from "effect/PlatformError"; | ||
| 15 | import * as Queue from "effect/Queue"; | 12 | import * as Queue from "effect/Queue"; |
| 16 | import * as Ref from "effect/Ref"; | 13 | import * as Ref from "effect/Ref"; |
| 17 | import * as Schema from "effect/Schema"; | 14 | import * as Schema from "effect/Schema"; |
| ... | @@ -23,18 +20,15 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab | ... | @@ -23,18 +20,15 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab |
| 23 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 20 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 24 | 21 | ||
| 25 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; | 22 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 26 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | ||
| 27 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | 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 | const decodeDesktopBackendBootstrap = Schema.decodeEffect( | 25 | const decodeDesktopBackendBootstrap = Schema.decodeEffect( |
| 32 | Schema.fromJsonString(DesktopBackendBootstrap), | 26 | Schema.fromJsonString(DesktopBackendBootstrap), |
| 33 | ); | 27 | ); |
| 34 | const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError); | ||
| 35 | 28 | ||
| 36 | const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { | 29 | const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { |
| 37 | executablePath: "/electron", | 30 | executablePath: "/electron", |
| 31 | args: ["/server/bin.mjs", "--bootstrap-fd", "3"], | ||
| 38 | entryPath: "/server/bin.mjs", | 32 | entryPath: "/server/bin.mjs", |
| 39 | cwd: "/server", | 33 | cwd: "/server", |
| 40 | env: { ELECTRON_RUN_AS_NODE: "1" }, | 34 | env: { ELECTRON_RUN_AS_NODE: "1" }, |
| ... | @@ -48,8 +42,11 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { | ... | @@ -48,8 +42,11 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { |
| 48 | tailscaleServeEnabled: false, | 42 | tailscaleServeEnabled: false, |
| 49 | tailscaleServePort: 443, | 43 | tailscaleServePort: 443, |
| 50 | }, | 44 | }, |
| 45 | bootstrapDelivery: "fd3", | ||
| 46 | extendEnv: true, | ||
| 51 | httpBaseUrl: new URL("http://127.0.0.1:3773"), | 47 | httpBaseUrl: new URL("http://127.0.0.1:3773"), |
| 52 | captureOutput: true, | 48 | captureOutput: true, |
| 49 | preflightFailure: Option.none(), | ||
| 53 | }; | 50 | }; |
| 54 | 51 | ||
| 55 | const configWithObservability: DesktopBackendBootstrapValue = { | 52 | const configWithObservability: DesktopBackendBootstrapValue = { |
| ... | @@ -59,9 +56,9 @@ const configWithObservability: DesktopBackendBootstrapValue = { | ... | @@ -59,9 +56,9 @@ const configWithObservability: DesktopBackendBootstrapValue = { |
| 59 | }; | 56 | }; |
| 60 | 57 | ||
| 61 | function makeProcess(options?: { | 58 | function makeProcess(options?: { |
| 62 | readonly stdout?: Stream.Stream<Uint8Array, PlatformError.PlatformError>; | 59 | readonly stdout?: Stream.Stream<Uint8Array>; |
| 63 | readonly stderr?: Stream.Stream<Uint8Array, PlatformError.PlatformError>; | 60 | readonly stderr?: Stream.Stream<Uint8Array>; |
| 64 | readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>; | 61 | readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>; |
| 65 | readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; | 62 | readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; |
| 66 | }): ChildProcessSpawner.ChildProcessHandle { | 63 | }): ChildProcessSpawner.ChildProcessHandle { |
| 67 | return ChildProcessSpawner.makeHandle({ | 64 | return ChildProcessSpawner.makeHandle({ |
| ... | @@ -105,114 +102,98 @@ function decodeBootstrap(raw: string) { | ... | @@ -105,114 +102,98 @@ function decodeBootstrap(raw: string) { |
| 105 | return decodeDesktopBackendBootstrap(raw); | 102 | return decodeDesktopBackendBootstrap(raw); |
| 106 | } | 103 | } |
| 107 | 104 | ||
| 108 | function makeManagerLayer(input: { | 105 | interface MakeInstanceInput { |
| 109 | readonly spawnerLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>; | 106 | readonly spawnerLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>; |
| 110 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; | 107 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; |
| 111 | readonly backendOutputLog?: Partial<DesktopObservability.DesktopBackendOutputLog["Service"]>; | 108 | readonly backendOutputLog?: Partial<DesktopObservability.DesktopBackendOutputLogShape>; |
| 112 | readonly desktopState?: DesktopState.DesktopState["Service"]; | 109 | readonly onReady?: Effect.Effect<void>; |
| 113 | readonly desktopWindow?: Partial<DesktopWindow.DesktopWindow["Service"]>; | 110 | readonly onShutdown?: Effect.Effect<void>; |
| 111 | readonly onPreflightFailed?: ( | ||
| 112 | failure: DesktopBackendManager.PreflightFailure, | ||
| 113 | ) => Effect.Effect<boolean>; | ||
| 114 | readonly config?: DesktopBackendManager.DesktopBackendStartConfig; | 114 | readonly config?: DesktopBackendManager.DesktopBackendStartConfig; |
| 115 | }) { | 115 | readonly configResolve?: Effect.Effect<DesktopBackendManager.DesktopBackendStartConfig>; |
| 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 | ); | ||
| 149 | } | 116 | } |
| 150 | 117 | ||
| 151 | describe("DesktopBackendManager", () => { | 118 | // Helper that constructs a primary backend instance using the factory |
| 152 | it("preserves the complete restart cause and schedule context", () => { | 119 | // directly. The factory's deps (FileSystem, ChildProcessSpawner, |
| 153 | const cause = Cause.combine( | 120 | // HttpClient, DesktopBackendOutputLogFactory) are provided per-test via |
| 154 | Cause.fail(new Error("start failed")), | 121 | // a scoped layer; tests yield the returned Effect inside `Effect.scoped` |
| 155 | Cause.die(new Error("restart defect")), | 122 | // to drive the instance's lifecycle. |
| 156 | ); | 123 | function makeTestInstance(input: MakeInstanceInput) { |
| 157 | const error = new DesktopBackendManager.DesktopBackendRestartError({ | 124 | const stubLog: DesktopObservability.DesktopBackendOutputLogShape = { |
| 158 | reason: "backend exited with code 1", | 125 | writeSessionBoundary: () => Effect.void, |
| 159 | delayMs: 500, | 126 | writeOutputChunk: () => Effect.void, |
| 160 | cause, | 127 | ...input.backendOutputLog, |
| 161 | }); | 128 | }; |
| 162 | 129 | const servicesLayer = Layer.mergeAll( | |
| 163 | assert.strictEqual(error.cause, cause); | 130 | FileSystem.layerNoop({ |
| 164 | assert.equal(error.reason, "backend exited with code 1"); | 131 | exists: () => Effect.succeed(true), |
| 165 | assert.equal(error.delayMs, 500); | 132 | }), |
| 166 | assert.equal(error.message, "Desktop backend restart failed after a scheduled 500ms delay."); | 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 | it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => | 153 | it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => |
| 170 | Effect.gen(function* () { | 154 | Effect.scoped( |
| 171 | let spawnedCommand: ChildProcess.Command | undefined; | 155 | Effect.gen(function* () { |
| 172 | let bootstrapJson = ""; | 156 | let spawnedCommand: ChildProcess.Command | undefined; |
| 173 | let readyCount = 0; | 157 | let bootstrapJson = ""; |
| 174 | const ready = yield* Deferred.make<void>(); | 158 | let readyCount = 0; |
| 175 | const exited = yield* Queue.unbounded<void>(); | 159 | const ready = yield* Deferred.make<void>(); |
| 176 | 160 | const exited = yield* Queue.unbounded<void>(); | |
| 177 | const spawnerLayer = Layer.succeed( | 161 | |
| 178 | ChildProcessSpawner.ChildProcessSpawner, | 162 | const spawnerLayer = Layer.succeed( |
| 179 | ChildProcessSpawner.make((command) => | 163 | ChildProcessSpawner.ChildProcessSpawner, |
| 180 | Effect.gen(function* () { | 164 | ChildProcessSpawner.make((command) => |
| 181 | spawnedCommand = command; | 165 | Effect.gen(function* () { |
| 182 | if (command._tag === "StandardCommand") { | 166 | spawnedCommand = command; |
| 183 | const fd3 = command.options.additionalFds?.fd3; | 167 | if (command._tag === "StandardCommand") { |
| 184 | if (fd3?.type === "input" && fd3.stream) { | 168 | const fd3 = command.options.additionalFds?.fd3; |
| 185 | bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); | 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({ | 174 | return makeProcess({ |
| 190 | exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | 175 | exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), |
| 191 | }); | 176 | }); |
| 192 | }), | 177 | }), |
| 193 | ), | 178 | ), |
| 194 | ); | 179 | ); |
| 195 | 180 | ||
| 196 | const managerLayer = makeManagerLayer({ | 181 | const instance = yield* makeTestInstance({ |
| 197 | config: { | 182 | config: { |
| 198 | ...baseConfig, | 183 | ...baseConfig, |
| 199 | bootstrap: configWithObservability, | 184 | bootstrap: configWithObservability, |
| 200 | }, | 185 | }, |
| 201 | spawnerLayer, | 186 | spawnerLayer, |
| 202 | desktopWindow: { | 187 | onReady: Effect.sync(() => { |
| 203 | handleBackendReady: Effect.sync(() => { | ||
| 204 | readyCount += 1; | 188 | readyCount += 1; |
| 205 | }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), | 189 | }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), |
| 206 | }, | 190 | backendOutputLog: { |
| 207 | backendOutputLog: { | 191 | writeSessionBoundary: ({ phase }) => |
| 208 | writeSessionBoundary: ({ phase }) => | 192 | phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, |
| 209 | phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, | 193 | }, |
| 210 | }, | 194 | }); |
| 211 | }); | 195 | |
| 212 | 196 | yield* instance.start; | |
| 213 | yield* Effect.gen(function* () { | ||
| 214 | const manager = yield* DesktopBackendManager.DesktopBackendManager; | ||
| 215 | yield* manager.start; | ||
| 216 | yield* Queue.take(exited); | 197 | yield* Queue.take(exited); |
| 217 | 198 | ||
| 218 | assert.equal(readyCount, 1); | 199 | assert.equal(readyCount, 1); |
| ... | @@ -235,292 +216,52 @@ describe("DesktopBackendManager", () => { | ... | @@ -235,292 +216,52 @@ describe("DesktopBackendManager", () => { |
| 235 | ); | 216 | ); |
| 236 | 217 | ||
| 237 | assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); | 218 | assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); |
| 238 | }).pipe(Effect.provide(managerLayer)); | 219 | }), |
| 239 | }), | 220 | ), |
| 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 | }), | ||
| 433 | ); | 221 | ); |
| 434 | 222 | ||
| 435 | it.effect("reports output handler failures separately from stream read failures", () => | 223 | it.effect("retries HTTP readiness before reporting the backend ready", () => |
| 436 | Effect.gen(function* () { | 224 | Effect.scoped( |
| 437 | const chunk = new TextEncoder().encode("backend output"); | 225 | Effect.gen(function* () { |
| 438 | const outputCause = new Error("output-handler-secret-sentinel"); | 226 | const requestUrls: Array<string> = []; |
| 439 | const reported = yield* Deferred.make<DesktopBackendManager.BackendProcessOutputError>(); | 227 | const statuses = [503, 200]; |
| 440 | const spawnerLayer = Layer.succeed( | 228 | let readyCount = 0; |
| 441 | ChildProcessSpawner.ChildProcessSpawner, | 229 | const firstRequest = yield* Deferred.make<void>(); |
| 442 | ChildProcessSpawner.make(() => | 230 | const ready = yield* Deferred.make<void>(); |
| 443 | Effect.succeed( | 231 | const exited = yield* Queue.unbounded<void>(); |
| 444 | makeProcess({ | 232 | |
| 445 | stdout: Stream.make(chunk), | 233 | const spawnerLayer = Layer.succeed( |
| 446 | exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | 234 | ChildProcessSpawner.ChildProcessSpawner, |
| 447 | }), | 235 | ChildProcessSpawner.make(() => |
| 236 | Effect.succeed( | ||
| 237 | makeProcess({ | ||
| 238 | exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | ||
| 239 | }), | ||
| 240 | ), | ||
| 448 | ), | 241 | ), |
| 449 | ), | 242 | ); |
| 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 | ); | ||
| 478 | 243 | ||
| 479 | it.effect("retries HTTP readiness before reporting the backend ready", () => | 244 | const instance = yield* makeTestInstance({ |
| 480 | Effect.gen(function* () { | 245 | spawnerLayer, |
| 481 | const requestUrls: Array<string> = []; | 246 | httpClientLayer: httpClientLayer((request) => |
| 482 | const statuses = [503, 200]; | 247 | Effect.gen(function* () { |
| 483 | let readyCount = 0; | 248 | const status = statuses.shift(); |
| 484 | const firstRequest = yield* Deferred.make<void>(); | 249 | assert.isDefined(status); |
| 485 | const ready = yield* Deferred.make<void>(); | 250 | requestUrls.push(request.url); |
| 486 | const exited = yield* Queue.unbounded<void>(); | 251 | yield* Deferred.succeed(firstRequest, void 0); |
| 487 | 252 | return responseForRequest(request, status); | |
| 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))), | ||
| 494 | }), | 253 | }), |
| 495 | ), | 254 | ), |
| 496 | ), | 255 | onReady: Effect.sync(() => { |
| 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(() => { | ||
| 512 | readyCount += 1; | 256 | readyCount += 1; |
| 513 | }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), | 257 | }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), |
| 514 | }, | 258 | backendOutputLog: { |
| 515 | backendOutputLog: { | 259 | writeSessionBoundary: ({ phase }) => |
| 516 | writeSessionBoundary: ({ phase }) => | 260 | phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, |
| 517 | phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, | 261 | }, |
| 518 | }, | 262 | }); |
| 519 | }); | 263 | |
| 520 | 264 | yield* instance.start; | |
| 521 | yield* Effect.gen(function* () { | ||
| 522 | const manager = yield* DesktopBackendManager.DesktopBackendManager; | ||
| 523 | yield* manager.start; | ||
| 524 | yield* Deferred.await(firstRequest); | 265 | yield* Deferred.await(firstRequest); |
| 525 | 266 | ||
| 526 | assert.equal(readyCount, 0); | 267 | assert.equal(readyCount, 0); |
| ... | @@ -534,106 +275,136 @@ describe("DesktopBackendManager", () => { | ... | @@ -534,106 +275,136 @@ describe("DesktopBackendManager", () => { |
| 534 | "http://127.0.0.1:3773/.well-known/t3/environment", | 275 | "http://127.0.0.1:3773/.well-known/t3/environment", |
| 535 | "http://127.0.0.1:3773/.well-known/t3/environment", | 276 | "http://127.0.0.1:3773/.well-known/t3/environment", |
| 536 | ]); | 277 | ]); |
| 537 | }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); | 278 | }).pipe(Effect.provide(TestClock.layer())), |
| 538 | }), | 279 | ), |
| 539 | ); | 280 | ); |
| 540 | 281 | ||
| 541 | it.effect("starts the configured backend and closes the scoped process on stop", () => | 282 | it.effect("starts the configured backend and closes the scoped process on stop", () => |
| 542 | Effect.gen(function* () { | 283 | Effect.scoped( |
| 543 | let startCount = 0; | 284 | Effect.gen(function* () { |
| 544 | let closedCount = 0; | 285 | let startCount = 0; |
| 545 | const closed = yield* Deferred.make<void>(); | 286 | let closedCount = 0; |
| 546 | const startedPids = yield* Queue.unbounded<number>(); | 287 | const closed = yield* Deferred.make<void>(); |
| 547 | const ready = yield* Deferred.make<void>(); | 288 | const startedPids = yield* Queue.unbounded<number>(); |
| 548 | const backendReady = yield* Ref.make(false); | 289 | const ready = yield* Deferred.make<void>(); |
| 549 | const quitting = yield* Ref.make(false); | 290 | const backendReadyFlag = yield* Ref.make(false); |
| 550 | 291 | ||
| 551 | const spawnerLayer = Layer.succeed( | 292 | const spawnerLayer = Layer.succeed( |
| 552 | ChildProcessSpawner.ChildProcessSpawner, | 293 | ChildProcessSpawner.ChildProcessSpawner, |
| 553 | ChildProcessSpawner.make(() => | 294 | ChildProcessSpawner.make(() => |
| 554 | Effect.gen(function* () { | 295 | Effect.gen(function* () { |
| 555 | const scope = yield* Scope.Scope; | 296 | const scope = yield* Scope.Scope; |
| 556 | startCount += 1; | 297 | startCount += 1; |
| 557 | yield* Queue.offer(startedPids, 123); | 298 | yield* Queue.offer(startedPids, 123); |
| 558 | const close = Effect.sync(() => { | 299 | const close = Effect.sync(() => { |
| 559 | closedCount += 1; | 300 | closedCount += 1; |
| 560 | }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid); | 301 | }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid); |
| 561 | 302 | ||
| 562 | yield* Scope.addFinalizer(scope, close); | 303 | yield* Scope.addFinalizer(scope, close); |
| 563 | 304 | ||
| 564 | return makeProcess({ | 305 | return makeProcess({ |
| 565 | exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), | 306 | exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), |
| 566 | kill: () => close, | 307 | kill: () => close, |
| 567 | }); | 308 | }); |
| 568 | }), | 309 | }), |
| 569 | ), | 310 | ), |
| 570 | ); | 311 | ); |
| 571 | 312 | ||
| 572 | const managerLayer = makeManagerLayer({ | 313 | const instance = yield* makeTestInstance({ |
| 573 | spawnerLayer, | 314 | spawnerLayer, |
| 574 | desktopState: { | 315 | onReady: Ref.set(backendReadyFlag, true).pipe( |
| 575 | backendReady, | 316 | Effect.andThen(Deferred.succeed(ready, void 0)), |
| 576 | quitting, | 317 | Effect.asVoid, |
| 577 | }, | 318 | ), |
| 578 | desktopWindow: { | 319 | onShutdown: Ref.set(backendReadyFlag, false), |
| 579 | handleBackendReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid), | 320 | }); |
| 580 | }, | 321 | assert.isTrue(Option.isNone(yield* instance.currentConfig)); |
| 581 | }); | 322 | |
| 582 | 323 | yield* instance.start; | |
| 583 | yield* Effect.gen(function* () { | ||
| 584 | const manager = yield* DesktopBackendManager.DesktopBackendManager; | ||
| 585 | assert.isTrue(Option.isNone(yield* manager.currentConfig)); | ||
| 586 | |||
| 587 | yield* manager.start; | ||
| 588 | assert.equal(yield* Queue.take(startedPids), 123); | 324 | assert.equal(yield* Queue.take(startedPids), 123); |
| 589 | yield* Deferred.await(ready); | 325 | yield* Deferred.await(ready); |
| 590 | assert.isTrue(yield* Ref.get(backendReady)); | 326 | assert.isTrue(yield* Ref.get(backendReadyFlag)); |
| 591 | assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig)); | 327 | assert.deepEqual(yield* instance.currentConfig, Option.some(baseConfig)); |
| 592 | 328 | ||
| 593 | const runningSnapshot = yield* manager.snapshot; | 329 | const runningSnapshot = yield* instance.snapshot; |
| 594 | assert.equal(runningSnapshot.ready, true); | 330 | assert.equal(runningSnapshot.ready, true); |
| 595 | assert.deepEqual(runningSnapshot.activePid, Option.some(123)); | 331 | assert.deepEqual(runningSnapshot.activePid, Option.some(123)); |
| 596 | 332 | ||
| 597 | yield* manager.stop(); | 333 | yield* instance.stop(); |
| 598 | assert.equal(startCount, 1); | 334 | assert.equal(startCount, 1); |
| 599 | assert.equal(closedCount, 1); | 335 | assert.equal(closedCount, 1); |
| 600 | 336 | ||
| 601 | const stoppedSnapshot = yield* manager.snapshot; | 337 | const stoppedSnapshot = yield* instance.snapshot; |
| 602 | assert.isFalse(yield* Ref.get(backendReady)); | 338 | assert.isFalse(yield* Ref.get(backendReadyFlag)); |
| 603 | assert.equal(stoppedSnapshot.desiredRunning, false); | 339 | assert.equal(stoppedSnapshot.desiredRunning, false); |
| 604 | assert.equal(stoppedSnapshot.ready, false); | 340 | assert.equal(stoppedSnapshot.ready, false); |
| 605 | assert.equal(Option.isNone(stoppedSnapshot.activePid), true); | 341 | assert.equal(Option.isNone(stoppedSnapshot.activePid), true); |
| 606 | }).pipe(Effect.provide(managerLayer)); | 342 | }), |
| 607 | }), | 343 | ), |
| 608 | ); | 344 | ); |
| 609 | 345 | ||
| 610 | it.effect("restarts an unexpectedly exited backend with the Effect clock", () => | 346 | it.effect("does not notify shutdown before the first start has prior state", () => |
| 611 | Effect.gen(function* () { | 347 | Effect.scoped( |
| 612 | const starts = yield* Queue.unbounded<number>(); | 348 | Effect.gen(function* () { |
| 613 | let startCount = 0; | 349 | let shutdownCount = 0; |
| 614 | 350 | const closed = yield* Deferred.make<void>(); | |
| 615 | const spawnerLayer = Layer.succeed( | 351 | const startedPids = yield* Queue.unbounded<number>(); |
| 616 | ChildProcessSpawner.ChildProcessSpawner, | 352 | |
| 617 | ChildProcessSpawner.make(() => | 353 | const spawnerLayer = Layer.succeed( |
| 618 | Effect.sync(() => { | 354 | ChildProcessSpawner.ChildProcessSpawner, |
| 619 | startCount += 1; | 355 | ChildProcessSpawner.make(() => |
| 620 | return makeProcess({ | 356 | Effect.gen(function* () { |
| 621 | exitCode: Queue.offer(starts, startCount).pipe( | 357 | yield* Queue.offer(startedPids, 123); |
| 622 | Effect.as(ChildProcessSpawner.ExitCode(1)), | 358 | const close = Deferred.succeed(closed, void 0).pipe(Effect.asVoid); |
| 623 | ), | 359 | return makeProcess({ |
| 624 | }); | 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 | ), | 373 | }); |
| 627 | ); | ||
| 628 | 374 | ||
| 629 | const managerLayer = makeManagerLayer({ | 375 | yield* instance.start; |
| 630 | spawnerLayer, | 376 | assert.equal(yield* Queue.take(startedPids), 123); |
| 631 | httpClientLayer: httpClientLayer(() => Effect.never), | 377 | assert.equal(shutdownCount, 0); |
| 632 | }); | 378 | }), |
| 379 | ), | ||
| 380 | ); | ||
| 633 | 381 | ||
| 634 | yield* Effect.gen(function* () { | 382 | it.effect("restarts an unexpectedly exited backend with the Effect clock", () => |
| 635 | const manager = yield* DesktopBackendManager.DesktopBackendManager; | 383 | Effect.scoped( |
| 636 | yield* manager.start; | 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 | assert.equal(yield* Queue.take(starts), 1); | 409 | assert.equal(yield* Queue.take(starts), 1); |
| 639 | 410 | ||
| ... | @@ -646,107 +417,300 @@ describe("DesktopBackendManager", () => { | ... | @@ -646,107 +417,300 @@ describe("DesktopBackendManager", () => { |
| 646 | assert.equal(yield* Queue.size(starts), 0); | 417 | assert.equal(yield* Queue.size(starts), 0); |
| 647 | yield* TestClock.adjust(Duration.millis(1)); | 418 | yield* TestClock.adjust(Duration.millis(1)); |
| 648 | assert.equal(yield* Queue.take(starts), 3); | 419 | assert.equal(yield* Queue.take(starts), 3); |
| 649 | }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); | 420 | }).pipe(Effect.provide(TestClock.layer())), |
| 650 | }), | 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 | it.effect("cancels a scheduled restart when start is requested manually", () => | 619 | it.effect("cancels a scheduled restart when start is requested manually", () => |
| 654 | Effect.gen(function* () { | 620 | Effect.scoped( |
| 655 | const starts = yield* Queue.unbounded<number>(); | 621 | Effect.gen(function* () { |
| 656 | const secondClosed = yield* Deferred.make<void>(); | 622 | const starts = yield* Queue.unbounded<number>(); |
| 657 | let startCount = 0; | 623 | const secondClosed = yield* Deferred.make<void>(); |
| 658 | 624 | let startCount = 0; | |
| 659 | const spawnerLayer = Layer.succeed( | 625 | |
| 660 | ChildProcessSpawner.ChildProcessSpawner, | 626 | const spawnerLayer = Layer.succeed( |
| 661 | ChildProcessSpawner.make(() => | 627 | ChildProcessSpawner.ChildProcessSpawner, |
| 662 | Effect.gen(function* () { | 628 | ChildProcessSpawner.make(() => |
| 663 | startCount += 1; | 629 | Effect.gen(function* () { |
| 664 | yield* Queue.offer(starts, startCount); | 630 | startCount += 1; |
| 665 | 631 | yield* Queue.offer(starts, startCount); | |
| 666 | if (startCount === 1) { | 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 | return makeProcess({ | 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 | } | 648 | }), |
| 671 | 649 | ), | |
| 672 | const scope = yield* Scope.Scope; | 650 | ); |
| 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 | ); | ||
| 684 | 651 | ||
| 685 | const managerLayer = makeManagerLayer({ | 652 | const instance = yield* makeTestInstance({ |
| 686 | spawnerLayer, | 653 | spawnerLayer, |
| 687 | httpClientLayer: httpClientLayer(() => Effect.never), | 654 | httpClientLayer: httpClientLayer(() => Effect.never), |
| 688 | }); | 655 | }); |
| 689 | 656 | ||
| 690 | yield* Effect.gen(function* () { | 657 | yield* instance.start; |
| 691 | const manager = yield* DesktopBackendManager.DesktopBackendManager; | ||
| 692 | yield* manager.start; | ||
| 693 | 658 | ||
| 694 | assert.equal(yield* Queue.take(starts), 1); | 659 | assert.equal(yield* Queue.take(starts), 1); |
| 695 | 660 | ||
| 696 | yield* manager.start; | 661 | yield* instance.start; |
| 697 | assert.equal(yield* Queue.take(starts), 2); | 662 | assert.equal(yield* Queue.take(starts), 2); |
| 698 | 663 | ||
| 699 | yield* manager.stop(); | 664 | yield* instance.stop(); |
| 700 | yield* TestClock.adjust(Duration.millis(500)); | 665 | yield* TestClock.adjust(Duration.millis(500)); |
| 701 | 666 | ||
| 702 | assert.equal(yield* Queue.size(starts), 0); | 667 | assert.equal(yield* Queue.size(starts), 0); |
| 703 | }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); | 668 | }).pipe(Effect.provide(TestClock.layer())), |
| 704 | }), | 669 | ), |
| 705 | ); | 670 | ); |
| 706 | 671 | ||
| 707 | it.effect("does not restart after stop cancels a scheduled restart", () => | 672 | it.effect("does not restart after stop cancels a scheduled restart", () => |
| 708 | Effect.gen(function* () { | 673 | Effect.scoped( |
| 709 | const starts = yield* Queue.unbounded<number>(); | 674 | Effect.gen(function* () { |
| 710 | let startCount = 0; | 675 | const starts = yield* Queue.unbounded<number>(); |
| 711 | 676 | let startCount = 0; | |
| 712 | const spawnerLayer = Layer.succeed( | 677 | |
| 713 | ChildProcessSpawner.ChildProcessSpawner, | 678 | const spawnerLayer = Layer.succeed( |
| 714 | ChildProcessSpawner.make(() => | 679 | ChildProcessSpawner.ChildProcessSpawner, |
| 715 | Effect.sync(() => { | 680 | ChildProcessSpawner.make(() => |
| 716 | startCount += 1; | 681 | Effect.sync(() => { |
| 717 | return makeProcess({ | 682 | startCount += 1; |
| 718 | exitCode: Queue.offer(starts, startCount).pipe( | 683 | return makeProcess({ |
| 719 | Effect.as(ChildProcessSpawner.ExitCode(1)), | 684 | exitCode: Queue.offer(starts, startCount).pipe( |
| 720 | ), | 685 | Effect.as(ChildProcessSpawner.ExitCode(1)), |
| 721 | }); | 686 | ), |
| 722 | }), | 687 | }); |
| 723 | ), | 688 | }), |
| 724 | ); | 689 | ), |
| 690 | ); | ||
| 725 | 691 | ||
| 726 | const managerLayer = makeManagerLayer({ | 692 | const instance = yield* makeTestInstance({ |
| 727 | spawnerLayer, | 693 | spawnerLayer, |
| 728 | httpClientLayer: httpClientLayer(() => Effect.never), | 694 | httpClientLayer: httpClientLayer(() => Effect.never), |
| 729 | }); | 695 | }); |
| 730 | 696 | ||
| 731 | yield* Effect.gen(function* () { | 697 | yield* instance.start; |
| 732 | const manager = yield* DesktopBackendManager.DesktopBackendManager; | ||
| 733 | yield* manager.start; | ||
| 734 | assert.equal(yield* Queue.take(starts), 1); | 698 | assert.equal(yield* Queue.take(starts), 1); |
| 735 | 699 | ||
| 736 | let restartScheduled = false; | 700 | let restartScheduled = false; |
| 737 | while (!restartScheduled) { | 701 | while (!restartScheduled) { |
| 738 | restartScheduled = (yield* manager.snapshot).restartScheduled; | 702 | restartScheduled = (yield* instance.snapshot).restartScheduled; |
| 739 | if (!restartScheduled) { | 703 | if (!restartScheduled) { |
| 740 | yield* Effect.yieldNow; | 704 | yield* Effect.yieldNow; |
| 741 | } | 705 | } |
| 742 | } | 706 | } |
| 743 | 707 | ||
| 744 | yield* manager.stop(); | 708 | yield* instance.stop(); |
| 745 | yield* TestClock.adjust(Duration.millis(500)); | 709 | yield* TestClock.adjust(Duration.millis(500)); |
| 746 | 710 | ||
| 747 | assert.equal(yield* Queue.size(starts), 0); | 711 | assert.equal(yield* Queue.size(starts), 0); |
| 748 | assert.equal((yield* manager.snapshot).desiredRunning, false); | 712 | assert.equal((yield* instance.snapshot).desiredRunning, false); |
| 749 | }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); | 713 | }).pipe(Effect.provide(TestClock.layer())), |
| 750 | }), | 714 | ), |
| 751 | ); | 715 | ); |
| 752 | }); | 716 | }); |
apps/desktop/src/backend/DesktopBackendManager.ts+353-312| ... | @@ -1,35 +1,63 @@ | ... | @@ -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 | import * as Cause from "effect/Cause"; | 27 | import * as Cause from "effect/Cause"; |
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as Duration from "effect/Duration"; | 28 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; | 29 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; | 30 | import * as Exit from "effect/Exit"; |
| 6 | import * as Fiber from "effect/Fiber"; | 31 | import * as Fiber from "effect/Fiber"; |
| 7 | import * as FileSystem from "effect/FileSystem"; | 32 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as Option from "effect/Option"; | 33 | import * as Option from "effect/Option"; |
| 10 | import * as PlatformError from "effect/PlatformError"; | 34 | import * as PlatformError from "effect/PlatformError"; |
| 11 | import * as Ref from "effect/Ref"; | 35 | import * as Ref from "effect/Ref"; |
| 36 | import * as Result from "effect/Result"; | ||
| 12 | import * as Schedule from "effect/Schedule"; | 37 | import * as Schedule from "effect/Schedule"; |
| 13 | import * as Schema from "effect/Schema"; | 38 | import * as Schema from "effect/Schema"; |
| 14 | import * as Semaphore from "effect/Semaphore"; | 39 | import * as Semaphore from "effect/Semaphore"; |
| 15 | import * as Scope from "effect/Scope"; | 40 | import * as Scope from "effect/Scope"; |
| 16 | import * as Stream from "effect/Stream"; | 41 | import * as Stream from "effect/Stream"; |
| 17 | import * as HttpClient from "effect/unstable/http/HttpClient"; | 42 | import { HttpClient } from "effect/unstable/http"; |
| 18 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; | 43 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 19 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 20 | 44 | ||
| 21 | import { | 45 | import { |
| 22 | DesktopBackendBootstrap, | 46 | DesktopBackendBootstrap, |
| 23 | type DesktopBackendBootstrap as DesktopBackendBootstrapValue, | 47 | type DesktopBackendBootstrap as DesktopBackendBootstrapValue, |
| 48 | PRIMARY_LOCAL_ENVIRONMENT_ID, | ||
| 24 | } from "@t3tools/contracts"; | 49 | } from "@t3tools/contracts"; |
| 50 | import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness"; | ||
| 25 | 51 | ||
| 26 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | ||
| 27 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | 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 | const INITIAL_RESTART_DELAY = Duration.millis(500); | 54 | const INITIAL_RESTART_DELAY = Duration.millis(500); |
| 32 | const MAX_RESTART_DELAY = Duration.seconds(10); | 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 | const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); | 61 | const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); |
| 34 | const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); | 62 | const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); |
| 35 | const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); | 63 | const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); |
| ... | @@ -42,145 +70,91 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; | ... | @@ -42,145 +70,91 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; |
| 42 | 70 | ||
| 43 | export type BackendProcessOutputStream = "stdout" | "stderr"; | 71 | export type BackendProcessOutputStream = "stdout" | "stderr"; |
| 44 | 72 | ||
| 45 | export interface BackendProcessContext { | 73 | export type DesktopBackendBootstrapDelivery = "fd3" | "stdin"; |
| 74 | |||
| 75 | export interface DesktopBackendStartConfig { | ||
| 46 | readonly executablePath: string; | 76 | readonly executablePath: string; |
| 77 | readonly args: ReadonlyArray<string>; | ||
| 47 | readonly entryPath: string; | 78 | readonly entryPath: string; |
| 48 | readonly cwd: string; | 79 | readonly cwd: string; |
| 49 | readonly httpBaseUrl: URL; | ||
| 50 | } | ||
| 51 | |||
| 52 | export interface DesktopBackendStartConfig extends BackendProcessContext { | ||
| 53 | readonly env: Record<string, string | undefined>; | 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 | readonly bootstrap: DesktopBackendBootstrapValue; | 85 | readonly bootstrap: DesktopBackendBootstrapValue; |
| 86 | readonly bootstrapDelivery: DesktopBackendBootstrapDelivery; | ||
| 87 | readonly httpBaseUrl: URL; | ||
| 55 | readonly captureOutput: boolean; | 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 | interface BackendProcessExit { | 106 | interface BackendProcessExit { |
| 59 | readonly code: Option.Option<number>; | 107 | readonly code: Option.Option<number>; |
| 60 | readonly reason: string; | 108 | readonly reason: string; |
| 109 | readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>; | ||
| 61 | } | 110 | } |
| 62 | 111 | ||
| 63 | const backendProcessContextSchema = { | 112 | export class BackendTimeoutError extends Schema.TaggedErrorClass<BackendTimeoutError>()( |
| 64 | executablePath: Schema.String, | 113 | "BackendTimeoutError", |
| 65 | entryPath: Schema.String, | ||
| 66 | cwd: Schema.String, | ||
| 67 | httpBaseUrl: Schema.URL, | ||
| 68 | }; | ||
| 69 | |||
| 70 | export class BackendReadinessTimeoutError extends Schema.TaggedErrorClass<BackendReadinessTimeoutError>()( | ||
| 71 | "BackendReadinessTimeoutError", | ||
| 72 | { | 114 | { |
| 73 | ...backendProcessContextSchema, | 115 | url: Schema.instanceOf(URL), |
| 74 | readinessUrl: Schema.URL, | ||
| 75 | timeoutMs: Schema.Number, | ||
| 76 | cause: Schema.Defect(), | ||
| 77 | }, | 116 | }, |
| 78 | ) { | 117 | ) { |
| 79 | override get message(): string { | 118 | override get message() { |
| 80 | return `Timed out after ${this.timeoutMs}ms waiting for desktop backend readiness at ${this.readinessUrl.href}.`; | 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 | "BackendProcessBootstrapEncodeError", | 124 | "BackendProcessBootstrapEncodeError", |
| 86 | { | 125 | { |
| 87 | ...backendProcessContextSchema, | 126 | entryPath: Schema.String, |
| 88 | cause: Schema.Defect(), | 127 | cause: Schema.Defect(), |
| 89 | }, | 128 | }, |
| 90 | ) { | 129 | ) { |
| 91 | override get message(): string { | 130 | override get message() { |
| 92 | return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`; | 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 | "BackendProcessSpawnError", | 136 | "BackendProcessSpawnError", |
| 98 | { | 137 | { |
| 99 | ...backendProcessContextSchema, | 138 | executablePath: Schema.String, |
| 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, | ||
| 159 | cause: Schema.Defect(), | 139 | cause: Schema.Defect(), |
| 160 | }, | 140 | }, |
| 161 | ) { | 141 | ) { |
| 162 | override get message(): string { | 142 | override get message() { |
| 163 | return `Desktop backend restart failed after a scheduled ${this.delayMs}ms delay.`; | 143 | return `Failed to spawn the desktop backend process at ${this.executablePath}.`; |
| 164 | } | 144 | } |
| 165 | } | 145 | } |
| 166 | 146 | ||
| 167 | export const BackendProcessError = Schema.Union([ | 147 | type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; |
| 168 | BackendProcessBootstrapEncodeError, | ||
| 169 | BackendProcessSpawnError, | ||
| 170 | BackendProcessExitStatusError, | ||
| 171 | ]); | ||
| 172 | export type BackendProcessError = typeof BackendProcessError.Type; | ||
| 173 | 148 | ||
| 174 | interface RunBackendProcessOptions extends DesktopBackendStartConfig { | 149 | interface RunBackendProcessOptions extends DesktopBackendStartConfig { |
| 175 | readonly readinessTimeout?: Duration.Duration; | 150 | readonly readinessTimeout?: Duration.Duration; |
| 176 | readonly onStarted?: (pid: number) => Effect.Effect<void>; | 151 | readonly onStarted?: (pid: number) => Effect.Effect<void>; |
| 177 | readonly onReady?: () => Effect.Effect<void>; | 152 | readonly onReady?: () => Effect.Effect<void>; |
| 178 | readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect<void>; | 153 | readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect<void>; |
| 179 | readonly onOutput?: ( | 154 | readonly onOutput?: ( |
| 180 | streamName: BackendProcessOutputStream, | 155 | streamName: BackendProcessOutputStream, |
| 181 | chunk: Uint8Array, | 156 | chunk: Uint8Array, |
| 182 | ) => Effect.Effect<void, Error>; | 157 | ) => Effect.Effect<void>; |
| 183 | readonly onOutputFailure?: (error: BackendProcessOutputError) => Effect.Effect<void>; | ||
| 184 | } | 158 | } |
| 185 | 159 | ||
| 186 | export interface DesktopBackendSnapshot { | 160 | export interface DesktopBackendSnapshot { |
| ... | @@ -191,18 +165,60 @@ export interface DesktopBackendSnapshot { | ... | @@ -191,18 +165,60 @@ export interface DesktopBackendSnapshot { |
| 191 | readonly restartScheduled: boolean; | 165 | readonly restartScheduled: boolean; |
| 192 | } | 166 | } |
| 193 | 167 | ||
| 194 | export class DesktopBackendManager extends Context.Service< | 168 | // Opaque identifier for one backend process inside the pool. Today only |
| 195 | DesktopBackendManager, | 169 | // PRIMARY_INSTANCE_ID is registered. Follow-up commits add WSL distros |
| 196 | { | 170 | // under ids derived from the distro name (e.g. "wsl:ubuntu"). Eventually |
| 197 | readonly start: Effect.Effect<void>; | 171 | // these map 1:1 with environment ids on the frontend; keeping them |
| 198 | readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect<void>; | 172 | // desktop-local for now avoids leaking the contracts dependency. |
| 199 | readonly currentConfig: Effect.Effect<Option.Option<DesktopBackendStartConfig>>; | 173 | export type BackendInstanceId = string & Brand.Brand<"BackendInstanceId">; |
| 200 | readonly snapshot: Effect.Effect<DesktopBackendSnapshot>; | 174 | export const BackendInstanceId = Brand.nominal<BackendInstanceId>(); |
| 201 | } | 175 | |
| 202 | >()("@t3tools/desktop/backend/DesktopBackendManager") {} | 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 } = | 197 | // Spec describing one backend instance to spawn. The configResolve |
| 205 | DesktopObservability.makeComponentLogger("desktop-backend-manager"); | 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 | interface ActiveBackendRun { | 223 | interface ActiveBackendRun { |
| 208 | readonly id: number; | 224 | readonly id: number; |
| ... | @@ -217,6 +233,9 @@ interface BackendManagerState { | ... | @@ -217,6 +233,9 @@ interface BackendManagerState { |
| 217 | readonly config: Option.Option<DesktopBackendStartConfig>; | 233 | readonly config: Option.Option<DesktopBackendStartConfig>; |
| 218 | readonly active: Option.Option<ActiveBackendRun>; | 234 | readonly active: Option.Option<ActiveBackendRun>; |
| 219 | readonly restartAttempt: number; | 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 | readonly restartFiber: Option.Option<Fiber.Fiber<void, never>>; | 239 | readonly restartFiber: Option.Option<Fiber.Fiber<void, never>>; |
| 221 | readonly nextRunId: number; | 240 | readonly nextRunId: number; |
| 222 | } | 241 | } |
| ... | @@ -227,6 +246,7 @@ const initialState: BackendManagerState = { | ... | @@ -227,6 +246,7 @@ const initialState: BackendManagerState = { |
| 227 | config: Option.none(), | 246 | config: Option.none(), |
| 228 | active: Option.none(), | 247 | active: Option.none(), |
| 229 | restartAttempt: 0, | 248 | restartAttempt: 0, |
| 249 | preflightFailureAttempt: 0, | ||
| 230 | restartFiber: Option.none(), | 250 | restartFiber: Option.none(), |
| 231 | nextRunId: 1, | 251 | nextRunId: 1, |
| 232 | }; | 252 | }; |
| ... | @@ -259,198 +279,135 @@ const closeRun = ( | ... | @@ -259,198 +279,135 @@ const closeRun = ( |
| 259 | ).pipe(Effect.ignore); | 279 | ).pipe(Effect.ignore); |
| 260 | }; | 280 | }; |
| 261 | 281 | ||
| 262 | export const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( | 282 | const waitForHttpReady = ( |
| 263 | options: BackendProcessContext & { readonly timeout: Duration.Duration }, | 283 | baseUrl: URL, |
| 264 | ): Effect.fn.Return<void, BackendReadinessTimeoutError, HttpClient.HttpClient> { | 284 | timeout: Duration.Duration, |
| 265 | const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl); | 285 | ): Effect.Effect<void, BackendTimeoutError, HttpClient.HttpClient> => { |
| 266 | const client = (yield* HttpClient.HttpClient).pipe( | 286 | const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl); |
| 267 | HttpClient.filterStatusOk, | 287 | return waitForHttpReadyShared({ |
| 268 | HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)), | 288 | baseUrl: baseUrl.href, |
| 269 | HttpClient.retry(Schedule.spaced(DEFAULT_BACKEND_READINESS_INTERVAL)), | 289 | path: BACKEND_READINESS_PATH, |
| 270 | ); | 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( | 297 | function describeProcessExit( |
| 273 | Effect.asVoid, | 298 | result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>, |
| 274 | Effect.timeout(options.timeout), | 299 | ): BackendProcessExit { |
| 275 | Effect.mapError( | 300 | if (Result.isSuccess(result)) { |
| 276 | (cause) => | 301 | return { |
| 277 | new BackendReadinessTimeoutError({ | 302 | code: Option.some(result.success), |
| 278 | executablePath: options.executablePath, | 303 | reason: `code=${result.success}`, |
| 279 | entryPath: options.entryPath, | 304 | result, |
| 280 | cwd: options.cwd, | 305 | }; |
| 281 | httpBaseUrl: options.httpBaseUrl, | 306 | } |
| 282 | readinessUrl, | 307 | |
| 283 | timeoutMs: Duration.toMillis(options.timeout), | 308 | return { |
| 284 | cause, | 309 | code: Option.none(), |
| 285 | }), | 310 | reason: result.failure.message, |
| 286 | ), | 311 | result, |
| 287 | ); | 312 | }; |
| 288 | }); | 313 | } |
| 289 | 314 | ||
| 290 | function drainBackendOutput( | 315 | function drainBackendOutput( |
| 291 | context: BackendProcessContext & { readonly pid: number }, | ||
| 292 | streamName: BackendProcessOutputStream, | 316 | streamName: BackendProcessOutputStream, |
| 293 | stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>, | 317 | stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>, |
| 294 | onOutput: ( | 318 | onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect<void>, |
| 295 | streamName: BackendProcessOutputStream, | ||
| 296 | chunk: Uint8Array, | ||
| 297 | ) => Effect.Effect<void, Error>, | ||
| 298 | onOutputFailure: (error: BackendProcessOutputError) => Effect.Effect<void>, | ||
| 299 | ): Effect.Effect<void> { | 319 | ): Effect.Effect<void> { |
| 300 | return stream.pipe( | 320 | return stream.pipe( |
| 301 | Stream.mapError( | 321 | Stream.runForEach((chunk) => onOutput(streamName, chunk)), |
| 302 | (cause) => | 322 | Effect.ignore, |
| 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 | }), | ||
| 326 | ); | 323 | ); |
| 327 | } | 324 | } |
| 328 | 325 | ||
| 329 | const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); | 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 | options: RunBackendProcessOptions, | 329 | options: RunBackendProcessOptions, |
| 333 | ): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> { | 330 | ): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> { |
| 334 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 331 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 335 | const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( | 332 | const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( |
| 336 | Effect.mapError( | 333 | Effect.mapError( |
| 337 | (cause) => | 334 | (cause) => new BackendProcessBootstrapEncodeError({ entryPath: options.entryPath, cause }), |
| 338 | new BackendProcessBootstrapEncodeError({ | ||
| 339 | executablePath: options.executablePath, | ||
| 340 | entryPath: options.entryPath, | ||
| 341 | cwd: options.cwd, | ||
| 342 | httpBaseUrl: options.httpBaseUrl, | ||
| 343 | cause, | ||
| 344 | }), | ||
| 345 | ), | 335 | ), |
| 346 | ); | 336 | ); |
| 347 | const onOutput = options.onOutput ?? (() => Effect.void); | 337 | const onOutput = options.onOutput ?? (() => Effect.void); |
| 348 | const command = ChildProcess.make( | 338 | const bootstrapStream = Stream.encodeText(Stream.make(`${bootstrapJson}\n`)); |
| 349 | options.executablePath, | 339 | const command = ChildProcess.make(options.executablePath, options.args, { |
| 350 | [options.entryPath, "--bootstrap-fd", "3"], | 340 | cwd: options.cwd, |
| 351 | { | 341 | env: options.env, |
| 352 | cwd: options.cwd, | 342 | extendEnv: options.extendEnv, |
| 353 | env: options.env, | 343 | // In Electron main, process.execPath points to the Electron binary. |
| 354 | extendEnv: true, | 344 | // Run the child in Node mode so this backend process does not become a GUI app instance. |
| 355 | // In Electron main, process.execPath points to the Electron binary. | 345 | stdin: options.bootstrapDelivery === "stdin" ? bootstrapStream : "ignore", |
| 356 | // Run the child in Node mode so this backend process does not become a GUI app instance. | 346 | stdout: options.captureOutput ? "pipe" : "inherit", |
| 357 | stdin: "ignore", | 347 | stderr: options.captureOutput ? "pipe" : "inherit", |
| 358 | stdout: options.captureOutput ? "pipe" : "inherit", | 348 | killSignal: "SIGTERM", |
| 359 | stderr: options.captureOutput ? "pipe" : "inherit", | 349 | forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, |
| 360 | killSignal: "SIGTERM", | 350 | // wsl.exe drops additional file descriptors when forwarding to the Linux |
| 361 | forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, | 351 | // side, so the WSL spawn path delivers the bootstrap envelope via stdin |
| 362 | additionalFds: { | 352 | // (`--bootstrap-fd 0`) instead. |
| 363 | fd3: { | 353 | ...(options.bootstrapDelivery === "fd3" |
| 364 | type: "input", | 354 | ? { additionalFds: { fd3: { type: "input" as const, stream: bootstrapStream } } } |
| 365 | stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)), | 355 | : {}), |
| 366 | }, | 356 | }); |
| 367 | }, | ||
| 368 | }, | ||
| 369 | ); | ||
| 370 | 357 | ||
| 371 | const handle = yield* spawner.spawn(command).pipe( | 358 | const handle = yield* spawner |
| 372 | Effect.mapError( | 359 | .spawn(command) |
| 373 | (cause) => | 360 | .pipe( |
| 374 | new BackendProcessSpawnError({ | 361 | Effect.mapError( |
| 375 | executablePath: options.executablePath, | 362 | (cause) => new BackendProcessSpawnError({ executablePath: options.executablePath, cause }), |
| 376 | entryPath: options.entryPath, | 363 | ), |
| 377 | cwd: options.cwd, | 364 | ); |
| 378 | httpBaseUrl: options.httpBaseUrl, | ||
| 379 | cause, | ||
| 380 | }), | ||
| 381 | ), | ||
| 382 | ); | ||
| 383 | 365 | ||
| 384 | yield* options.onStarted?.(handle.pid) ?? Effect.void; | 366 | yield* options.onStarted?.(handle.pid) ?? Effect.void; |
| 385 | if (options.captureOutput) { | 367 | if (options.captureOutput) { |
| 386 | const outputContext = { | 368 | yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); |
| 387 | executablePath: options.executablePath, | 369 | yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); |
| 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); | ||
| 408 | } | 370 | } |
| 409 | yield* waitForHttpReady({ | 371 | yield* waitForHttpReady( |
| 410 | executablePath: options.executablePath, | 372 | options.httpBaseUrl, |
| 411 | entryPath: options.entryPath, | 373 | options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, |
| 412 | cwd: options.cwd, | 374 | ).pipe( |
| 413 | httpBaseUrl: options.httpBaseUrl, | ||
| 414 | timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, | ||
| 415 | }).pipe( | ||
| 416 | Effect.tap(() => options.onReady?.() ?? Effect.void), | 375 | Effect.tap(() => options.onReady?.() ?? Effect.void), |
| 417 | Effect.catchTags({ | 376 | Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), |
| 418 | BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void, | ||
| 419 | }), | ||
| 420 | Effect.forkScoped, | 377 | Effect.forkScoped, |
| 421 | ); | 378 | ); |
| 422 | 379 | ||
| 423 | const exitCode = yield* handle.exitCode.pipe( | 380 | return describeProcessExit(yield* Effect.result(handle.exitCode)); |
| 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; | ||
| 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 | const parentScope = yield* Scope.Scope; | 399 | const parentScope = yield* Scope.Scope; |
| 444 | const fileSystem = yield* FileSystem.FileSystem; | 400 | const fileSystem = yield* FileSystem.FileSystem; |
| 445 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | 401 | const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory; |
| 446 | const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog; | 402 | const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id); |
| 447 | const desktopState = yield* DesktopState.DesktopState; | ||
| 448 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 449 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 403 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 450 | const httpClient = yield* HttpClient.HttpClient; | 404 | const httpClient = yield* HttpClient.HttpClient; |
| 451 | const state = yield* Ref.make(initialState); | 405 | const state = yield* Ref.make(initialState); |
| 452 | const mutex = yield* Semaphore.make(1); | 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 | const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => | 411 | const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => |
| 455 | Ref.update(state, withActiveRun(runId, f)); | 412 | Ref.update(state, withActiveRun(runId, f)); |
| 456 | 413 | ||
| ... | @@ -490,11 +447,16 @@ export const make = Effect.gen(function* () { | ... | @@ -490,11 +447,16 @@ export const make = Effect.gen(function* () { |
| 490 | return; | 447 | return; |
| 491 | } | 448 | } |
| 492 | 449 | ||
| 493 | yield* Ref.set(desktopState.backendReady, false); | 450 | if (current.ready) { |
| 494 | const config = yield* configuration.resolve.pipe( | 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 | Effect.tapError((error) => | 457 | Effect.tapError((error) => |
| 496 | logBackendManagerError("failed to generate desktop backend configuration", { | 458 | logInstanceError("failed to generate desktop backend configuration", { |
| 497 | cause: error, | 459 | cause: error.message, |
| 498 | }), | 460 | }), |
| 499 | ), | 461 | ), |
| 500 | Effect.option, | 462 | Effect.option, |
| ... | @@ -506,14 +468,84 @@ export const make = Effect.gen(function* () { | ... | @@ -506,14 +468,84 @@ export const make = Effect.gen(function* () { |
| 506 | .exists(config.value.entryPath) | 468 | .exists(config.value.entryPath) |
| 507 | .pipe(Effect.orElseSucceed(() => false)); | 469 | .pipe(Effect.orElseSucceed(() => false)); |
| 508 | 470 | ||
| 471 | const resetFatalPreflightCounter = | ||
| 472 | !current.desiredRunning && current.preflightFailureAttempt > 0; | ||
| 509 | yield* cancelRestart; | 473 | yield* cancelRestart; |
| 510 | yield* Ref.update(state, (latest) => ({ | 474 | yield* Ref.update(state, (latest) => ({ |
| 511 | ...latest, | 475 | ...latest, |
| 512 | desiredRunning: true, | 476 | desiredRunning: true, |
| 513 | ready: false, | 477 | ready: false, |
| 514 | config: Option.some(config.value), | 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 | if (!entryExists) { | 549 | if (!entryExists) { |
| 518 | yield* scheduleRestart(`missing server entry at ${config.value.entryPath}`); | 550 | yield* scheduleRestart(`missing server entry at ${config.value.entryPath}`); |
| 519 | return; | 551 | return; |
| ... | @@ -534,7 +566,7 @@ export const make = Effect.gen(function* () { | ... | @@ -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 | reason: string, | 570 | reason: string, |
| 539 | ) { | 571 | ) { |
| 540 | yield* mutex.withPermits(1)( | 572 | yield* mutex.withPermits(1)( |
| ... | @@ -586,7 +618,7 @@ export const make = Effect.gen(function* () { | ... | @@ -586,7 +618,7 @@ export const make = Effect.gen(function* () { |
| 586 | details: `pid=${pid.value} ${reason}`, | 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 | if (isCurrentRun && nextState.desiredRunning) { | 624 | if (isCurrentRun && nextState.desiredRunning) { |
| ... | @@ -598,7 +630,7 @@ export const make = Effect.gen(function* () { | ... | @@ -598,7 +630,7 @@ export const make = Effect.gen(function* () { |
| 598 | 630 | ||
| 599 | const program = runBackendProcess({ | 631 | const program = runBackendProcess({ |
| 600 | ...config.value, | 632 | ...config.value, |
| 601 | onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) { | 633 | onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) { |
| 602 | yield* updateActiveRun(runId, (run) => ({ | 634 | yield* updateActiveRun(runId, (run) => ({ |
| 603 | ...run, | 635 | ...run, |
| 604 | pid: Option.some(pid), | 636 | pid: Option.some(pid), |
| ... | @@ -608,7 +640,7 @@ export const make = Effect.gen(function* () { | ... | @@ -608,7 +640,7 @@ export const make = Effect.gen(function* () { |
| 608 | details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`, | 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 | const isCurrentRun = yield* Ref.modify(state, (latest) => { | 644 | const isCurrentRun = yield* Ref.modify(state, (latest) => { |
| 613 | const activeRun = Option.getOrUndefined(latest.active); | 645 | const activeRun = Option.getOrUndefined(latest.active); |
| 614 | if (activeRun?.id !== runId) { | 646 | if (activeRun?.id !== runId) { |
| ... | @@ -628,30 +660,19 @@ export const make = Effect.gen(function* () { | ... | @@ -628,30 +660,19 @@ export const make = Effect.gen(function* () { |
| 628 | return; | 660 | return; |
| 629 | } | 661 | } |
| 630 | 662 | ||
| 631 | yield* Ref.set(desktopState.backendReady, true); | 663 | yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void; |
| 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 | ); | ||
| 639 | }), | 664 | }), |
| 640 | onReadinessFailure: (error) => | 665 | onReadinessFailure: (error) => |
| 641 | logBackendManagerWarning("backend readiness check failed during bootstrap", { | 666 | logInstanceWarning("backend readiness check failed during bootstrap", { |
| 642 | error, | 667 | error: error.message, |
| 643 | }), | 668 | }), |
| 644 | onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), | 669 | onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), |
| 645 | onOutputFailure: (error) => logBackendManagerError(error.message, { error }), | ||
| 646 | }).pipe( | 670 | }).pipe( |
| 647 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 671 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 648 | Effect.provideService(HttpClient.HttpClient, httpClient), | 672 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 649 | Scope.provide(runScope), | 673 | Scope.provide(runScope), |
| 650 | Effect.matchEffect({ | 674 | Effect.matchEffect({ |
| 651 | onFailure: (error) => | 675 | onFailure: (error) => finalizeRun(error.message), |
| 652 | logBackendManagerError(error.message, { error }).pipe( | ||
| 653 | Effect.andThen(finalizeRun(error.message)), | ||
| 654 | ), | ||
| 655 | onSuccess: (exit) => finalizeRun(exit.reason), | 676 | onSuccess: (exit) => finalizeRun(exit.reason), |
| 656 | }), | 677 | }), |
| 657 | Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), | 678 | Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), |
| ... | @@ -664,9 +685,9 @@ export const make = Effect.gen(function* () { | ... | @@ -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 | reason: string, | 691 | reason: string, |
| 671 | ) { | 692 | ) { |
| 672 | const scheduled = yield* Ref.modify(state, (latest) => { | 693 | const scheduled = yield* Ref.modify(state, (latest) => { |
| ... | @@ -686,8 +707,8 @@ export const make = Effect.gen(function* () { | ... | @@ -686,8 +707,8 @@ export const make = Effect.gen(function* () { |
| 686 | 707 | ||
| 687 | yield* Option.match(scheduled, { | 708 | yield* Option.match(scheduled, { |
| 688 | onNone: () => Effect.void, | 709 | onNone: () => Effect.void, |
| 689 | onSome: Effect.fn("desktop.backendManager.scheduleRestartFiber")(function* (delay) { | 710 | onSome: Effect.fn("desktop.backendInstance.scheduleRestartFiber")(function* (delay) { |
| 690 | yield* logBackendManagerError("backend exited unexpectedly; restart scheduled", { | 711 | yield* logInstanceError("backend exited unexpectedly; restart scheduled", { |
| 691 | reason, | 712 | reason, |
| 692 | delayMs: Duration.toMillis(delay), | 713 | delayMs: Duration.toMillis(delay), |
| 693 | }); | 714 | }); |
| ... | @@ -706,17 +727,11 @@ export const make = Effect.gen(function* () { | ... | @@ -706,17 +727,11 @@ export const make = Effect.gen(function* () { |
| 706 | }), | 727 | }), |
| 707 | ), | 728 | ), |
| 708 | Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), | 729 | Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), |
| 709 | Effect.catchCause((cause) => { | 730 | Effect.catchCause((cause) => |
| 710 | if (Cause.hasInterruptsOnly(cause)) { | 731 | logInstanceError("desktop backend restart fiber failed", { |
| 711 | return Effect.void; | 732 | cause: Cause.pretty(cause), |
| 712 | } | 733 | }), |
| 713 | const error = new DesktopBackendRestartError({ | 734 | ), |
| 714 | reason, | ||
| 715 | delayMs: Duration.toMillis(delay), | ||
| 716 | cause, | ||
| 717 | }); | ||
| 718 | return logBackendManagerError(error.message, { error }); | ||
| 719 | }), | ||
| 720 | ), | 735 | ), |
| 721 | parentScope, | 736 | parentScope, |
| 722 | ); | 737 | ); |
| ... | @@ -732,7 +747,7 @@ export const make = Effect.gen(function* () { | ... | @@ -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 | readonly timeout?: Duration.Duration; | 751 | readonly timeout?: Duration.Duration; |
| 737 | }) { | 752 | }) { |
| 738 | const { active, restartFiber } = yield* mutex.withPermits(1)( | 753 | const { active, restartFiber } = yield* mutex.withPermits(1)( |
| ... | @@ -750,7 +765,15 @@ export const make = Effect.gen(function* () { | ... | @@ -750,7 +765,15 @@ export const make = Effect.gen(function* () { |
| 750 | restartFiber: Option.none<Fiber.Fiber<void, never>>(), | 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 | return result; | 777 | return result; |
| 755 | }), | 778 | }), |
| 756 | ); | 779 | ); |
| ... | @@ -765,14 +788,32 @@ export const make = Effect.gen(function* () { | ... | @@ -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 | yield* Effect.addFinalizer(() => stop()); | 808 | yield* Effect.addFinalizer(() => stop()); |
| 769 | 809 | ||
| 770 | return DesktopBackendManager.of({ | 810 | return { |
| 811 | id: spec.id, | ||
| 812 | label: spec.label, | ||
| 771 | start, | 813 | start, |
| 772 | stop, | 814 | stop, |
| 773 | currentConfig, | 815 | currentConfig, |
| 774 | snapshot, | 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,11 +5,12 @@ import * as Option from "effect/Option"; |
| 5 | import * as Ref from "effect/Ref"; | 5 | import * as Ref from "effect/Ref"; |
| 6 | import * as HttpClient from "effect/unstable/http/HttpClient"; | 6 | import * as HttpClient from "effect/unstable/http/HttpClient"; |
| 7 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; | 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 | import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts"; | 11 | import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts"; |
| 11 | 12 | ||
| 12 | const config: DesktopBackendManager.DesktopBackendStartConfig = { | 13 | const config = { |
| 13 | executablePath: "/electron", | 14 | executablePath: "/electron", |
| 14 | entryPath: "/server/bin.mjs", | 15 | entryPath: "/server/bin.mjs", |
| 15 | cwd: "/server", | 16 | cwd: "/server", |
| ... | @@ -54,20 +55,17 @@ describe("DesktopLocalEnvironmentAuth", () => { | ... | @@ -54,20 +55,17 @@ describe("DesktopLocalEnvironmentAuth", () => { |
| 54 | ), | 55 | ), |
| 55 | ), | 56 | ), |
| 56 | ); | 57 | ); |
| 57 | const managerLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { | 58 | const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { |
| 58 | start: Effect.void, | 59 | list: Effect.succeed([ |
| 59 | stop: () => Effect.void, | 60 | { |
| 60 | currentConfig: Effect.succeed(Option.some(config)), | 61 | id: PRIMARY_LOCAL_ENVIRONMENT_ID, |
| 61 | snapshot: Effect.succeed({ | 62 | label: Effect.succeed("Windows"), |
| 62 | desiredRunning: true, | 63 | currentConfig: Effect.succeed(Option.some(config)), |
| 63 | ready: true, | 64 | }, |
| 64 | activePid: Option.none(), | 65 | ]), |
| 65 | restartAttempt: 0, | 66 | } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); |
| 66 | restartScheduled: false, | ||
| 67 | }), | ||
| 68 | }); | ||
| 69 | const testLayer = DesktopLocalEnvironmentAuth.layer.pipe( | 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 | const [first, second] = yield* Effect.gen(function* () { | 71 | const [first, second] = yield* Effect.gen(function* () { |
apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts+11-4| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; | 1 | import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; |
| 2 | import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | 3 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| ... | @@ -8,7 +9,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -8,7 +9,7 @@ import * as Schema from "effect/Schema"; |
| 8 | import * as Semaphore from "effect/Semaphore"; | 9 | import * as Semaphore from "effect/Semaphore"; |
| 9 | import * as HttpClient from "effect/unstable/http/HttpClient"; | 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 | export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedErrorClass<DesktopLocalEnvironmentAuthBackendNotConfiguredError>()( | 14 | export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedErrorClass<DesktopLocalEnvironmentAuthBackendNotConfiguredError>()( |
| 14 | "DesktopLocalEnvironmentAuthBackendNotConfiguredError", | 15 | "DesktopLocalEnvironmentAuthBackendNotConfiguredError", |
| ... | @@ -42,7 +43,7 @@ export class DesktopLocalEnvironmentAuth extends Context.Service< | ... | @@ -42,7 +43,7 @@ export class DesktopLocalEnvironmentAuth extends Context.Service< |
| 42 | >()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {} | 43 | >()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {} |
| 43 | 44 | ||
| 44 | export const make = Effect.gen(function* () { | 45 | export const make = Effect.gen(function* () { |
| 45 | const backendManager = yield* DesktopBackendManager.DesktopBackendManager; | 46 | const pool = yield* DesktopBackendPool.DesktopBackendPool; |
| 46 | const httpClient = yield* HttpClient.HttpClient; | 47 | const httpClient = yield* HttpClient.HttpClient; |
| 47 | const tokenRef = yield* Ref.make(Option.none<string>()); | 48 | const tokenRef = yield* Ref.make(Option.none<string>()); |
| 48 | const mutex = yield* Semaphore.make(1); | 49 | const mutex = yield* Semaphore.make(1); |
| ... | @@ -55,14 +56,20 @@ export const make = Effect.gen(function* () { | ... | @@ -55,14 +56,20 @@ export const make = Effect.gen(function* () { |
| 55 | return cached.value; | 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 | if (Option.isNone(configOption)) { | 62 | if (Option.isNone(configOption)) { |
| 60 | return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); | 63 | return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); |
| 61 | } | 64 | } |
| 62 | const config = configOption.value; | 65 | const config = configOption.value; |
| 66 | const credential = config.bootstrap.desktopBootstrapToken; | ||
| 67 | if (!credential) { | ||
| 68 | return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); | ||
| 69 | } | ||
| 63 | const session = yield* bootstrapRemoteBearerSession({ | 70 | const session = yield* bootstrapRemoteBearerSession({ |
| 64 | httpBaseUrl: config.httpBaseUrl.href, | 71 | httpBaseUrl: config.httpBaseUrl.href, |
| 65 | credential: config.bootstrap.desktopBootstrapToken, | 72 | credential, |
| 66 | clientMetadata: { | 73 | clientMetadata: { |
| 67 | label: "T3 Code Desktop", | 74 | label: "T3 Code Desktop", |
| 68 | deviceType: "desktop", | 75 | deviceType: "desktop", |
apps/desktop/src/backend/DesktopServerExposure.test.ts+5| ... | @@ -253,6 +253,11 @@ describe("DesktopServerExposure", () => { | ... | @@ -253,6 +253,11 @@ describe("DesktopServerExposure", () => { |
| 253 | setServerExposureMode: () => Effect.fail(settingsFailure), | 253 | setServerExposureMode: () => Effect.fail(settingsFailure), |
| 254 | setTailscaleServe: () => Effect.fail(settingsFailure), | 254 | setTailscaleServe: () => Effect.fail(settingsFailure), |
| 255 | setUpdateChannel: () => Effect.die("unexpected update channel change"), | 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 | } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); | 261 | } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); |
| 257 | 262 | ||
| 258 | return withHarness( | 263 | return withHarness( |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+8-2| ... | @@ -33,21 +33,22 @@ import { | ... | @@ -33,21 +33,22 @@ import { |
| 33 | import { | 33 | import { |
| 34 | confirm, | 34 | confirm, |
| 35 | getAppBranding, | 35 | getAppBranding, |
| 36 | getLocalEnvironmentBootstraps, | ||
| 36 | getLocalEnvironmentBearerToken, | 37 | getLocalEnvironmentBearerToken, |
| 37 | getLocalEnvironmentBootstrap, | ||
| 38 | openExternal, | 38 | openExternal, |
| 39 | pickFolder, | 39 | pickFolder, |
| 40 | setTheme, | 40 | setTheme, |
| 41 | showContextMenu, | 41 | showContextMenu, |
| 42 | } from "./methods/window.ts"; | 42 | } from "./methods/window.ts"; |
| 43 | import * as PreviewIpc from "./methods/preview.ts"; | 43 | import * as PreviewIpc from "./methods/preview.ts"; |
| 44 | import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; | ||
| 44 | 45 | ||
| 45 | export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { | 46 | export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { |
| 46 | const ipc = yield* DesktopIpc.DesktopIpc; | 47 | const ipc = yield* DesktopIpc.DesktopIpc; |
| 47 | yield* PreviewIpc.installPreviewEventForwarding(); | 48 | yield* PreviewIpc.installPreviewEventForwarding(); |
| 48 | 49 | ||
| 49 | yield* ipc.handleSync(getAppBranding); | 50 | yield* ipc.handleSync(getAppBranding); |
| 50 | yield* ipc.handleSync(getLocalEnvironmentBootstrap); | 51 | yield* ipc.handleSync(getLocalEnvironmentBootstraps); |
| 51 | yield* ipc.handle(getLocalEnvironmentBearerToken); | 52 | yield* ipc.handle(getLocalEnvironmentBearerToken); |
| 52 | 53 | ||
| 53 | yield* ipc.handle(getClientSettings); | 54 | yield* ipc.handle(getClientSettings); |
| ... | @@ -70,6 +71,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" | ... | @@ -70,6 +71,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 70 | yield* ipc.handle(setTailscaleServeEnabled); | 71 | yield* ipc.handle(setTailscaleServeEnabled); |
| 71 | yield* ipc.handle(getAdvertisedEndpoints); | 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 | yield* ipc.handle(pickFolder); | 79 | yield* ipc.handle(pickFolder); |
| 74 | yield* ipc.handle(confirm); | 80 | yield* ipc.handle(confirm); |
| 75 | yield* ipc.handle(setTheme); | 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,7 +11,7 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 11 | export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | 11 | export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 12 | export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; | 12 | export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 13 | export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; | 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 | export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = | 15 | export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = |
| 16 | "desktop:get-local-environment-bearer-token"; | 16 | "desktop:get-local-environment-bearer-token"; |
| 17 | export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; | 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,6 +32,10 @@ export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-st |
| 32 | export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; | 32 | export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; |
| 33 | export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; | 33 | export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; |
| 34 | export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; | 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 | export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; | 39 | export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; |
| 36 | export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; | 40 | export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; |
| 37 | export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; | 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,14 +4,19 @@ import { |
| 4 | DesktopEnvironmentBootstrapSchema, | 4 | DesktopEnvironmentBootstrapSchema, |
| 5 | DesktopThemeSchema, | 5 | DesktopThemeSchema, |
| 6 | PickFolderOptionsSchema, | 6 | PickFolderOptionsSchema, |
| 7 | PRIMARY_LOCAL_ENVIRONMENT_ID, | ||
| 8 | type DesktopEnvironmentBootstrap, | ||
| 7 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 8 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 9 | import * as Option from "effect/Option"; | 11 | import * as Option from "effect/Option"; |
| 10 | import * as Schema from "effect/Schema"; | 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 | import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; | 15 | import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; |
| 14 | import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; | 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 | import * as ElectronDialog from "../../electron/ElectronDialog.ts"; | 20 | import * as ElectronDialog from "../../electron/ElectronDialog.ts"; |
| 16 | import * as ElectronMenu from "../../electron/ElectronMenu.ts"; | 21 | import * as ElectronMenu from "../../electron/ElectronMenu.ts"; |
| 17 | import * as ElectronShell from "../../electron/ElectronShell.ts"; | 22 | import * as ElectronShell from "../../electron/ElectronShell.ts"; |
| ... | @@ -19,6 +24,11 @@ import * as ElectronTheme from "../../electron/ElectronTheme.ts"; | ... | @@ -19,6 +24,11 @@ import * as ElectronTheme from "../../electron/ElectronTheme.ts"; |
| 19 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | 24 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; |
| 20 | import * as IpcChannels from "../channels.ts"; | 25 | import * as IpcChannels from "../channels.ts"; |
| 21 | import * as DesktopIpc from "../DesktopIpc.ts"; | 26 | import * as DesktopIpc from "../DesktopIpc.ts"; |
| 27 | import { | ||
| 28 | extractDistroFromUncPath, | ||
| 29 | resolveWslPickFolderDefaultPath, | ||
| 30 | wslUncPathToLinuxPath, | ||
| 31 | } from "../../wsl/wslPathParsing.ts"; | ||
| 22 | 32 | ||
| 23 | const ContextMenuPosition = Schema.Struct({ | 33 | const ContextMenuPosition = Schema.Struct({ |
| 24 | x: Schema.Number, | 34 | x: Schema.Number, |
| ... | @@ -45,26 +55,79 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ | ... | @@ -45,26 +55,79 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ |
| 45 | }), | 55 | }), |
| 46 | }); | 56 | }); |
| 47 | 57 | ||
| 48 | export const getLocalEnvironmentBootstrap = DesktopIpc.makeSyncIpcMethod({ | 58 | export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ |
| 49 | channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, | 59 | channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, |
| 50 | result: Schema.NullOr(DesktopEnvironmentBootstrapSchema), | 60 | result: Schema.Array(DesktopEnvironmentBootstrapSchema), |
| 51 | handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstrap")(function* () { | 61 | handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () { |
| 52 | const backendManager = yield* DesktopBackendManager.DesktopBackendManager; | 62 | const pool = yield* DesktopBackendPool.DesktopBackendPool; |
| 53 | const config = yield* backendManager.currentConfig; | 63 | const instances = yield* pool.list; |
| 54 | return Option.match(config, { | 64 | const bootstraps: DesktopEnvironmentBootstrap[] = []; |
| 55 | onNone: () => null, | 65 | for (const instance of instances) { |
| 56 | onSome: ({ bootstrap, httpBaseUrl }) => ({ | 66 | const isPrimary = instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID; |
| 57 | label: "Local environment", | 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 | httpBaseUrl: httpBaseUrl.href, | 108 | httpBaseUrl: httpBaseUrl.href, |
| 59 | wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), | 109 | wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), |
| 60 | ...(bootstrap.desktopBootstrapToken | 110 | ...(bootstrap.desktopBootstrapToken |
| 61 | ? { bootstrapToken: bootstrap.desktopBootstrapToken } | 111 | ? { bootstrapToken: bootstrap.desktopBootstrapToken } |
| 62 | : {}), | 112 | : {}), |
| 63 | }), | 113 | }); |
| 64 | }); | 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 | export const getLocalEnvironmentBearerToken = DesktopIpc.makeIpcMethod({ | 131 | export const getLocalEnvironmentBearerToken = DesktopIpc.makeIpcMethod({ |
| 69 | channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL, | 132 | channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL, |
| 70 | payload: Schema.Void, | 133 | payload: Schema.Void, |
| ... | @@ -83,11 +146,62 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ | ... | @@ -83,11 +146,62 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ |
| 83 | const dialog = yield* ElectronDialog.ElectronDialog; | 146 | const dialog = yield* ElectronDialog.ElectronDialog; |
| 84 | const electronWindow = yield* ElectronWindow.ElectronWindow; | 147 | const electronWindow = yield* ElectronWindow.ElectronWindow; |
| 85 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | 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 | const selectedPath = yield* dialog.pickFolder({ | 184 | const selectedPath = yield* dialog.pickFolder({ |
| 87 | owner: yield* electronWindow.focusedMainOrFirst, | 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,7 +31,7 @@ import * as DesktopClerk from "./app/DesktopClerk.ts"; |
| 31 | import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; | 31 | import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; |
| 32 | import * as DesktopAssets from "./app/DesktopAssets.ts"; | 32 | import * as DesktopAssets from "./app/DesktopAssets.ts"; |
| 33 | import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; | 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 | import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts"; | 35 | import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts"; |
| 36 | import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; | 36 | import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; |
| 37 | import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; | 37 | import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; |
| ... | @@ -50,6 +50,8 @@ import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; | ... | @@ -50,6 +50,8 @@ import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 50 | import * as BrowserSession from "./preview/BrowserSession.ts"; | 50 | import * as BrowserSession from "./preview/BrowserSession.ts"; |
| 51 | import * as PreviewManager from "./preview/Manager.ts"; | 51 | import * as PreviewManager from "./preview/Manager.ts"; |
| 52 | import * as DesktopWindow from "./window/DesktopWindow.ts"; | 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 | const desktopEnvironmentLayer = Layer.unwrap( | 56 | const desktopEnvironmentLayer = Layer.unwrap( |
| 55 | Effect.gen(function* () { | 57 | Effect.gen(function* () { |
| ... | @@ -143,12 +145,25 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( | ... | @@ -143,12 +145,25 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( |
| 143 | Layer.provideMerge(desktopPreviewLayer), | 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 | Layer.provideMerge(DesktopAppIdentity.layer), | 154 | Layer.provideMerge(DesktopAppIdentity.layer), |
| 148 | Layer.provideMerge(DesktopBackendConfiguration.layer), | 155 | Layer.provideMerge(DesktopBackendConfiguration.layer), |
| 156 | Layer.provideMerge(DesktopWslEnvironment.layer), | ||
| 149 | Layer.provideMerge(desktopWindowLayer), | 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 | const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( | 167 | const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( |
| 153 | Layer.provideMerge(desktopBackendLayer), | 168 | Layer.provideMerge(desktopBackendLayer), |
| 154 | ); | 169 | ); |
| ... | @@ -160,6 +175,7 @@ const desktopApplicationLayer = Layer.mergeAll( | ... | @@ -160,6 +175,7 @@ const desktopApplicationLayer = Layer.mergeAll( |
| 160 | desktopSshLayer, | 175 | desktopSshLayer, |
| 161 | ).pipe( | 176 | ).pipe( |
| 162 | Layer.provideMerge(DesktopUpdates.layer), | 177 | Layer.provideMerge(DesktopUpdates.layer), |
| 178 | Layer.provideMerge(desktopWslBackendLayer), | ||
| 163 | Layer.provideMerge(desktopLocalEnvironmentAuthLayer), | 179 | Layer.provideMerge(desktopLocalEnvironmentAuthLayer), |
| 164 | ); | 180 | ); |
| 165 | 181 |
apps/desktop/src/preload.ts+10-5| ... | @@ -35,12 +35,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -35,12 +35,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 35 | } | 35 | } |
| 36 | return result as ReturnType<DesktopBridge["getAppBranding"]>; | 36 | return result as ReturnType<DesktopBridge["getAppBranding"]>; |
| 37 | }, | 37 | }, |
| 38 | getLocalEnvironmentBootstrap: () => { | 38 | getLocalEnvironmentBootstraps: () => { |
| 39 | const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); | 39 | const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); |
| 40 | if (typeof result !== "object" || result === null) { | 40 | if (!Array.isArray(result)) { |
| 41 | return null; | 41 | return []; |
| 42 | } | 42 | } |
| 43 | return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>; | 43 | return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstraps"]>; |
| 44 | }, | 44 | }, |
| 45 | getLocalEnvironmentBearerToken: () => | 45 | getLocalEnvironmentBearerToken: () => |
| 46 | ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), | 46 | ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), |
| ... | @@ -91,6 +91,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -91,6 +91,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 91 | setTailscaleServeEnabled: (input) => | 91 | setTailscaleServeEnabled: (input) => |
| 92 | ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), | 92 | ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), |
| 93 | getAdvertisedEndpoints: () => ipcRenderer.invoke(IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL), | 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 | pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), | 99 | pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), |
| 95 | confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), | 100 | confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), |
| 96 | setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), | 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,6 +16,10 @@ const DesktopSettingsPatch = Schema.Struct({ |
| 16 | tailscaleServePort: Schema.optionalKey(Schema.Number), | 16 | tailscaleServePort: Schema.optionalKey(Schema.Number), |
| 17 | updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])), | 17 | updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])), |
| 18 | updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), | 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 | const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); | 25 | const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); |
| ... | @@ -92,6 +96,9 @@ describe("DesktopSettings", () => { | ... | @@ -92,6 +96,9 @@ describe("DesktopSettings", () => { |
| 92 | tailscaleServePort: 443, | 96 | tailscaleServePort: 443, |
| 93 | updateChannel: "nightly", | 97 | updateChannel: "nightly", |
| 94 | updateChannelConfiguredByUser: false, | 98 | updateChannelConfiguredByUser: false, |
| 99 | wslBackendEnabled: false, | ||
| 100 | wslOnly: false, | ||
| 101 | wslDistro: null, | ||
| 95 | } satisfies DesktopAppSettings.DesktopSettings, | 102 | } satisfies DesktopAppSettings.DesktopSettings, |
| 96 | ); | 103 | ); |
| 97 | }); | 104 | }); |
| ... | @@ -114,6 +121,9 @@ describe("DesktopSettings", () => { | ... | @@ -114,6 +121,9 @@ describe("DesktopSettings", () => { |
| 114 | tailscaleServePort: 8443, | 121 | tailscaleServePort: 8443, |
| 115 | updateChannel: "latest", | 122 | updateChannel: "latest", |
| 116 | updateChannelConfiguredByUser: true, | 123 | updateChannelConfiguredByUser: true, |
| 124 | wslBackendEnabled: false, | ||
| 125 | wslOnly: false, | ||
| 126 | wslDistro: null, | ||
| 117 | } satisfies DesktopAppSettings.DesktopSettings); | 127 | } satisfies DesktopAppSettings.DesktopSettings); |
| 118 | 128 | ||
| 119 | const exposure = yield* settings.setServerExposureMode("local-only"); | 129 | const exposure = yield* settings.setServerExposureMode("local-only"); |
| ... | @@ -214,6 +224,9 @@ describe("DesktopSettings", () => { | ... | @@ -214,6 +224,9 @@ describe("DesktopSettings", () => { |
| 214 | tailscaleServePort: 8443, | 224 | tailscaleServePort: 8443, |
| 215 | updateChannel: "latest", | 225 | updateChannel: "latest", |
| 216 | updateChannelConfiguredByUser: false, | 226 | updateChannelConfiguredByUser: false, |
| 227 | wslBackendEnabled: false, | ||
| 228 | wslOnly: false, | ||
| 229 | wslDistro: null, | ||
| 217 | } satisfies DesktopAppSettings.DesktopSettings); | 230 | } satisfies DesktopAppSettings.DesktopSettings); |
| 218 | }), | 231 | }), |
| 219 | ), | 232 | ), |
| ... | @@ -253,6 +266,9 @@ describe("DesktopSettings", () => { | ... | @@ -253,6 +266,9 @@ describe("DesktopSettings", () => { |
| 253 | tailscaleServePort: 443, | 266 | tailscaleServePort: 443, |
| 254 | updateChannel: "nightly", | 267 | updateChannel: "nightly", |
| 255 | updateChannelConfiguredByUser: false, | 268 | updateChannelConfiguredByUser: false, |
| 269 | wslBackendEnabled: false, | ||
| 270 | wslOnly: false, | ||
| 271 | wslDistro: null, | ||
| 256 | } satisfies DesktopAppSettings.DesktopSettings); | 272 | } satisfies DesktopAppSettings.DesktopSettings); |
| 257 | }), | 273 | }), |
| 258 | { appVersion: "0.0.17-nightly.20260415.1" }, | 274 | { appVersion: "0.0.17-nightly.20260415.1" }, |
| ... | @@ -275,6 +291,9 @@ describe("DesktopSettings", () => { | ... | @@ -275,6 +291,9 @@ describe("DesktopSettings", () => { |
| 275 | tailscaleServePort: 443, | 291 | tailscaleServePort: 443, |
| 276 | updateChannel: "latest", | 292 | updateChannel: "latest", |
| 277 | updateChannelConfiguredByUser: true, | 293 | updateChannelConfiguredByUser: true, |
| 294 | wslBackendEnabled: false, | ||
| 295 | wslOnly: false, | ||
| 296 | wslDistro: null, | ||
| 278 | } satisfies DesktopAppSettings.DesktopSettings); | 297 | } satisfies DesktopAppSettings.DesktopSettings); |
| 279 | }), | 298 | }), |
| 280 | { appVersion: "0.0.17-nightly.20260415.1" }, | 299 | { appVersion: "0.0.17-nightly.20260415.1" }, |
| ... | @@ -296,8 +315,101 @@ describe("DesktopSettings", () => { | ... | @@ -296,8 +315,101 @@ describe("DesktopSettings", () => { |
| 296 | tailscaleServePort: 443, | 315 | tailscaleServePort: 443, |
| 297 | updateChannel: "latest", | 316 | updateChannel: "latest", |
| 298 | updateChannelConfiguredByUser: false, | 317 | updateChannelConfiguredByUser: false, |
| 318 | wslBackendEnabled: false, | ||
| 319 | wslOnly: false, | ||
| 320 | wslDistro: null, | ||
| 299 | } satisfies DesktopAppSettings.DesktopSettings); | 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,6 +17,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 17 | 17 | ||
| 18 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 18 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 19 | import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; | 19 | import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; |
| 20 | import { isValidDistroName } from "../wsl/wslPathParsing.ts"; | ||
| 20 | 21 | ||
| 21 | export interface DesktopSettings { | 22 | export interface DesktopSettings { |
| 22 | readonly serverExposureMode: DesktopServerExposureMode; | 23 | readonly serverExposureMode: DesktopServerExposureMode; |
| ... | @@ -24,6 +25,21 @@ export interface DesktopSettings { | ... | @@ -24,6 +25,21 @@ export interface DesktopSettings { |
| 24 | readonly tailscaleServePort: number; | 25 | readonly tailscaleServePort: number; |
| 25 | readonly updateChannel: DesktopUpdateChannel; | 26 | readonly updateChannel: DesktopUpdateChannel; |
| 26 | readonly updateChannelConfiguredByUser: boolean; | 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 | export interface DesktopSettingsChange { | 45 | export interface DesktopSettingsChange { |
| ... | @@ -39,6 +55,9 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { | ... | @@ -39,6 +55,9 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { |
| 39 | tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, | 55 | tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, |
| 40 | updateChannel: "latest", | 56 | updateChannel: "latest", |
| 41 | updateChannelConfiguredByUser: false, | 57 | updateChannelConfiguredByUser: false, |
| 58 | wslBackendEnabled: false, | ||
| 59 | wslDistro: null, | ||
| 60 | wslOnly: false, | ||
| 42 | }; | 61 | }; |
| 43 | 62 | ||
| 44 | const DesktopSettingsDocument = Schema.Struct({ | 63 | const DesktopSettingsDocument = Schema.Struct({ |
| ... | @@ -47,6 +66,13 @@ const DesktopSettingsDocument = Schema.Struct({ | ... | @@ -47,6 +66,13 @@ const DesktopSettingsDocument = Schema.Struct({ |
| 47 | tailscaleServePort: Schema.optionalKey(Schema.Number), | 66 | tailscaleServePort: Schema.optionalKey(Schema.Number), |
| 48 | updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), | 67 | updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), |
| 49 | updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), | 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 | type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; | 78 | type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; |
| ... | @@ -98,6 +124,20 @@ export class DesktopAppSettings extends Context.Service< | ... | @@ -98,6 +124,20 @@ export class DesktopAppSettings extends Context.Service< |
| 98 | readonly setUpdateChannel: ( | 124 | readonly setUpdateChannel: ( |
| 99 | channel: DesktopUpdateChannel, | 125 | channel: DesktopUpdateChannel, |
| 100 | ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>; | 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 | >()("@t3tools/desktop/settings/DesktopAppSettings") {} | 142 | >()("@t3tools/desktop/settings/DesktopAppSettings") {} |
| 103 | 143 | ||
| ... | @@ -114,6 +154,10 @@ function normalizeTailscaleServePort(value: unknown): number { | ... | @@ -114,6 +154,10 @@ function normalizeTailscaleServePort(value: unknown): number { |
| 114 | : DEFAULT_TAILSCALE_SERVE_PORT; | 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 | function normalizeDesktopSettingsDocument( | 161 | function normalizeDesktopSettingsDocument( |
| 118 | parsed: DesktopSettingsDocument, | 162 | parsed: DesktopSettingsDocument, |
| 119 | appVersion: string, | 163 | appVersion: string, |
| ... | @@ -125,6 +169,13 @@ function normalizeDesktopSettingsDocument( | ... | @@ -125,6 +169,13 @@ function normalizeDesktopSettingsDocument( |
| 125 | parsed.updateChannelConfiguredByUser === true || | 169 | parsed.updateChannelConfiguredByUser === true || |
| 126 | (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly")); | 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 | return { | 179 | return { |
| 129 | serverExposureMode: | 180 | serverExposureMode: |
| 130 | parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", | 181 | parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", |
| ... | @@ -134,6 +185,9 @@ function normalizeDesktopSettingsDocument( | ... | @@ -134,6 +185,9 @@ function normalizeDesktopSettingsDocument( |
| 134 | ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) | 185 | ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) |
| 135 | : defaultSettings.updateChannel, | 186 | : defaultSettings.updateChannel, |
| 136 | updateChannelConfiguredByUser, | 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,6 +212,15 @@ function toDesktopSettingsDocument( |
| 158 | if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) { | 212 | if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) { |
| 159 | document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser; | 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 | return document; | 225 | return document; |
| 163 | } | 226 | } |
| ... | @@ -204,6 +267,38 @@ function setUpdateChannel( | ... | @@ -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 | function readSettings( | 302 | function readSettings( |
| 208 | fileSystem: FileSystem.FileSystem, | 303 | fileSystem: FileSystem.FileSystem, |
| 209 | settingsPath: string, | 304 | settingsPath: string, |
| ... | @@ -287,6 +382,12 @@ export const make = Effect.gen(function* () { | ... | @@ -287,6 +382,12 @@ export const make = Effect.gen(function* () { |
| 287 | const crypto = yield* Crypto.Crypto; | 382 | const crypto = yield* Crypto.Crypto; |
| 288 | const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); | 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 | const persist = ( | 391 | const persist = ( |
| 291 | update: (settings: DesktopSettings) => DesktopSettings, | 392 | update: (settings: DesktopSettings) => DesktopSettings, |
| 292 | ): Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError> => | 393 | ): Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError> => |
| ... | @@ -342,6 +443,26 @@ export const make = Effect.gen(function* () { | ... | @@ -342,6 +443,26 @@ export const make = Effect.gen(function* () { |
| 342 | persist((settings) => setUpdateChannel(settings, channel)).pipe( | 443 | persist((settings) => setUpdateChannel(settings, channel)).pipe( |
| 343 | Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), | 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,6 +492,12 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET |
| 371 | update((settings) => setServerExposureMode(settings, mode)), | 492 | update((settings) => setServerExposureMode(settings, mode)), |
| 372 | setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), | 493 | setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), |
| 373 | setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), | 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,7 +13,7 @@ import * as References from "effect/References"; |
| 13 | import * as Ref from "effect/Ref"; | 13 | import * as Ref from "effect/Ref"; |
| 14 | import * as TestClock from "effect/testing/TestClock"; | 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 | import * as DesktopConfig from "../app/DesktopConfig.ts"; | 17 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 18 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 18 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 19 | import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; | 19 | import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; |
| ... | @@ -107,7 +107,9 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { | ... | @@ -107,7 +107,9 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 107 | syncAllAppearance: () => Effect.void, | 107 | syncAllAppearance: () => Effect.void, |
| 108 | } satisfies ElectronWindow.ElectronWindow["Service"]); | 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 | start: Effect.void, | 113 | start: Effect.void, |
| 112 | stop: () => options.stopBackend ?? Effect.void, | 114 | stop: () => options.stopBackend ?? Effect.void, |
| 113 | currentConfig: Effect.succeed(Option.none()), | 115 | currentConfig: Effect.succeed(Option.none()), |
| ... | @@ -118,7 +120,9 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { | ... | @@ -118,7 +120,9 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 118 | restartAttempt: 0, | 120 | restartAttempt: 0, |
| 119 | restartScheduled: false, | 121 | restartScheduled: false, |
| 120 | }), | 122 | }), |
| 121 | }); | 123 | waitForReady: () => Effect.succeed(true), |
| 124 | }; | ||
| 125 | const backendLayer = DesktopBackendPool.layerTest([stubBackendInstance]); | ||
| 122 | 126 | ||
| 123 | const environmentLayer = DesktopEnvironment.layer({ | 127 | const environmentLayer = DesktopEnvironment.layer({ |
| 124 | dirname: "/repo/apps/desktop/src", | 128 | dirname: "/repo/apps/desktop/src", |
| ... | @@ -152,6 +156,11 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { | ... | @@ -152,6 +156,11 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { |
| 152 | setServerExposureMode: () => Effect.die("unexpected server exposure update"), | 156 | setServerExposureMode: () => Effect.die("unexpected server exposure update"), |
| 153 | setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), | 157 | setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), |
| 154 | setUpdateChannel: () => Effect.fail(setUpdateChannelError), | 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 | } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) | 164 | } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) |
| 156 | : DesktopAppSettings.layer; | 165 | : DesktopAppSettings.layer; |
| 157 | 166 |
apps/desktop/src/updates/DesktopUpdates.ts+15-3| ... | @@ -18,7 +18,7 @@ import * as Ref from "effect/Ref"; | ... | @@ -18,7 +18,7 @@ import * as Ref from "effect/Ref"; |
| 18 | import * as Schema from "effect/Schema"; | 18 | import * as Schema from "effect/Schema"; |
| 19 | import * as Scope from "effect/Scope"; | 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 | import * as DesktopConfig from "../app/DesktopConfig.ts"; | 22 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 23 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 23 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 24 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | 24 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| ... | @@ -241,7 +241,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean | ... | @@ -241,7 +241,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean |
| 241 | 241 | ||
| 242 | export const make = Effect.gen(function* () { | 242 | export const make = Effect.gen(function* () { |
| 243 | const config = yield* DesktopConfig.DesktopConfig; | 243 | const config = yield* DesktopConfig.DesktopConfig; |
| 244 | const backendManager = yield* DesktopBackendManager.DesktopBackendManager; | 244 | const pool = yield* DesktopBackendPool.DesktopBackendPool; |
| 245 | const desktopState = yield* DesktopState.DesktopState; | 245 | const desktopState = yield* DesktopState.DesktopState; |
| 246 | const electronUpdater = yield* ElectronUpdater.ElectronUpdater; | 246 | const electronUpdater = yield* ElectronUpdater.ElectronUpdater; |
| 247 | const electronWindow = yield* ElectronWindow.ElectronWindow; | 247 | const electronWindow = yield* ElectronWindow.ElectronWindow; |
| ... | @@ -458,7 +458,19 @@ export const make = Effect.gen(function* () { | ... | @@ -458,7 +458,19 @@ export const make = Effect.gen(function* () { |
| 458 | yield* Ref.set(updateInstallInFlightRef, true); | 458 | yield* Ref.set(updateInstallInFlightRef, true); |
| 459 | 459 | ||
| 460 | return yield* Effect.gen(function* () { | 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 | yield* electronWindow.destroyAll; | 474 | yield* electronWindow.destroyAll; |
| 463 | yield* electronUpdater.quitAndInstall({ | 475 | yield* electronUpdater.quitAndInstall({ |
| 464 | isSilent: true, | 476 | isSilent: true, |
apps/desktop/src/window/DesktopApplicationMenu.test.ts+3-1| ... | @@ -73,7 +73,9 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) => | ... | @@ -73,7 +73,9 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) => |
| 73 | revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), | 73 | revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), |
| 74 | activate: Effect.void, | 74 | activate: Effect.void, |
| 75 | createMainIfBackendReady: Effect.void, | 75 | createMainIfBackendReady: Effect.void, |
| 76 | handleBackendReady: Effect.void, | 76 | showConnectingSplash: Effect.void, |
| 77 | handleBackendReady: () => Effect.void, | ||
| 78 | handleBackendNotReady: Effect.void, | ||
| 77 | dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), | 79 | dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), |
| 78 | syncAppearance: Effect.void, | 80 | syncAppearance: Effect.void, |
| 79 | } satisfies DesktopWindow.DesktopWindow["Service"]); | 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,6 +28,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; |
| 28 | import * as ElectronShell from "../electron/ElectronShell.ts"; | 28 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 29 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; | 29 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| 30 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | 30 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 31 | import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; | ||
| 31 | import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; | 32 | import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; |
| 32 | import * as DesktopWindow from "./DesktopWindow.ts"; | 33 | import * as DesktopWindow from "./DesktopWindow.ts"; |
| 33 | import * as PreviewManager from "../preview/Manager.ts"; | 34 | import * as PreviewManager from "../preview/Manager.ts"; |
| ... | @@ -62,6 +63,7 @@ function makeFakeBrowserWindow() { | ... | @@ -62,6 +63,7 @@ function makeFakeBrowserWindow() { |
| 62 | }; | 63 | }; |
| 63 | 64 | ||
| 64 | const window = { | 65 | const window = { |
| 66 | close: vi.fn(), | ||
| 65 | focus: vi.fn(), | 67 | focus: vi.fn(), |
| 66 | isDestroyed: vi.fn(() => false), | 68 | isDestroyed: vi.fn(() => false), |
| 67 | isMinimized: vi.fn(() => false), | 69 | isMinimized: vi.fn(() => false), |
| ... | @@ -83,6 +85,7 @@ function makeFakeBrowserWindow() { | ... | @@ -83,6 +85,7 @@ function makeFakeBrowserWindow() { |
| 83 | loadURL: window.loadURL, | 85 | loadURL: window.loadURL, |
| 84 | openDevTools: webContents.openDevTools, | 86 | openDevTools: webContents.openDevTools, |
| 85 | reload: webContents.reload, | 87 | reload: webContents.reload, |
| 88 | send: webContents.send, | ||
| 86 | setAutoHideCursor: window.setAutoHideCursor, | 89 | setAutoHideCursor: window.setAutoHideCursor, |
| 87 | webContentsListeners, | 90 | webContentsListeners, |
| 88 | }; | 91 | }; |
| ... | @@ -191,6 +194,100 @@ function makeTestLayer(input: { | ... | @@ -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 | describe("DesktopWindow", () => { | 291 | describe("DesktopWindow", () => { |
| 195 | it("recognizes only same-origin renderer navigations", () => { | 292 | it("recognizes only same-origin renderer navigations", () => { |
| 196 | assert.isTrue( | 293 | assert.isTrue( |
| ... | @@ -231,7 +328,7 @@ describe("DesktopWindow", () => { | ... | @@ -231,7 +328,7 @@ describe("DesktopWindow", () => { |
| 231 | yield* desktopWindow.activate; | 328 | yield* desktopWindow.activate; |
| 232 | assert.equal(yield* Ref.get(createCount), 0); | 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 | assert.equal(yield* Ref.get(createCount), 1); | 332 | assert.equal(yield* Ref.get(createCount), 1); |
| 236 | assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); | 333 | assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); |
| 237 | assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); | 334 | assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); |
| ... | @@ -254,7 +351,7 @@ describe("DesktopWindow", () => { | ... | @@ -254,7 +351,7 @@ describe("DesktopWindow", () => { |
| 254 | 351 | ||
| 255 | yield* Effect.gen(function* () { | 352 | yield* Effect.gen(function* () { |
| 256 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | 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 | const didFailLoad = fakeWindow.webContentsListeners.get("did-fail-load"); | 356 | const didFailLoad = fakeWindow.webContentsListeners.get("did-fail-load"); |
| 260 | const didFinishLoad = fakeWindow.webContentsListeners.get("did-finish-load"); | 357 | const didFinishLoad = fakeWindow.webContentsListeners.get("did-finish-load"); |
| ... | @@ -323,7 +420,7 @@ describe("DesktopWindow", () => { | ... | @@ -323,7 +420,7 @@ describe("DesktopWindow", () => { |
| 323 | 420 | ||
| 324 | yield* Effect.gen(function* () { | 421 | yield* Effect.gen(function* () { |
| 325 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | 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 | const willNavigate = fakeWindow.webContentsListeners.get("will-navigate"); | 425 | const willNavigate = fakeWindow.webContentsListeners.get("will-navigate"); |
| 329 | if (!willNavigate) { | 426 | if (!willNavigate) { |
| ... | @@ -345,4 +442,108 @@ describe("DesktopWindow", () => { | ... | @@ -345,4 +442,108 @@ describe("DesktopWindow", () => { |
| 345 | }).pipe(Effect.provide(layer)); | 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,7 +10,6 @@ import type * as Electron from "electron"; |
| 10 | import * as DesktopAssets from "../app/DesktopAssets.ts"; | 10 | import * as DesktopAssets from "../app/DesktopAssets.ts"; |
| 11 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 11 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 12 | import { makeComponentLogger } from "../app/DesktopObservability.ts"; | 12 | import { makeComponentLogger } from "../app/DesktopObservability.ts"; |
| 13 | import * as DesktopState from "../app/DesktopState.ts"; | ||
| 14 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; | 13 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; |
| 15 | import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; | 14 | import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; |
| 16 | import * as ElectronShell from "../electron/ElectronShell.ts"; | 15 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| ... | @@ -42,7 +41,6 @@ type WindowTitleBarOptions = Pick< | ... | @@ -42,7 +41,6 @@ type WindowTitleBarOptions = Pick< |
| 42 | type DesktopWindowRuntimeServices = | 41 | type DesktopWindowRuntimeServices = |
| 43 | | DesktopEnvironment.DesktopEnvironment | 42 | | DesktopEnvironment.DesktopEnvironment |
| 44 | | DesktopAssets.DesktopAssets | 43 | | DesktopAssets.DesktopAssets |
| 45 | | DesktopState.DesktopState | ||
| 46 | | ElectronMenu.ElectronMenu | 44 | | ElectronMenu.ElectronMenu |
| 47 | | ElectronShell.ElectronShell | 45 | | ElectronShell.ElectronShell |
| 48 | | ElectronTheme.ElectronTheme | 46 | | ElectronTheme.ElectronTheme |
| ... | @@ -61,7 +59,22 @@ export class DesktopWindow extends Context.Service< | ... | @@ -61,7 +59,22 @@ export class DesktopWindow extends Context.Service< |
| 61 | readonly revealOrCreateMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>; | 59 | readonly revealOrCreateMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>; |
| 62 | readonly activate: Effect.Effect<void, DesktopWindowError>; | 60 | readonly activate: Effect.Effect<void, DesktopWindowError>; |
| 63 | readonly createMainIfBackendReady: Effect.Effect<void, DesktopWindowError>; | 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 | readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>; | 78 | readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>; |
| 66 | readonly syncAppearance: Effect.Effect<void>; | 79 | readonly syncAppearance: Effect.Effect<void>; |
| 67 | } | 80 | } |
| ... | @@ -86,6 +99,18 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { | ... | @@ -86,6 +99,18 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { |
| 86 | return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; | 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 | export function isSameOriginRendererNavigation(input: { | 114 | export function isSameOriginRendererNavigation(input: { |
| 90 | readonly applicationUrl: string; | 115 | readonly applicationUrl: string; |
| 91 | readonly navigationUrl: string; | 116 | readonly navigationUrl: string; |
| ... | @@ -177,11 +202,45 @@ export const make = Effect.gen(function* () { | ... | @@ -177,11 +202,45 @@ export const make = Effect.gen(function* () { |
| 177 | const electronTheme = yield* ElectronTheme.ElectronTheme; | 202 | const electronTheme = yield* ElectronTheme.ElectronTheme; |
| 178 | const electronWindow = yield* ElectronWindow.ElectronWindow; | 203 | const electronWindow = yield* ElectronWindow.ElectronWindow; |
| 179 | const previewManager = yield* PreviewManager.PreviewManager; | 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 | const context = yield* Effect.context<DesktopWindowRuntimeServices>(); | 214 | const context = yield* Effect.context<DesktopWindowRuntimeServices>(); |
| 182 | const runFork = Effect.runForkWith(context); | 215 | const runFork = Effect.runForkWith(context); |
| 183 | const runPromise = Effect.runPromiseWith(context); | 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 | const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< | 244 | const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< |
| 186 | Electron.BrowserWindow, | 245 | Electron.BrowserWindow, |
| 187 | DesktopWindowError | 246 | DesktopWindowError |
| ... | @@ -402,7 +461,9 @@ export const make = Effect.gen(function* () { | ... | @@ -402,7 +461,9 @@ export const make = Effect.gen(function* () { |
| 402 | revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); | 461 | revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); |
| 403 | } | 462 | } |
| 404 | bindFirstRevealTrigger(revealSubscribers, () => { | 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 | loadApplication(); | 469 | loadApplication(); |
| ... | @@ -426,7 +487,7 @@ export const make = Effect.gen(function* () { | ... | @@ -426,7 +487,7 @@ export const make = Effect.gen(function* () { |
| 426 | }).pipe(Effect.withSpan("desktop.window.createMain")); | 487 | }).pipe(Effect.withSpan("desktop.window.createMain")); |
| 427 | 488 | ||
| 428 | const ensureMain = Effect.gen(function* () { | 489 | const ensureMain = Effect.gen(function* () { |
| 429 | const existingWindow = yield* electronWindow.currentMainOrFirst; | 490 | const existingWindow = yield* currentMainWindow; |
| 430 | if (Option.isSome(existingWindow)) { | 491 | if (Option.isSome(existingWindow)) { |
| 431 | return existingWindow.value; | 492 | return existingWindow.value; |
| 432 | } | 493 | } |
| ... | @@ -440,35 +501,101 @@ export const make = Effect.gen(function* () { | ... | @@ -440,35 +501,101 @@ export const make = Effect.gen(function* () { |
| 440 | }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); | 501 | }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); |
| 441 | 502 | ||
| 442 | const createMainIfBackendReady = Effect.gen(function* () { | 503 | const createMainIfBackendReady = Effect.gen(function* () { |
| 443 | const backendReady = yield* Ref.get(state.backendReady); | 504 | const backendReady = yield* Ref.get(backendReadyRef); |
| 444 | if (!backendReady) return; | 505 | if (!backendReady) return; |
| 445 | const existingWindow = yield* electronWindow.currentMainOrFirst; | 506 | const existingWindow = yield* currentMainWindow; |
| 446 | if (Option.isSome(existingWindow)) return; | 507 | if (Option.isSome(existingWindow)) return; |
| 447 | yield* createMain; | 508 | yield* createMain; |
| 448 | }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); | 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 | return DesktopWindow.of({ | 557 | return DesktopWindow.of({ |
| 451 | createMain, | 558 | createMain, |
| 452 | ensureMain, | 559 | ensureMain, |
| 453 | revealOrCreateMain, | 560 | revealOrCreateMain, |
| 454 | activate: Effect.gen(function* () { | 561 | activate: Effect.gen(function* () { |
| 455 | const existingWindow = yield* electronWindow.currentMainOrFirst; | 562 | const existingWindow = yield* currentMainWindow; |
| 456 | if (Option.isSome(existingWindow)) { | 563 | if (Option.isSome(existingWindow)) { |
| 457 | yield* electronWindow.reveal(existingWindow.value); | 564 | yield* electronWindow.reveal(existingWindow.value); |
| 458 | } else { | 565 | return; |
| 459 | yield* createMainIfBackendReady; | 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 | }).pipe(Effect.withSpan("desktop.window.activate")), | 581 | }).pipe(Effect.withSpan("desktop.window.activate")), |
| 462 | createMainIfBackendReady, | 582 | createMainIfBackendReady, |
| 463 | handleBackendReady: Effect.gen(function* () { | 583 | showConnectingSplash, |
| 464 | yield* Ref.set(state.backendReady, true); | 584 | handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { |
| 465 | yield* logWindowInfo("backend ready", { source: "http" }); | 585 | yield* Ref.set(backendReadyRef, true); |
| 586 | yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); | ||
| 466 | yield* createMainIfBackendReady; | 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 | dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { | 592 | dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { |
| 469 | yield* Effect.annotateCurrentSpan({ action }); | 593 | yield* Effect.annotateCurrentSpan({ action }); |
| 470 | const existingWindow = yield* electronWindow.focusedMainOrFirst; | 594 | const existingWindow = yield* focusedMainWindow; |
| 471 | const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* createMain; | 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 | const send = () => { | 600 | const send = () => { |
| 474 | if (targetWindow.isDestroyed()) return; | 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,11 +137,12 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 137 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), | 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 | Effect.gen(function* () { | 141 | Effect.gen(function* () { |
| 142 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | 142 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
| 143 | const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); | 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 | expect(first.method).toBe("desktop-bootstrap"); | 147 | expect(first.method).toBe("desktop-bootstrap"); |
| 147 | expect(first.scopes).toEqual([ | 148 | expect(first.scopes).toEqual([ |
| ... | @@ -155,7 +156,8 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { | ... | @@ -155,7 +156,8 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 155 | "relay:write", | 156 | "relay:write", |
| 156 | ]); | 157 | ]); |
| 157 | expect(first.subject).toBe("desktop-bootstrap"); | 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 | }).pipe( | 161 | }).pipe( |
| 160 | Effect.provide( | 162 | Effect.provide( |
| 161 | makePairingGrantStoreLayer({ | 163 | makePairingGrantStoreLayer({ |
| ... | @@ -169,7 +171,13 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { | ... | @@ -169,7 +171,13 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 169 | Effect.gen(function* () { | 171 | Effect.gen(function* () { |
| 170 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | 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 | const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); | 181 | const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); |
| 174 | 182 | ||
| 175 | expect(expired._tag).toBe("ExpiredBootstrapCredentialError"); | 183 | expect(expired._tag).toBe("ExpiredBootstrapCredentialError"); |
apps/server/src/auth/PairingGrantStore.ts+16-2| ... | @@ -234,6 +234,15 @@ type ConsumeResult = | ... | @@ -234,6 +234,15 @@ type ConsumeResult = |
| 234 | }; | 234 | }; |
| 235 | 235 | ||
| 236 | const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); | 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 | const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; | 246 | const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; |
| 238 | const PAIRING_TOKEN_LENGTH = 12; | 247 | const PAIRING_TOKEN_LENGTH = 12; |
| 239 | const PAIRING_TOKEN_REJECTION_LIMIT = | 248 | const PAIRING_TOKEN_REJECTION_LIMIT = |
| ... | @@ -295,9 +304,14 @@ export const make = Effect.gen(function* () { | ... | @@ -295,9 +304,14 @@ export const make = Effect.gen(function* () { |
| 295 | scopes: AuthAdministrativeScopes, | 304 | scopes: AuthAdministrativeScopes, |
| 296 | subject: "desktop-bootstrap", | 305 | subject: "desktop-bootstrap", |
| 297 | expiresAt: DateTime.add(now, { | 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,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 | const isBootstrapFdPathDuplicationError = Predicate.compose( | 228 | const isBootstrapFdPathDuplicationError = Predicate.compose( |
| 226 | Predicate.hasProperty("code"), | 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 | function resolveFdPath(fd: number, platform: NodeJS.Platform): string | undefined { | 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,6 +17,8 @@ import * as Sink from "effect/Sink"; |
| 17 | import * as Stream from "effect/Stream"; | 17 | import * as Stream from "effect/Stream"; |
| 18 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 18 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 19 | import { ChildProcessSpawner } from "effect/unstable/process"; | 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 | import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; | 23 | import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; |
| 22 | import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; | 24 | import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; |
| ... | @@ -35,6 +37,12 @@ const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor"); | ... | @@ -35,6 +37,12 @@ const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor"); |
| 35 | const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); | 37 | const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); |
| 36 | const encoder = new TextEncoder(); | 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 | function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { | 46 | function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { |
| 39 | if (provider === CURSOR_DRIVER) { | 47 | if (provider === CURSOR_DRIVER) { |
| 40 | return makeProviderMaintenanceCapabilities({ | 48 | return makeProviderMaintenanceCapabilities({ |
| ... | @@ -230,6 +238,7 @@ describe("providerMaintenanceRunner", () => { | ... | @@ -230,6 +238,7 @@ describe("providerMaintenanceRunner", () => { |
| 230 | }).pipe( | 238 | }).pipe( |
| 231 | Effect.provide( | 239 | Effect.provide( |
| 232 | Layer.mergeAll( | 240 | Layer.mergeAll( |
| 241 | NonWindowsPlatform, | ||
| 233 | latestVersionHttpClient("0.0.0"), | 242 | latestVersionHttpClient("0.0.0"), |
| 234 | mockSpawnerLayer((command, args) => { | 243 | mockSpawnerLayer((command, args) => { |
| 235 | calls.push({ command, args }); | 244 | calls.push({ command, args }); |
| ... | @@ -279,6 +288,7 @@ describe("providerMaintenanceRunner", () => { | ... | @@ -279,6 +288,7 @@ describe("providerMaintenanceRunner", () => { |
| 279 | }).pipe( | 288 | }).pipe( |
| 280 | Effect.provide( | 289 | Effect.provide( |
| 281 | Layer.mergeAll( | 290 | Layer.mergeAll( |
| 291 | NonWindowsPlatform, | ||
| 282 | latestVersionHttpClient("0.0.0"), | 292 | latestVersionHttpClient("0.0.0"), |
| 283 | mockSpawnerLayer((command, args) => { | 293 | mockSpawnerLayer((command, args) => { |
| 284 | calls.push({ command, args }); | 294 | calls.push({ command, args }); |
| ... | @@ -309,6 +319,7 @@ describe("providerMaintenanceRunner", () => { | ... | @@ -309,6 +319,7 @@ describe("providerMaintenanceRunner", () => { |
| 309 | }).pipe( | 319 | }).pipe( |
| 310 | Effect.provide( | 320 | Effect.provide( |
| 311 | Layer.mergeAll( | 321 | Layer.mergeAll( |
| 322 | NonWindowsPlatform, | ||
| 312 | latestVersionHttpClient("0.0.0"), | 323 | latestVersionHttpClient("0.0.0"), |
| 313 | mockSpawnerLayer((command, args) => { | 324 | mockSpawnerLayer((command, args) => { |
| 314 | calls.push({ command, args }); | 325 | calls.push({ command, args }); |
| ... | @@ -381,6 +392,7 @@ describe("providerMaintenanceRunner", () => { | ... | @@ -381,6 +392,7 @@ describe("providerMaintenanceRunner", () => { |
| 381 | }).pipe( | 392 | }).pipe( |
| 382 | Effect.provide( | 393 | Effect.provide( |
| 383 | Layer.mergeAll( | 394 | Layer.mergeAll( |
| 395 | NonWindowsPlatform, | ||
| 384 | latestVersionHttpClient("0.124.0-alpha.3"), | 396 | latestVersionHttpClient("0.124.0-alpha.3"), |
| 385 | mockSpawnerLayer((command, args) => { | 397 | mockSpawnerLayer((command, args) => { |
| 386 | calls.push({ command, args }); | 398 | calls.push({ command, args }); |
| ... | @@ -405,6 +417,7 @@ describe("providerMaintenanceRunner", () => { | ... | @@ -405,6 +417,7 @@ describe("providerMaintenanceRunner", () => { |
| 405 | }).pipe( | 417 | }).pipe( |
| 406 | Effect.provide( | 418 | Effect.provide( |
| 407 | Layer.mergeAll( | 419 | Layer.mergeAll( |
| 420 | NonWindowsPlatform, | ||
| 408 | latestVersionHttpClient("0.0.0"), | 421 | latestVersionHttpClient("0.0.0"), |
| 409 | mockSpawnerLayer(() => ({ stderr: "permission denied", code: 1 })), | 422 | mockSpawnerLayer(() => ({ stderr: "permission denied", code: 1 })), |
| 410 | ), | 423 | ), |
| ... | @@ -430,6 +443,7 @@ describe("providerMaintenanceRunner", () => { | ... | @@ -430,6 +443,7 @@ describe("providerMaintenanceRunner", () => { |
| 430 | }).pipe( | 443 | }).pipe( |
| 431 | Effect.provide( | 444 | Effect.provide( |
| 432 | Layer.mergeAll( | 445 | Layer.mergeAll( |
| 446 | NonWindowsPlatform, | ||
| 433 | latestVersionHttpClient("9.9.9"), | 447 | latestVersionHttpClient("9.9.9"), |
| 434 | mockSpawnerLayer(() => ({ stdout: "updated" })), | 448 | mockSpawnerLayer(() => ({ stdout: "updated" })), |
| 435 | ), | 449 | ), |
| ... | @@ -468,6 +482,7 @@ describe("providerMaintenanceRunner", () => { | ... | @@ -468,6 +482,7 @@ describe("providerMaintenanceRunner", () => { |
| 468 | }).pipe( | 482 | }).pipe( |
| 469 | Effect.provide( | 483 | Effect.provide( |
| 470 | Layer.mergeAll( | 484 | Layer.mergeAll( |
| 485 | NonWindowsPlatform, | ||
| 471 | latestVersionHttpClient("0.0.0"), | 486 | latestVersionHttpClient("0.0.0"), |
| 472 | mockSpawnerLayer(() => { | 487 | mockSpawnerLayer(() => { |
| 473 | startedLatch.resolve(); | 488 | startedLatch.resolve(); |
| ... | @@ -544,6 +559,7 @@ describe("providerMaintenanceRunner", () => { | ... | @@ -544,6 +559,7 @@ describe("providerMaintenanceRunner", () => { |
| 544 | }).pipe( | 559 | }).pipe( |
| 545 | Effect.provide( | 560 | Effect.provide( |
| 546 | Layer.mergeAll( | 561 | Layer.mergeAll( |
| 562 | NonWindowsPlatform, | ||
| 547 | latestVersionHttpClient("0.0.0"), | 563 | latestVersionHttpClient("0.0.0"), |
| 548 | mockSpawnerLayer((_command, args) => { | 564 | mockSpawnerLayer((_command, args) => { |
| 549 | calls.push(args.join(" ")); | 565 | calls.push(args.join(" ")); |
| ... | @@ -587,6 +603,7 @@ describe("providerMaintenanceRunner", () => { | ... | @@ -587,6 +603,7 @@ describe("providerMaintenanceRunner", () => { |
| 587 | }).pipe( | 603 | }).pipe( |
| 588 | Effect.provide( | 604 | Effect.provide( |
| 589 | Layer.mergeAll( | 605 | Layer.mergeAll( |
| 606 | NonWindowsPlatform, | ||
| 590 | latestVersionHttpClient("0.0.0"), | 607 | latestVersionHttpClient("0.0.0"), |
| 591 | mockSpawnerLayer((_command, args) => { | 608 | mockSpawnerLayer((_command, args) => { |
| 592 | calls.push(args.join(" ")); | 609 | calls.push(args.join(" ")); |
| ... | @@ -641,10 +658,73 @@ describe("providerMaintenanceRunner", () => { | ... | @@ -641,10 +658,73 @@ describe("providerMaintenanceRunner", () => { |
| 641 | }).pipe( | 658 | }).pipe( |
| 642 | Effect.provide( | 659 | Effect.provide( |
| 643 | Layer.mergeAll( | 660 | Layer.mergeAll( |
| 661 | NonWindowsPlatform, | ||
| 644 | latestVersionHttpClient("0.0.0"), | 662 | latestVersionHttpClient("0.0.0"), |
| 645 | mockSpawnerLayer(() => ({ stdout: "updated" })), | 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,6 +7,7 @@ import { |
| 7 | type ServerProviderUpdatedPayload, | 7 | type ServerProviderUpdatedPayload, |
| 8 | type ServerProviderUpdateState, | 8 | type ServerProviderUpdateState, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 10 | import * as Cause from "effect/Cause"; | 11 | import * as Cause from "effect/Cause"; |
| 11 | import * as Context from "effect/Context"; | 12 | import * as Context from "effect/Context"; |
| 12 | import * as Data from "effect/Data"; | 13 | import * as Data from "effect/Data"; |
| ... | @@ -75,8 +76,14 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR | ... | @@ -75,8 +76,14 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR |
| 75 | }) { | 76 | }) { |
| 76 | const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( | 77 | const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( |
| 77 | function* () { | 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 | const child = yield* input.spawner | 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 | .pipe( | 87 | .pipe( |
| 81 | Effect.mapError( | 88 | Effect.mapError( |
| 82 | (cause) => | 89 | (cause) => |
apps/server/src/server.test.ts+6-5| ... | @@ -3680,18 +3680,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3680,18 +3680,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3680 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | Effect.gen(function* () { | 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 | yield* buildAppUnderTest(); | 3689 | yield* buildAppUnderTest(); |
| 3686 | 3690 | ||
| 3687 | const first = yield* bootstrapBrowserSession(); | 3691 | const first = yield* bootstrapBrowserSession(); |
| 3688 | const second = yield* bootstrapBrowserSession(); | 3692 | const second = yield* bootstrapBrowserSession(); |
| 3689 | 3693 | ||
| 3690 | assert.equal(first.response.status, 200); | 3694 | assert.equal(first.response.status, 200); |
| 3691 | assert.equal(second.response.status, 401); | 3695 | assert.equal(second.response.status, 200); |
| 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 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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,13 +74,16 @@ function installTestBrowser(url: string) { |
| 74 | function installDesktopBootstrap() { | 74 | function installDesktopBootstrap() { |
| 75 | const testWindow = installTestBrowser("http://localhost/"); | 75 | const testWindow = installTestBrowser("http://localhost/"); |
| 76 | testWindow.desktopBridge = { | 76 | testWindow.desktopBridge = { |
| 77 | getLocalEnvironmentBootstrap: () => ({ | 77 | getLocalEnvironmentBootstraps: () => [ |
| 78 | label: "Local environment", | 78 | { |
| 79 | httpBaseUrl: "http://localhost:3773", | 79 | id: "primary", |
| 80 | wsBaseUrl: "ws://localhost:3773", | 80 | label: "Local environment", |
| 81 | bootstrapToken: "desktop-bootstrap-token", | 81 | httpBaseUrl: "http://localhost:3773", |
| 82 | }), | 82 | wsBaseUrl: "ws://localhost:3773", |
| 83 | } as DesktopBridge; | 83 | bootstrapToken: "desktop-bootstrap-token", |
| 84 | }, | ||
| 85 | ], | ||
| 86 | } as unknown as DesktopBridge; | ||
| 84 | } | 87 | } |
| 85 | 88 | ||
| 86 | function sequence<A>(...values: ReadonlyArray<A>) { | 89 | function sequence<A>(...values: ReadonlyArray<A>) { |
| ... | @@ -143,7 +146,18 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -143,7 +146,18 @@ describe("resolveInitialServerAuthGateState", () => { |
| 143 | browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), | 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 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | 162 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); |
| 149 | 163 | ||
| ... | @@ -192,12 +206,15 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -192,12 +206,15 @@ describe("resolveInitialServerAuthGateState", () => { |
| 192 | 206 | ||
| 193 | const testWindow = installTestBrowser("http://127.0.0.1:5733/"); | 207 | const testWindow = installTestBrowser("http://127.0.0.1:5733/"); |
| 194 | testWindow.desktopBridge = { | 208 | testWindow.desktopBridge = { |
| 195 | getLocalEnvironmentBootstrap: () => ({ | 209 | getLocalEnvironmentBootstraps: () => [ |
| 196 | label: "Local environment", | 210 | { |
| 197 | httpBaseUrl: "http://127.0.0.1:3773", | 211 | id: "primary", |
| 198 | wsBaseUrl: "ws://127.0.0.1:3773", | 212 | label: "Windows", |
| 199 | }), | 213 | httpBaseUrl: "http://127.0.0.1:3773", |
| 200 | } as DesktopBridge; | 214 | wsBaseUrl: "ws://127.0.0.1:3773", |
| 215 | }, | ||
| 216 | ], | ||
| 217 | } as unknown as DesktopBridge; | ||
| 201 | 218 | ||
| 202 | const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } = | 219 | const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } = |
| 203 | await import("./environments/primary"); | 220 | await import("./environments/primary"); |
| ... | @@ -373,7 +390,18 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -373,7 +390,18 @@ describe("resolveInitialServerAuthGateState", () => { |
| 373 | browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), | 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 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | 406 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); |
| 379 | 407 |
apps/web/src/components/CommandPalette.tsx+180-15| ... | @@ -8,6 +8,7 @@ import { | ... | @@ -8,6 +8,7 @@ import { |
| 8 | } from "@t3tools/client-runtime/state/runtime"; | 8 | } from "@t3tools/client-runtime/state/runtime"; |
| 9 | import { | 9 | import { |
| 10 | DEFAULT_MODEL, | 10 | DEFAULT_MODEL, |
| 11 | type DesktopWslState, | ||
| 11 | type EnvironmentId, | 12 | type EnvironmentId, |
| 12 | type FilesystemBrowseResult, | 13 | type FilesystemBrowseResult, |
| 13 | type ProjectId, | 14 | type ProjectId, |
| ... | @@ -15,6 +16,7 @@ import { | ... | @@ -15,6 +16,7 @@ import { |
| 15 | type SourceControlDiscoveryResult, | 16 | type SourceControlDiscoveryResult, |
| 16 | type SourceControlProviderKind, | 17 | type SourceControlProviderKind, |
| 17 | type SourceControlRepositoryInfo, | 18 | type SourceControlRepositoryInfo, |
| 19 | PRIMARY_LOCAL_ENVIRONMENT_ID, | ||
| 18 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| 19 | import { useNavigate, useParams } from "@tanstack/react-router"; | 21 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 20 | import * as Option from "effect/Option"; | 22 | import * as Option from "effect/Option"; |
| ... | @@ -44,9 +46,12 @@ import { | ... | @@ -44,9 +46,12 @@ import { |
| 44 | } from "react"; | 46 | } from "react"; |
| 45 | import { useAtomValue } from "@effect/atom-react"; | 47 | import { useAtomValue } from "@effect/atom-react"; |
| 46 | import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext"; | 48 | import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext"; |
| 49 | import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; | ||
| 50 | import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; | ||
| 47 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 51 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 48 | import { useClientSettings } from "../hooks/useSettings"; | 52 | import { useClientSettings } from "../hooks/useSettings"; |
| 49 | import { readLocalApi } from "../localApi"; | 53 | import { readLocalApi } from "../localApi"; |
| 54 | import { desktopLocalBackendId } from "../connection/desktopLocal"; | ||
| 50 | import { filesystemEnvironment } from "../state/filesystem"; | 55 | import { filesystemEnvironment } from "../state/filesystem"; |
| 51 | import { projectEnvironment } from "../state/projects"; | 56 | import { projectEnvironment } from "../state/projects"; |
| 52 | import { useEnvironmentQuery } from "../state/query"; | 57 | import { useEnvironmentQuery } from "../state/query"; |
| ... | @@ -79,6 +84,12 @@ import { getLatestThreadForProject } from "../lib/threadSort"; | ... | @@ -79,6 +84,12 @@ import { getLatestThreadForProject } from "../lib/threadSort"; |
| 79 | import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; | 84 | import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; |
| 80 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | 85 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 81 | import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; | 86 | import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; |
| 87 | import { | ||
| 88 | applyWslEnvironmentConfiguration, | ||
| 89 | parseWslUncPath, | ||
| 90 | resolveProjectPickerTarget, | ||
| 91 | resolveWslProjectSelection, | ||
| 92 | } from "../wslPaths"; | ||
| 82 | import { | 93 | import { |
| 83 | ADDON_ICON_CLASS, | 94 | ADDON_ICON_CLASS, |
| 84 | buildBrowseGroups, | 95 | buildBrowseGroups, |
| ... | @@ -458,6 +469,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -458,6 +469,7 @@ function OpenCommandPaletteDialog(props: { |
| 458 | reportFailure: false, | 469 | reportFailure: false, |
| 459 | }); | 470 | }); |
| 460 | const { environments } = useEnvironments(); | 471 | const { environments } = useEnvironments(); |
| 472 | const desktopLocalBootstraps = useDesktopLocalBootstraps(); | ||
| 461 | const primaryEnvironment = usePrimaryEnvironment(); | 473 | const primaryEnvironment = usePrimaryEnvironment(); |
| 462 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = | 474 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = |
| 463 | useHandleNewThread(); | 475 | useHandleNewThread(); |
| ... | @@ -500,9 +512,43 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -500,9 +512,43 @@ function OpenCommandPaletteDialog(props: { |
| 500 | return options; | 512 | return options; |
| 501 | }, [environments]); | 513 | }, [environments]); |
| 502 | const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null; | 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 | const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId; | 527 | const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId; |
| 504 | const browseEnvironment = | 528 | const browseEnvironment = |
| 505 | environments.find((environment) => environment.environmentId === browseEnvironmentId) ?? null; | 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 | const sourceControlDiscovery = useEnvironmentQuery( | 552 | const sourceControlDiscovery = useEnvironmentQuery( |
| 507 | browseEnvironmentId === null | 553 | browseEnvironmentId === null |
| 508 | ? null | 554 | ? null |
| ... | @@ -984,6 +1030,21 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -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 | actionItems.push({ | 1048 | actionItems.push({ |
| 988 | kind: "action", | 1049 | kind: "action", |
| 989 | value: "action:settings", | 1050 | value: "action:settings", |
| ... | @@ -1016,11 +1077,16 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1016,11 +1077,16 @@ function OpenCommandPaletteDialog(props: { |
| 1016 | threadSearchItems: allThreadItems, | 1077 | threadSearchItems: allThreadItems, |
| 1017 | }); | 1078 | }); |
| 1018 | 1079 | ||
| 1019 | const handleAddProject = useCallback( | 1080 | const handleAddProjectForEnvironment = useCallback( |
| 1020 | async (rawCwd: string) => { | 1081 | async (input: { |
| 1021 | if (!browseEnvironmentId) return; | 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 | toastManager.add( | 1090 | toastManager.add( |
| 1025 | stackedThreadToast({ | 1091 | stackedThreadToast({ |
| 1026 | type: "error", | 1092 | type: "error", |
| ... | @@ -1031,7 +1097,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1031,7 +1097,7 @@ function OpenCommandPaletteDialog(props: { |
| 1031 | return; | 1097 | return; |
| 1032 | } | 1098 | } |
| 1033 | 1099 | ||
| 1034 | if (isExplicitRelativeProjectPath(rawCwd.trim()) && !currentProjectCwdForBrowse) { | 1100 | if (isExplicitRelativeProjectPath(rawCwd.trim()) && !input.currentProjectCwd) { |
| 1035 | toastManager.add( | 1101 | toastManager.add( |
| 1036 | stackedThreadToast({ | 1102 | stackedThreadToast({ |
| 1037 | type: "error", | 1103 | type: "error", |
| ... | @@ -1042,11 +1108,11 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1042,11 +1108,11 @@ function OpenCommandPaletteDialog(props: { |
| 1042 | return; | 1108 | return; |
| 1043 | } | 1109 | } |
| 1044 | 1110 | ||
| 1045 | const cwd = resolveProjectPathForDispatch(rawCwd, currentProjectCwdForBrowse); | 1111 | const cwd = resolveProjectPathForDispatch(rawCwd, input.currentProjectCwd); |
| 1046 | if (cwd.length === 0) return; | 1112 | if (cwd.length === 0) return; |
| 1047 | 1113 | ||
| 1048 | const existing = findProjectByPath( | 1114 | const existing = findProjectByPath( |
| 1049 | projects.filter((project) => project.environmentId === browseEnvironmentId), | 1115 | projects.filter((project) => project.environmentId === input.environmentId), |
| 1050 | cwd, | 1116 | cwd, |
| 1051 | ); | 1117 | ); |
| 1052 | if (existing) { | 1118 | if (existing) { |
| ... | @@ -1084,7 +1150,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1084,7 +1150,7 @@ function OpenCommandPaletteDialog(props: { |
| 1084 | 1150 | ||
| 1085 | const projectId = newProjectId(); | 1151 | const projectId = newProjectId(); |
| 1086 | const createResult = await createProject({ | 1152 | const createResult = await createProject({ |
| 1087 | environmentId: browseEnvironmentId, | 1153 | environmentId: input.environmentId, |
| 1088 | input: { | 1154 | input: { |
| 1089 | projectId, | 1155 | projectId, |
| 1090 | title: inferProjectTitleFromPath(cwd), | 1156 | title: inferProjectTitleFromPath(cwd), |
| ... | @@ -1111,7 +1177,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1111,7 +1177,7 @@ function OpenCommandPaletteDialog(props: { |
| 1111 | } | 1177 | } |
| 1112 | 1178 | ||
| 1113 | const navigationResult = await settlePromise(() => | 1179 | const navigationResult = await settlePromise(() => |
| 1114 | handleNewThread(scopeProjectRef(browseEnvironmentId, projectId)), | 1180 | handleNewThread(scopeProjectRef(input.environmentId, projectId)), |
| 1115 | ); | 1181 | ); |
| 1116 | if (navigationResult._tag === "Failure") { | 1182 | if (navigationResult._tag === "Failure") { |
| 1117 | const error = squashAtomCommandFailure(navigationResult); | 1183 | const error = squashAtomCommandFailure(navigationResult); |
| ... | @@ -1127,9 +1193,6 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1127,9 +1193,6 @@ function OpenCommandPaletteDialog(props: { |
| 1127 | setOpen(false); | 1193 | setOpen(false); |
| 1128 | }, | 1194 | }, |
| 1129 | [ | 1195 | [ |
| 1130 | browseEnvironmentId, | ||
| 1131 | browseEnvironmentPlatform, | ||
| 1132 | currentProjectCwdForBrowse, | ||
| 1133 | handleNewThread, | 1196 | handleNewThread, |
| 1134 | createProject, | 1197 | createProject, |
| 1135 | navigate, | 1198 | navigate, |
| ... | @@ -1140,6 +1203,24 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -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 | function getDefaultCloneParentPath(environmentId: EnvironmentId): string { | 1224 | function getDefaultCloneParentPath(environmentId: EnvironmentId): string { |
| 1144 | return getAddProjectInitialQueryForEnvironment(environmentId); | 1225 | return getAddProjectInitialQueryForEnvironment(environmentId); |
| 1145 | } | 1226 | } |
| ... | @@ -1372,8 +1453,13 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1372,8 +1453,13 @@ function OpenCommandPaletteDialog(props: { |
| 1372 | const canOpenProjectFromFileManager = | 1453 | const canOpenProjectFromFileManager = |
| 1373 | isBrowsing && | 1454 | isBrowsing && |
| 1374 | browseEnvironmentId !== null && | 1455 | browseEnvironmentId !== null && |
| 1375 | primaryEnvironmentId !== null && | 1456 | // For a desktop-local (WSL) env, only offer the picker once we have resolved |
| 1376 | browseEnvironmentId === primaryEnvironmentId && | 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 | typeof window !== "undefined" && | 1463 | typeof window !== "undefined" && |
| 1378 | window.desktopBridge !== undefined; | 1464 | window.desktopBridge !== undefined; |
| 1379 | const fileManagerInitialPath = useMemo(() => { | 1465 | const fileManagerInitialPath = useMemo(() => { |
| ... | @@ -1468,9 +1554,31 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1468,9 +1554,31 @@ function OpenCommandPaletteDialog(props: { |
| 1468 | 1554 | ||
| 1469 | setIsPickingProjectFolder(true); | 1555 | setIsPickingProjectFolder(true); |
| 1470 | let pickedPath: string | null = null; | 1556 | let pickedPath: string | null = null; |
| 1557 | let desktopWslState: DesktopWslState | null = null; | ||
| 1471 | try { | 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 | pickedPath = await api.dialogs.pickFolder( | 1580 | pickedPath = await api.dialogs.pickFolder( |
| 1473 | fileManagerInitialPath ? { initialPath: fileManagerInitialPath } : undefined, | 1581 | Object.keys(pickerOptions).length > 0 ? pickerOptions : undefined, |
| 1474 | ); | 1582 | ); |
| 1475 | } catch { | 1583 | } catch { |
| 1476 | // Ignore picker failures and leave the palette open. | 1584 | // Ignore picker failures and leave the palette open. |
| ... | @@ -1481,12 +1589,69 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1481,12 +1589,69 @@ function OpenCommandPaletteDialog(props: { |
| 1481 | if (!pickedPath) { | 1589 | if (!pickedPath) { |
| 1482 | return; | 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 | await handleAddProject(pickedPath); | 1642 | await handleAddProject(pickedPath); |
| 1485 | }, [ | 1643 | }, [ |
| 1644 | browseDesktopInstanceId, | ||
| 1645 | browseEnvironmentId, | ||
| 1646 | browseEnvironmentPlatform, | ||
| 1486 | canOpenProjectFromFileManager, | 1647 | canOpenProjectFromFileManager, |
| 1648 | desktopLocalBootstraps, | ||
| 1649 | environments, | ||
| 1487 | fileManagerInitialPath, | 1650 | fileManagerInitialPath, |
| 1488 | handleAddProject, | 1651 | handleAddProject, |
| 1652 | handleAddProjectForEnvironment, | ||
| 1489 | isPickingProjectFolder, | 1653 | isPickingProjectFolder, |
| 1654 | primaryEnvironmentId, | ||
| 1490 | ]); | 1655 | ]); |
| 1491 | 1656 | ||
| 1492 | return ( | 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,13 +1,24 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 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 | import * as Cause from "effect/Cause"; | 8 | import * as Cause from "effect/Cause"; |
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | 9 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | 10 | ||
| 6 | import { | 11 | import { |
| 12 | buildLocalEnvironmentUpdateGroups, | ||
| 7 | canOneClickUpdateProviderCandidate, | 13 | canOneClickUpdateProviderCandidate, |
| 8 | collectProviderUpdateCandidates, | 14 | collectProviderUpdateCandidates, |
| 15 | collectProviderUpdateOutcomeSnapshots, | ||
| 9 | collectUpdatedProviderSnapshots, | 16 | collectUpdatedProviderSnapshots, |
| 17 | deriveEnvironmentDisplayLabel, | ||
| 18 | environmentGroupsWithUpdates, | ||
| 10 | firstFailedProviderUpdateMessage, | 19 | firstFailedProviderUpdateMessage, |
| 20 | firstRejectedProviderUpdateMessage, | ||
| 21 | firstUnsuccessfulSecondaryProviderOutcome, | ||
| 11 | getProviderUpdateInitialToastView, | 22 | getProviderUpdateInitialToastView, |
| 12 | getProviderUpdateProgressToastView, | 23 | getProviderUpdateProgressToastView, |
| 13 | getProviderUpdateRejectedToastView, | 24 | getProviderUpdateRejectedToastView, |
| ... | @@ -15,8 +26,17 @@ import { | ... | @@ -15,8 +26,17 @@ import { |
| 15 | getSingleProviderUpdateProgressToastView, | 26 | getSingleProviderUpdateProgressToastView, |
| 16 | hasOneClickUpdateProviderCandidate, | 27 | hasOneClickUpdateProviderCandidate, |
| 17 | isProviderUpdateCandidate, | 28 | isProviderUpdateCandidate, |
| 29 | isTerminalProviderUpdatePhase, | ||
| 30 | localEnvironmentUpdateNotificationKey, | ||
| 31 | parseWslDistroFromInstanceId, | ||
| 18 | providerUpdateNotificationKey, | 32 | providerUpdateNotificationKey, |
| 33 | resolveEnvironmentUpdateRowStatus, | ||
| 34 | type LocalEnvironmentProvidersInput, | ||
| 35 | type LocalEnvironmentUpdateGroup, | ||
| 36 | type LocalProviderUpdateOutcome, | ||
| 19 | type ProviderUpdateCandidate, | 37 | type ProviderUpdateCandidate, |
| 38 | type ProviderUpdateSidebarPillView, | ||
| 39 | type ProviderUpdateToastView, | ||
| 20 | } from "./ProviderUpdateLaunchNotification.logic"; | 40 | } from "./ProviderUpdateLaunchNotification.logic"; |
| 21 | 41 | ||
| 22 | const checkedAt = "2026-04-23T10:00:00.000Z"; | 42 | const checkedAt = "2026-04-23T10:00:00.000Z"; |
| ... | @@ -665,4 +685,431 @@ describe("provider update launch notification logic", () => { | ... | @@ -665,4 +685,431 @@ describe("provider update launch notification logic", () => { |
| 665 | ]), | 685 | ]), |
| 666 | ).toBeNull(); | 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,6 +1,8 @@ |
| 1 | import { | 1 | import { |
| 2 | defaultInstanceIdForDriver, | 2 | defaultInstanceIdForDriver, |
| 3 | PROVIDER_DISPLAY_NAMES, | 3 | PROVIDER_DISPLAY_NAMES, |
| 4 | type EnvironmentId, | ||
| 5 | type ExecutionEnvironmentPlatformOs, | ||
| 4 | type ProviderDriverKind, | 6 | type ProviderDriverKind, |
| 5 | type ProviderInstanceId, | 7 | type ProviderInstanceId, |
| 6 | type ServerProvider, | 8 | type ServerProvider, |
| ... | @@ -28,6 +30,17 @@ export interface ProviderUpdateToastView { | ... | @@ -28,6 +30,17 @@ export interface ProviderUpdateToastView { |
| 28 | readonly dismissAfterVisibleMs?: number; | 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 | export type ProviderUpdateSidebarPillTone = "loading" | "warning" | "error" | "success"; | 44 | export type ProviderUpdateSidebarPillTone = "loading" | "warning" | "error" | "success"; |
| 32 | 45 | ||
| 33 | export interface ProviderUpdateSidebarPillView { | 46 | export interface ProviderUpdateSidebarPillView { |
| ... | @@ -540,3 +553,283 @@ function getFailedProviderUpdateDescription(providers: ReadonlyArray<ServerProvi | ... | @@ -540,3 +553,283 @@ function getFailedProviderUpdateDescription(providers: ReadonlyArray<ServerProvi |
| 540 | } | 553 | } |
| 541 | return `${formatProviderList(providers)} failed to update. Check provider settings for details.`; | 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,313 +1,197 @@ |
| 1 | import { useNavigate } from "@tanstack/react-router"; | 1 | import { useNavigate } from "@tanstack/react-router"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | ||
| 3 | import { DownloadIcon } from "lucide-react"; | 2 | import { DownloadIcon } from "lucide-react"; |
| 4 | import { useCallback, useEffect, useMemo, useRef } from "react"; | 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; | ||
| 6 | 4 | ||
| 7 | import { primaryServerProvidersAtom, serverEnvironment } from "../state/server"; | 5 | import { useEnvironments } from "~/state/environments"; |
| 8 | import { usePrimaryEnvironment } from "../state/environments"; | 6 | import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; |
| 9 | import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; | 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 | import { | 10 | import { |
| 12 | canOneClickUpdateProviderCandidate, | ||
| 13 | collectProviderUpdateCandidates, | 11 | collectProviderUpdateCandidates, |
| 14 | collectUpdatedProviderSnapshots, | 12 | environmentGroupsWithUpdates, |
| 15 | firstFailedProviderUpdateMessage, | ||
| 16 | getProviderUpdateInitialToastView, | 13 | getProviderUpdateInitialToastView, |
| 17 | getProviderUpdateProgressToastView, | 14 | localEnvironmentUpdateNotificationKey, |
| 18 | getProviderUpdateRejectedToastView, | ||
| 19 | getProviderUpdateRunningToastView, | ||
| 20 | providerUpdateNotificationKey, | ||
| 21 | type ProviderUpdateToastView, | ||
| 22 | } from "./ProviderUpdateLaunchNotification.logic"; | 15 | } from "./ProviderUpdateLaunchNotification.logic"; |
| 16 | import { ProviderUpdatePrimaryNotification } from "./ProviderUpdatePrimaryNotification"; | ||
| 23 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 17 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 24 | import { useAtomCommand } from "../state/use-atom-command"; | ||
| 25 | 18 | ||
| 26 | const seenProviderUpdateNotificationKeys = new Set<string>(); | 19 | /** |
| 27 | type ProviderUpdateToastId = ReturnType<typeof toastManager.add>; | 20 | * True when a desktop-local secondary backend (the parallel WSL backend) is |
| 28 | 21 | * present alongside the primary. Local secondaries connect over loopback with a | |
| 29 | type ActiveProviderUpdateToast = | 22 | * `local:<backendInstanceId>` bearer connection id; everything else (SSH, relay, |
| 30 | | { readonly kind: "prompt"; readonly key: string; readonly toastId: ProviderUpdateToastId } | 23 | * remote) is ignored. Gating on this keeps non-WSL users on the unchanged |
| 31 | | { | 24 | * single-prompt flow. |
| 32 | readonly kind: "update"; | 25 | */ |
| 33 | readonly key: string; | 26 | function useHasLocalSecondaryEnvironment(): boolean { |
| 34 | readonly toastId: ProviderUpdateToastId; | 27 | const { environments } = useEnvironments(); |
| 35 | readonly providerInstanceIds: ReadonlySet<ProviderInstanceId>; | 28 | return useMemo( |
| 36 | readonly providerCount: number; | 29 | () => |
| 37 | }; | 30 | environments.some((environment) => isDesktopLocalConnectionTarget(environment.entry.target)), |
| 38 | 31 | [environments], | |
| 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 | ); | 32 | ); |
| 58 | } | 33 | } |
| 59 | 34 | ||
| 60 | function updateProviderUpdateToast(input: { | 35 | /** |
| 61 | readonly toastId: ProviderUpdateToastId; | 36 | * The provider update popover. With a WSL backend present it splits the update |
| 62 | readonly view: ProviderUpdateToastView; | 37 | * trigger per environment; without one (the common case) it falls back to the |
| 63 | readonly openSettings: () => void; | 38 | * single-prompt flow so non-WSL users see no change. |
| 64 | }) { | 39 | */ |
| 65 | if (input.view.type === "loading" || input.view.type === "success") { | 40 | export function ProviderUpdateLaunchNotification() { |
| 66 | toastManager.update(input.toastId, { | 41 | const hasLocalSecondary = useHasLocalSecondaryEnvironment(); |
| 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 | 42 | ||
| 81 | toastManager.update( | 43 | return hasLocalSecondary ? ( |
| 82 | input.toastId, | 44 | <ProviderUpdateEnvironmentsNotification /> |
| 83 | stackedThreadToast({ | 45 | ) : ( |
| 84 | type: input.view.type, | 46 | <ProviderUpdatePrimaryNotification /> |
| 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 | ); | 47 | ); |
| 98 | } | 48 | } |
| 99 | 49 | ||
| 100 | function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { | 50 | const seenProviderUpdateNotificationKeys = new Set<string>(); |
| 101 | return view.phase === "failed" || view.phase === "unchanged" || view.phase === "succeeded"; | 51 | type ProviderUpdateToastId = ReturnType<typeof toastManager.add>; |
| 102 | } | ||
| 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 | const navigate = useNavigate(); | 59 | const navigate = useNavigate(); |
| 106 | const providers = useAtomValue(primaryServerProvidersAtom); | 60 | const { groups, isAnySettling } = useLocalEnvironmentUpdateGroups(); |
| 107 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 108 | const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { | ||
| 109 | reportFailure: false, | ||
| 110 | }); | ||
| 111 | const activeToastRef = useRef<ActiveProviderUpdateToast | null>(null); | ||
| 112 | const { dismissedNotificationKeys, dismissNotificationKey } = | 61 | const { dismissedNotificationKeys, dismissNotificationKey } = |
| 113 | useDismissedProviderUpdateNotificationKeys(); | 62 | useDismissedProviderUpdateNotificationKeys(); |
| 114 | 63 | ||
| 115 | const updateProviders = useMemo(() => collectProviderUpdateCandidates(providers), [providers]); | 64 | const activeToastRef = useRef<{ |
| 116 | const notificationKey = useMemo( | 65 | readonly toastId: ProviderUpdateToastId; |
| 117 | () => providerUpdateNotificationKey(updateProviders), | 66 | readonly key: string; |
| 118 | [updateProviders], | 67 | } | null>(null); |
| 119 | ); | 68 | const notificationKeyRef = useRef<string | null>(null); |
| 120 | const oneClickProviders = useMemo( | 69 | // Whether the user has triggered an update from the current toast. Until they |
| 121 | () => | 70 | // do, the prompt is replaced when the available updates change; afterward it |
| 122 | updateProviders.filter((provider) => canOneClickUpdateProviderCandidate(provider, providers)), | 71 | // is kept so in-progress rows are not torn down. |
| 123 | [providers, updateProviders], | 72 | const hasInteractedRef = useRef(false); |
| 124 | ); | 73 | |
| 125 | 74 | // Close our prompt if this flow unmounts (e.g. the WSL backend is disabled | |
| 126 | const openProviderSettings = useCallback( | 75 | // and we fall back to the single-prompt flow). |
| 127 | (toastId?: ProviderUpdateToastId) => { | 76 | useEffect(() => { |
| 128 | const activeToast = activeToastRef.current; | 77 | return () => { |
| 129 | if (toastId !== undefined) { | 78 | if (activeToastRef.current !== null) { |
| 130 | toastManager.close(toastId); | 79 | toastManager.close(activeToastRef.current.toastId); |
| 131 | } else if (activeToast) { | ||
| 132 | toastManager.close(activeToast.toastId); | ||
| 133 | } | ||
| 134 | if (activeToast && (toastId === undefined || activeToast.toastId === toastId)) { | ||
| 135 | activeToastRef.current = null; | 80 | activeToastRef.current = null; |
| 136 | } | 81 | } |
| 137 | void navigate({ to: "/settings/providers" }); | 82 | }; |
| 138 | }, | 83 | }, []); |
| 139 | [navigate], | 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 | useEffect(() => { | 100 | useEffect(() => { |
| 143 | const activeToast = activeToastRef.current; | 101 | if (!isAnySettling) { |
| 144 | if (activeToast?.kind !== "update") { | 102 | setSettleGraceElapsed(false); |
| 145 | return; | 103 | return; |
| 146 | } | 104 | } |
| 147 | 105 | const timer = setTimeout(() => setSettleGraceElapsed(true), SETTLING_GRACE_MS); | |
| 148 | const activeProviders = providers.filter((provider) => | 106 | return () => clearTimeout(timer); |
| 149 | activeToast.providerInstanceIds.has(provider.instanceId), | 107 | }, [isAnySettling]); |
| 150 | ); | 108 | const isGated = isAnySettling && !settleGraceElapsed; |
| 151 | const view = getProviderUpdateProgressToastView({ | 109 | |
| 152 | providers: activeProviders, | 110 | const openProviderSettings = useCallback(() => { |
| 153 | providerCount: activeToast.providerCount, | 111 | const active = activeToastRef.current; |
| 154 | }); | 112 | if (active !== null) { |
| 155 | updateProviderUpdateToast({ | 113 | toastManager.close(active.toastId); |
| 156 | toastId: activeToast.toastId, | ||
| 157 | view, | ||
| 158 | openSettings: () => openProviderSettings(activeToast.toastId), | ||
| 159 | }); | ||
| 160 | |||
| 161 | if (isTerminalProviderUpdateToastView(view)) { | ||
| 162 | activeToastRef.current = null; | 114 | activeToastRef.current = null; |
| 163 | } | 115 | } |
| 164 | }, [providers, openProviderSettings]); | 116 | void navigate({ to: "/settings/providers" }); |
| 117 | }, [navigate]); | ||
| 165 | 118 | ||
| 166 | useEffect(() => { | 119 | useEffect(() => { |
| 167 | const activeToast = activeToastRef.current; | 120 | // Whether a fresh prompt can actually be shown for the current update set. |
| 168 | if (activeToast?.kind === "prompt" && activeToast.key !== notificationKey) { | 121 | const canShowPrompt = |
| 169 | toastManager.close(activeToast.toastId); | 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 | activeToastRef.current = null; | 140 | activeToastRef.current = null; |
| 171 | } | 141 | } |
| 172 | 142 | ||
| 173 | if ( | 143 | if (!notificationKey || !canShowPrompt || activeToastRef.current !== null) { |
| 174 | !notificationKey || | ||
| 175 | dismissedNotificationKeys.has(notificationKey) || | ||
| 176 | seenProviderUpdateNotificationKeys.has(notificationKey) || | ||
| 177 | activeToastRef.current | ||
| 178 | ) { | ||
| 179 | return; | 144 | return; |
| 180 | } | 145 | } |
| 181 | 146 | ||
| 182 | seenProviderUpdateNotificationKeys.add(notificationKey); | 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 | const dismissPrompt = () => { | 150 | const dismissPrompt = () => { |
| 190 | dismissNotificationKey(notificationKey); | 151 | // Dismiss whatever set is still on offer at close time, so the popover |
| 191 | }; | 152 | // does not re-pop for updates the user just declined. |
| 192 | 153 | const liveKey = notificationKeyRef.current; | |
| 193 | const runUpdates = () => { | 154 | if (liveKey) { |
| 194 | if (updateStarted || oneClickProviders.length === 0 || !primaryEnvironment) { | 155 | dismissNotificationKey(liveKey); |
| 195 | return; | ||
| 196 | } | 156 | } |
| 197 | updateStarted = true; | 157 | activeToastRef.current = null; |
| 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 | })(); | ||
| 263 | }; | 158 | }; |
| 264 | 159 | ||
| 265 | toastId = toastManager.add( | 160 | const toastId = toastManager.add( |
| 266 | stackedThreadToast({ | 161 | stackedThreadToast({ |
| 267 | type: initialView.type, | 162 | type: "warning", |
| 268 | title: initialView.title, | 163 | title: getProviderUpdateInitialToastView({ |
| 269 | description: initialView.description, | 164 | updateProviders: candidateUnion, |
| 165 | oneClickProviders: candidateUnion, | ||
| 166 | }).title, | ||
| 167 | description: ( | ||
| 168 | <ProviderUpdateEnvironmentRows | ||
| 169 | onInteract={() => { | ||
| 170 | hasInteractedRef.current = true; | ||
| 171 | }} | ||
| 172 | /> | ||
| 173 | ), | ||
| 270 | timeout: 0, | 174 | timeout: 0, |
| 271 | actionProps: | 175 | actionProps: { |
| 272 | oneClickProviders.length > 0 | 176 | children: "Settings", |
| 273 | ? { | 177 | onClick: openProviderSettings, |
| 274 | children: "Update", | 178 | }, |
| 275 | onClick: runUpdates, | 179 | actionVariant: "outline", |
| 276 | } | ||
| 277 | : { | ||
| 278 | children: "Settings", | ||
| 279 | onClick: openSettings, | ||
| 280 | }, | ||
| 281 | actionVariant: oneClickProviders.length > 0 ? "default" : "outline", | ||
| 282 | data: { | 180 | data: { |
| 283 | leadingIcon: | ||
| 284 | updateProviders.length === 1 ? ( | ||
| 285 | <ProviderUpdateToastIcon provider={updateProviders[0]!.driver} /> | ||
| 286 | ) : undefined, | ||
| 287 | hideCopyButton: true, | 181 | hideCopyButton: true, |
| 182 | leadingIcon: <DownloadIcon aria-hidden="true" className="size-4 text-success" />, | ||
| 288 | onClose: dismissPrompt, | 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 | notificationKey, | 189 | notificationKey, |
| 307 | oneClickProviders, | 190 | isGated, |
| 191 | candidateUnion, | ||
| 192 | dismissedNotificationKeys, | ||
| 193 | dismissNotificationKey, | ||
| 308 | openProviderSettings, | 194 | openProviderSettings, |
| 309 | primaryEnvironment, | ||
| 310 | updateProviders, | ||
| 311 | ]); | 195 | ]); |
| 312 | 196 | ||
| 313 | return null; | 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,8 +3,10 @@ import { |
| 3 | ArrowUpDownIcon, | 3 | ArrowUpDownIcon, |
| 4 | ChevronRightIcon, | 4 | ChevronRightIcon, |
| 5 | CloudIcon, | 5 | CloudIcon, |
| 6 | ContainerIcon, | ||
| 6 | FolderPlusIcon, | 7 | FolderPlusIcon, |
| 7 | Globe2Icon, | 8 | Globe2Icon, |
| 9 | LoaderIcon, | ||
| 8 | SearchIcon, | 10 | SearchIcon, |
| 9 | SettingsIcon, | 11 | SettingsIcon, |
| 10 | SquarePenIcon, | 12 | SquarePenIcon, |
| ... | @@ -69,6 +71,8 @@ import { | ... | @@ -69,6 +71,8 @@ import { |
| 69 | type SidebarThreadPreviewCount, | 71 | type SidebarThreadPreviewCount, |
| 70 | type SidebarThreadSortOrder, | 72 | type SidebarThreadSortOrder, |
| 71 | } from "@t3tools/contracts/settings"; | 73 | } from "@t3tools/contracts/settings"; |
| 74 | import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; | ||
| 75 | import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; | ||
| 72 | import { isElectron } from "../env"; | 76 | import { isElectron } from "../env"; |
| 73 | import { APP_STAGE_LABEL } from "../branding"; | 77 | import { APP_STAGE_LABEL } from "../branding"; |
| 74 | import { useOpenPrLink } from "../lib/openPullRequestLink"; | 78 | import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| ... | @@ -398,7 +402,16 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr | ... | @@ -398,7 +402,16 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 398 | const isRemoteThread = | 402 | const isRemoteThread = |
| 399 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; | 403 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; |
| 400 | const remoteEnvLabel = environment?.label ?? null; | 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 | // For grouped projects, the thread may belong to a different environment | 415 | // For grouped projects, the thread may belong to a different environment |
| 403 | // than the representative project. Look up the thread's own project cwd | 416 | // than the representative project. Look up the thread's own project cwd |
| 404 | // so git status (and thus PR detection) queries the correct path. | 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,7 +834,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 821 | ) : null} | 834 | ) : null} |
| 822 | <span className={threadMetaClassName}> | 835 | <span className={threadMetaClassName}> |
| 823 | <span className="inline-flex items-center gap-1"> | 836 | <span className="inline-flex items-center gap-1"> |
| 824 | {isRemoteThread && ( | 837 | {isRemoteThread && !isDesktopLocalThread && ( |
| 825 | <Tooltip> | 838 | <Tooltip> |
| 826 | <TooltipTrigger | 839 | <TooltipTrigger |
| 827 | render={ | 840 | render={ |
| ... | @@ -2249,18 +2262,24 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2249,18 +2262,24 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2249 | render={ | 2262 | render={ |
| 2250 | <span | 2263 | <span |
| 2251 | aria-label={ | 2264 | aria-label={ |
| 2252 | project.environmentPresence === "remote-only" | 2265 | project.allRemoteMembersAreDesktopLocal |
| 2253 | ? "Remote project" | 2266 | ? "Local sandbox project" |
| 2254 | : "Available in multiple environments" | 2267 | : "Remote project" |
| 2255 | } | 2268 | } |
| 2256 | 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" | 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 | </TooltipTrigger> | 2278 | </TooltipTrigger> |
| 2262 | <TooltipPopup side="top"> | 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 | </TooltipPopup> | 2283 | </TooltipPopup> |
| 2265 | </Tooltip> | 2284 | </Tooltip> |
| 2266 | )} | 2285 | )} |
| ... | @@ -2452,6 +2471,81 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar | ... | @@ -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 | type SortableProjectHandleProps = Pick< | 2549 | type SortableProjectHandleProps = Pick< |
| 2456 | ReturnType<typeof useSortable>, | 2550 | ReturnType<typeof useSortable>, |
| 2457 | "attributes" | "listeners" | "setActivatorNodeRef" | 2551 | "attributes" | "listeners" | "setActivatorNodeRef" |
| ... | @@ -2889,6 +2983,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( | ... | @@ -2889,6 +2983,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( |
| 2889 | </Alert> | 2983 | </Alert> |
| 2890 | </SidebarGroup> | 2984 | </SidebarGroup> |
| 2891 | ) : null} | 2985 | ) : null} |
| 2986 | <LocalSecondaryStatus /> | ||
| 2892 | <SidebarGroup className="px-2 py-2"> | 2987 | <SidebarGroup className="px-2 py-2"> |
| 2893 | <div className="mb-1 flex items-center justify-between pl-2 pr-1.5"> | 2988 | <div className="mb-1 flex items-center justify-between pl-2 pr-1.5"> |
| 2894 | <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60"> | 2989 | <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60"> |
| ... | @@ -3058,6 +3153,15 @@ export default function Sidebar() { | ... | @@ -3058,6 +3153,15 @@ export default function Sidebar() { |
| 3058 | ), | 3153 | ), |
| 3059 | [environments], | 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 | const orderedProjects = useMemo(() => { | 3165 | const orderedProjects = useMemo(() => { |
| 3062 | return orderItemsByPreferredIds({ | 3166 | return orderItemsByPreferredIds({ |
| 3063 | items: projects, | 3167 | items: projects, |
| ... | @@ -3096,8 +3200,15 @@ export default function Sidebar() { | ... | @@ -3096,8 +3200,15 @@ export default function Sidebar() { |
| 3096 | settings: projectGroupingSettings, | 3200 | settings: projectGroupingSettings, |
| 3097 | primaryEnvironmentId, | 3201 | primaryEnvironmentId, |
| 3098 | resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, | 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 | const sidebarProjectByKey = useMemo( | 3213 | const sidebarProjectByKey = useMemo( |
| 3103 | () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), | 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,6 +27,7 @@ import { |
| 27 | type DesktopDiscoveredSshHost, | 27 | type DesktopDiscoveredSshHost, |
| 28 | type DesktopSshEnvironmentTarget, | 28 | type DesktopSshEnvironmentTarget, |
| 29 | type DesktopServerExposureState, | 29 | type DesktopServerExposureState, |
| 30 | type DesktopWslState, | ||
| 30 | type EnvironmentId, | 31 | type EnvironmentId, |
| 31 | } from "@t3tools/contracts"; | 32 | } from "@t3tools/contracts"; |
| 32 | import { | 33 | import { |
| ... | @@ -48,6 +49,7 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | ... | @@ -48,6 +49,7 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 48 | import { cn } from "../../lib/utils"; | 49 | import { cn } from "../../lib/utils"; |
| 49 | import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; | 50 | import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; |
| 50 | import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; | 51 | import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; |
| 52 | import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; | ||
| 51 | import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig"; | 53 | import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig"; |
| 52 | import { | 54 | import { |
| 53 | SettingsPageContainer, | 55 | SettingsPageContainer, |
| ... | @@ -82,6 +84,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | ... | @@ -82,6 +84,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 82 | import { QRCodeSvg } from "../ui/qr-code"; | 84 | import { QRCodeSvg } from "../ui/qr-code"; |
| 83 | import { Skeleton } from "../ui/skeleton"; | 85 | import { Skeleton } from "../ui/skeleton"; |
| 84 | import { Spinner } from "../ui/spinner"; | 86 | import { Spinner } from "../ui/spinner"; |
| 87 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | ||
| 85 | import { Switch } from "../ui/switch"; | 88 | import { Switch } from "../ui/switch"; |
| 86 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 89 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 87 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 90 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | @@ -111,6 +114,7 @@ import { | ... | @@ -111,6 +114,7 @@ import { |
| 111 | type ServerClientSessionRecord, | 114 | type ServerClientSessionRecord, |
| 112 | type ServerPairingLinkRecord, | 115 | type ServerPairingLinkRecord, |
| 113 | } from "~/environments/primary"; | 116 | } from "~/environments/primary"; |
| 117 | import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; | ||
| 114 | import { useUiStateStore } from "~/uiStateStore"; | 118 | import { useUiStateStore } from "~/uiStateStore"; |
| 115 | import { resolveServerConfigVersionMismatch } from "~/versionSkew"; | 119 | import { resolveServerConfigVersionMismatch } from "~/versionSkew"; |
| 116 | import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; | 120 | import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; |
| ... | @@ -132,6 +136,7 @@ import { | ... | @@ -132,6 +136,7 @@ import { |
| 132 | refreshDesktopNetworkAccessState, | 136 | refreshDesktopNetworkAccessState, |
| 133 | } from "~/state/desktopNetworkAccess"; | 137 | } from "~/state/desktopNetworkAccess"; |
| 134 | import { desktopSshHostsStateAtom } from "~/state/desktopSshHosts"; | 138 | import { desktopSshHostsStateAtom } from "~/state/desktopSshHosts"; |
| 139 | import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState"; | ||
| 135 | import { | 140 | import { |
| 136 | type EnvironmentPresentation, | 141 | type EnvironmentPresentation, |
| 137 | useEnvironments, | 142 | useEnvironments, |
| ... | @@ -145,6 +150,12 @@ const DEFAULT_TAILSCALE_SERVE_PORT = 443; | ... | @@ -145,6 +150,12 @@ const DEFAULT_TAILSCALE_SERVE_PORT = 443; |
| 145 | const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = []; | 150 | const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = []; |
| 146 | const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray<DesktopDiscoveredSshHost> = []; | 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 | const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { | 159 | const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { |
| 149 | dateStyle: "medium", | 160 | dateStyle: "medium", |
| 150 | timeStyle: "short", | 161 | timeStyle: "short", |
| ... | @@ -1461,6 +1472,12 @@ function SavedBackendListRow({ | ... | @@ -1461,6 +1472,12 @@ function SavedBackendListRow({ |
| 1461 | environment.relayManaged ? "T3 Connect" : null, | 1472 | environment.relayManaged ? "T3 Connect" : null, |
| 1462 | ].filter((value): value is string => value !== null); | 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 | return ( | 1481 | return ( |
| 1465 | <div className={ITEM_ROW_CLASSNAME}> | 1482 | <div className={ITEM_ROW_CLASSNAME}> |
| 1466 | <div className={ITEM_ROW_INNER_CLASSNAME}> | 1483 | <div className={ITEM_ROW_INNER_CLASSNAME}> |
| ... | @@ -1503,20 +1520,37 @@ function SavedBackendListRow({ | ... | @@ -1503,20 +1520,37 @@ function SavedBackendListRow({ |
| 1503 | ) : null} | 1520 | ) : null} |
| 1504 | </div> | 1521 | </div> |
| 1505 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | 1522 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
| 1506 | <Button | 1523 | {isWslEnvironment ? ( |
| 1507 | size="xs" | 1524 | <Tooltip> |
| 1508 | variant="outline" | 1525 | <TooltipTrigger |
| 1509 | disabled={isConnecting || removingEnvironmentId === environmentId} | 1526 | render={ |
| 1510 | onClick={() => void (isConnected ? onRemove(environmentId) : onConnect(environmentId))} | 1527 | <Button size="xs" variant="outline" disabled> |
| 1511 | > | 1528 | Managed above |
| 1512 | {isConnected | 1529 | </Button> |
| 1513 | ? removingEnvironmentId === environmentId | 1530 | } |
| 1514 | ? "Disconnecting…" | 1531 | /> |
| 1515 | : "Disconnect" | 1532 | <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight"> |
| 1516 | : isConnecting | 1533 | The WSL backend is managed by the WSL setting above — turn it on or off there. |
| 1517 | ? "Connecting…" | 1534 | </TooltipPopup> |
| 1518 | : "Connect"} | 1535 | </Tooltip> |
| 1519 | </Button> | 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 | </div> | 1554 | </div> |
| 1521 | </div> | 1555 | </div> |
| 1522 | </div> | 1556 | </div> |
| ... | @@ -2066,6 +2100,30 @@ export function ConnectionsSettings() { | ... | @@ -2066,6 +2100,30 @@ export function ConnectionsSettings() { |
| 2066 | const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); | 2100 | const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); |
| 2067 | const [isDesktopServerExposureDialogOpen, setIsDesktopServerExposureDialogOpen] = useState(false); | 2101 | const [isDesktopServerExposureDialogOpen, setIsDesktopServerExposureDialogOpen] = useState(false); |
| 2068 | const [isUpdatingTailscaleServe, setIsUpdatingTailscaleServe] = useState(false); | 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 | const [pendingTailscaleServeEndpoint, setPendingTailscaleServeEndpoint] = | 2127 | const [pendingTailscaleServeEndpoint, setPendingTailscaleServeEndpoint] = |
| 2070 | useState<AdvertisedEndpoint | null>(null); | 2128 | useState<AdvertisedEndpoint | null>(null); |
| 2071 | const [disableTailscaleServeDialogOpen, setDisableTailscaleServeDialogOpen] = useState(false); | 2129 | const [disableTailscaleServeDialogOpen, setDisableTailscaleServeDialogOpen] = useState(false); |
| ... | @@ -2102,6 +2160,12 @@ export function ConnectionsSettings() { | ... | @@ -2102,6 +2160,12 @@ export function ConnectionsSettings() { |
| 2102 | ? desktopSshHostsStateAtom | 2160 | ? desktopSshHostsStateAtom |
| 2103 | : null, | 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 | const discoveredSshHosts = desktopSshHosts.data ?? EMPTY_DISCOVERED_SSH_HOSTS; | 2169 | const discoveredSshHosts = desktopSshHosts.data ?? EMPTY_DISCOVERED_SSH_HOSTS; |
| 2106 | const unsavedDiscoveredSshHosts = useMemo( | 2170 | const unsavedDiscoveredSshHosts = useMemo( |
| 2107 | () => | 2171 | () => |
| ... | @@ -2793,6 +2857,313 @@ export function ConnectionsSettings() { | ... | @@ -2793,6 +2857,313 @@ export function ConnectionsSettings() { |
| 2793 | ); | 2857 | ); |
| 2794 | }) | 2858 | }) |
| 2795 | : null; | 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 | const renderTailscaleRow = () => ( | 3167 | const renderTailscaleRow = () => ( |
| 2797 | <SettingsRow | 3168 | <SettingsRow |
| 2798 | title="Tailscale HTTPS" | 3169 | title="Tailscale HTTPS" |
| ... | @@ -2928,6 +3299,7 @@ export function ConnectionsSettings() { | ... | @@ -2928,6 +3299,7 @@ export function ConnectionsSettings() { |
| 2928 | {renderNetworkAccessRow()} | 3299 | {renderNetworkAccessRow()} |
| 2929 | {renderEndpointRows("endpoint-rail")} | 3300 | {renderEndpointRows("endpoint-rail")} |
| 2930 | {renderTailscaleRow()} | 3301 | {renderTailscaleRow()} |
| 3302 | {renderWslRow()} | ||
| 2931 | <CloudLinkRow canManageRelay={canManageRelay} /> | 3303 | <CloudLinkRow canManageRelay={canManageRelay} /> |
| 2932 | </> | 3304 | </> |
| 2933 | ) : ( | 3305 | ) : ( |
| ... | @@ -3011,6 +3383,114 @@ export function ConnectionsSettings() { | ... | @@ -3011,6 +3383,114 @@ export function ConnectionsSettings() { |
| 3011 | </AlertDialogFooter> | 3383 | </AlertDialogFooter> |
| 3012 | </AlertDialogPopup> | 3384 | </AlertDialogPopup> |
| 3013 | </AlertDialog> | 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 | <AlertDialog | 3494 | <AlertDialog |
| 3015 | open={disableTailscaleServeDialogOpen} | 3495 | open={disableTailscaleServeDialogOpen} |
| 3016 | onOpenChange={(open) => { | 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,13 +1,23 @@ |
| 1 | import { | 1 | import { |
| 2 | AuthStandardClientScopes, | 2 | AuthStandardClientScopes, |
| 3 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | PRIMARY_LOCAL_ENVIRONMENT_ID, | ||
| 4 | type DesktopBridge, | 5 | type DesktopBridge, |
| 5 | type DesktopSshEnvironmentTarget, | 6 | type DesktopSshEnvironmentTarget, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 7 | import { describe, expect, it } from "@effect/vitest"; | 8 | import { describe, expect, it } from "@effect/vitest"; |
| 8 | import * as Effect from "effect/Effect"; | 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 | const TARGET: DesktopSshEnvironmentTarget = { | 22 | const TARGET: DesktopSshEnvironmentTarget = { |
| 13 | alias: "devbox", | 23 | alias: "devbox", |
| ... | @@ -86,3 +96,130 @@ describe("desktop SSH pairing", () => { | ... | @@ -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,37 +8,55 @@ import { |
| 8 | SshEnvironmentGateway, | 8 | SshEnvironmentGateway, |
| 9 | } from "@t3tools/client-runtime/platform"; | 9 | } from "@t3tools/client-runtime/platform"; |
| 10 | import { | 10 | import { |
| 11 | BearerConnectionCredential, | ||
| 12 | BearerConnectionProfile, | ||
| 13 | BearerConnectionRegistration, | ||
| 14 | BearerConnectionTarget, | ||
| 11 | ConnectionBlockedError, | 15 | ConnectionBlockedError, |
| 12 | ConnectionTransientError, | 16 | ConnectionTransientError, |
| 13 | Connectivity, | 17 | Connectivity, |
| 14 | mapRemoteEnvironmentError, | 18 | mapRemoteEnvironmentError, |
| 19 | type PlatformConnectionRegistration, | ||
| 15 | PrimaryConnectionRegistration, | 20 | PrimaryConnectionRegistration, |
| 16 | PrimaryConnectionTarget, | 21 | PrimaryConnectionTarget, |
| 17 | Wakeups, | 22 | Wakeups, |
| 18 | } from "@t3tools/client-runtime/connection"; | 23 | } from "@t3tools/client-runtime/connection"; |
| 24 | import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; | ||
| 19 | import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; | 25 | import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; |
| 20 | import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; | 26 | import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; |
| 21 | import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc"; | 27 | import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc"; |
| 22 | import { | 28 | import { |
| 23 | AuthStandardClientScopes, | 29 | AuthStandardClientScopes, |
| 24 | type DesktopBridge, | 30 | type DesktopBridge, |
| 31 | type DesktopEnvironmentBootstrap, | ||
| 25 | type DesktopSshEnvironmentTarget, | 32 | type DesktopSshEnvironmentTarget, |
| 33 | PRIMARY_LOCAL_ENVIRONMENT_ID, | ||
| 26 | } from "@t3tools/contracts"; | 34 | } from "@t3tools/contracts"; |
| 35 | import * as Clock from "effect/Clock"; | ||
| 27 | import * as Context from "effect/Context"; | 36 | import * as Context from "effect/Context"; |
| 28 | import * as Effect from "effect/Effect"; | 37 | import * as Effect from "effect/Effect"; |
| 29 | import * as Layer from "effect/Layer"; | 38 | import * as Layer from "effect/Layer"; |
| 30 | import * as Option from "effect/Option"; | 39 | import * as Option from "effect/Option"; |
| 31 | import * as Queue from "effect/Queue"; | 40 | import * as Queue from "effect/Queue"; |
| 32 | import * as Schedule from "effect/Schedule"; | 41 | import * as Ref from "effect/Ref"; |
| 33 | import * as Stream from "effect/Stream"; | 42 | import * as Stream from "effect/Stream"; |
| 43 | import { FetchHttpClient } from "effect/unstable/http"; | ||
| 34 | 44 | ||
| 35 | import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; | 45 | import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; |
| 36 | import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; | 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 | import { clearComposerDraftsEnvironment } from "../composerDraftStore"; | 51 | import { clearComposerDraftsEnvironment } from "../composerDraftStore"; |
| 39 | import { isHostedStaticApp } from "../hostedPairing"; | 52 | import { isHostedStaticApp } from "../hostedPairing"; |
| 40 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 53 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 41 | import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; | 54 | import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; |
| 55 | import { | ||
| 56 | desktopLocalConnectionId, | ||
| 57 | readDesktopSecondaryBootstrapsResult, | ||
| 58 | type DesktopSecondaryBootstrapsRead, | ||
| 59 | } from "./desktopLocal"; | ||
| 42 | import { connectionStorageLayer } from "./storage"; | 60 | import { connectionStorageLayer } from "./storage"; |
| 43 | 61 | ||
| 44 | let nextObservedRpcRequestId = 0; | 62 | let nextObservedRpcRequestId = 0; |
| ... | @@ -267,14 +285,7 @@ const capabilitiesLayer = Layer.effectContext( | ... | @@ -267,14 +285,7 @@ const capabilitiesLayer = Layer.effectContext( |
| 267 | 285 | ||
| 268 | const loadPrimaryConnectionRegistration = Effect.fn( | 286 | const loadPrimaryConnectionRegistration = Effect.fn( |
| 269 | "web.connectionPlatform.loadPrimaryConnectionRegistration", | 287 | "web.connectionPlatform.loadPrimaryConnectionRegistration", |
| 270 | )(function* () { | 288 | )(function* (resolved: PrimaryEnvironmentTarget) { |
| 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 | } | ||
| 278 | const descriptor = yield* fetchRemoteEnvironmentDescriptor({ | 289 | const descriptor = yield* fetchRemoteEnvironmentDescriptor({ |
| 279 | httpBaseUrl: resolved.target.httpBaseUrl, | 290 | httpBaseUrl: resolved.target.httpBaseUrl, |
| 280 | }).pipe(Effect.provide(primaryEnvironmentHttpLayer), Effect.mapError(mapRemoteEnvironmentError)); | 291 | }).pipe(Effect.provide(primaryEnvironmentHttpLayer), Effect.mapError(mapRemoteEnvironmentError)); |
| ... | @@ -288,29 +299,280 @@ const loadPrimaryConnectionRegistration = Effect.fn( | ... | @@ -288,29 +299,280 @@ const loadPrimaryConnectionRegistration = Effect.fn( |
| 288 | }); | 299 | }); |
| 289 | }); | 300 | }); |
| 290 | 301 | ||
| 291 | const primaryRegistrationRetrySchedule = Schedule.exponential("1 second").pipe( | 302 | // A desktop-local secondary backend (e.g. a parallel WSL backend) lives on its |
| 292 | Schedule.either(Schedule.spaced("16 seconds")), | 303 | // own loopback origin, so — unlike the same-origin primary — it authenticates |
| 293 | ); | 304 | // with a bearer token minted from the bootstrap credential the desktop issues. |
| 294 | 305 | const loadSecondaryConnectionRegistration = Effect.fn( | |
| 295 | const platformConnectionSourceLayer = Layer.sync(PlatformConnectionSource, () => { | 306 | "web.connectionPlatform.loadSecondaryConnectionRegistration", |
| 296 | if (isHostedStaticApp()) { | 307 | )(function* (entry: DesktopEnvironmentBootstrap) { |
| 297 | return PlatformConnectionSource.of({ | 308 | if ( |
| 298 | registrations: Stream.empty, | 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({ | 318 | const httpBaseUrl = entry.httpBaseUrl; |
| 302 | registrations: Stream.fromEffect(loadPrimaryConnectionRegistration()).pipe( | 319 | const wsBaseUrl = entry.wsBaseUrl; |
| 303 | Stream.tapError((error) => | 320 | const descriptor = yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( |
| 304 | Effect.logWarning("Could not discover the primary environment.", { | 321 | Effect.mapError(mapRemoteEnvironmentError), |
| 305 | error, | 322 | ); |
| 306 | }), | 323 | const issuedAtEpochMs = yield* Clock.currentTimeMillis; |
| 307 | ), | 324 | const access = yield* bootstrapRemoteBearerSession({ |
| 308 | Stream.retry(primaryRegistrationRetrySchedule), | 325 | httpBaseUrl, |
| 309 | Stream.catchCause(() => Stream.empty), | 326 | credential: entry.bootstrapToken, |
| 310 | ), | 327 | scopes: AuthStandardClientScopes, |
| 311 | }); | 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 | const environmentOwnedDataCleanupLayer = Layer.succeed( | 576 | const environmentOwnedDataCleanupLayer = Layer.succeed( |
| 315 | EnvironmentOwnedDataCleanup, | 577 | EnvironmentOwnedDataCleanup, |
| 316 | EnvironmentOwnedDataCleanup.of({ | 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 +7,7 @@ import type { |
| 7 | AuthSessionId, | 7 | AuthSessionId, |
| 8 | AuthSessionState, | 8 | AuthSessionState, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { EnvironmentHttpCommonError } from "@t3tools/contracts"; | 10 | import { EnvironmentHttpCommonError, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; |
| 11 | import type { EnvironmentHttpCommonError as EnvironmentHttpCommonErrorType } from "@t3tools/contracts"; | 11 | import type { EnvironmentHttpCommonError as EnvironmentHttpCommonErrorType } from "@t3tools/contracts"; |
| 12 | import * as DateTime from "effect/DateTime"; | 12 | import * as DateTime from "effect/DateTime"; |
| 13 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| ... | @@ -176,9 +176,13 @@ export function takePairingTokenFromUrl(): string | null { | ... | @@ -176,9 +176,13 @@ export function takePairingTokenFromUrl(): string | null { |
| 176 | } | 176 | } |
| 177 | 177 | ||
| 178 | function getDesktopBootstrapCredential(): string | null { | 178 | function getDesktopBootstrapCredential(): string | null { |
| 179 | const bootstrap = window.desktopBridge?.getLocalEnvironmentBootstrap(); | 179 | // Both backends share the same bootstrap token (DesktopBackendConfiguration |
| 180 | return typeof bootstrap?.bootstrapToken === "string" && bootstrap.bootstrapToken.length > 0 | 180 | // mints one tokenRef and feeds it to both resolvers), so picking the |
| 181 | ? bootstrap.bootstrapToken | 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 | : null; | 186 | : null; |
| 183 | } | 187 | } |
| 184 | 188 | ||
| ... | @@ -526,6 +530,16 @@ export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGat | ... | @@ -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 | export function __resetServerAuthBootstrapForTests() { | 543 | export function __resetServerAuthBootstrapForTests() { |
| 530 | bootstrapPromise = null; | 544 | bootstrapPromise = null; |
| 531 | resolvedAuthenticatedGateState = null; | 545 | resolvedAuthenticatedGateState = null; |
apps/web/src/environments/primary/bootstrap.test.ts+17-11| ... | @@ -173,12 +173,15 @@ describe("environmentBootstrap", () => { | ... | @@ -173,12 +173,15 @@ describe("environmentBootstrap", () => { |
| 173 | replaceState: vi.fn(), | 173 | replaceState: vi.fn(), |
| 174 | }, | 174 | }, |
| 175 | desktopBridge: { | 175 | desktopBridge: { |
| 176 | getLocalEnvironmentBootstrap: () => ({ | 176 | getLocalEnvironmentBootstraps: () => [ |
| 177 | label: "Local environment", | 177 | { |
| 178 | httpBaseUrl: "http://127.0.0.1:3773", | 178 | id: "primary", |
| 179 | wsBaseUrl: "ws://127.0.0.1:3773", | 179 | label: "Windows", |
| 180 | bootstrapToken: "desktop-bootstrap-token", | 180 | httpBaseUrl: "http://127.0.0.1:3773", |
| 181 | }), | 181 | wsBaseUrl: "ws://127.0.0.1:3773", |
| 182 | bootstrapToken: "desktop-bootstrap-token", | ||
| 183 | }, | ||
| 184 | ], | ||
| 182 | }, | 185 | }, |
| 183 | }); | 186 | }); |
| 184 | await installDescriptorApi(); | 187 | await installDescriptorApi(); |
| ... | @@ -212,11 +215,14 @@ describe("environmentBootstrap", () => { | ... | @@ -212,11 +215,14 @@ describe("environmentBootstrap", () => { |
| 212 | location: new URL("http://127.0.0.1:5733/"), | 215 | location: new URL("http://127.0.0.1:5733/"), |
| 213 | history: { replaceState: vi.fn() }, | 216 | history: { replaceState: vi.fn() }, |
| 214 | desktopBridge: { | 217 | desktopBridge: { |
| 215 | getLocalEnvironmentBootstrap: () => ({ | 218 | getLocalEnvironmentBootstraps: () => [ |
| 216 | label: "Local environment", | 219 | { |
| 217 | httpBaseUrl: "http://127.0.0.1:3773", | 220 | id: "primary", |
| 218 | bootstrapToken: "desktop-bootstrap-token", | 221 | label: "Local environment", |
| 219 | }), | 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,6 +23,7 @@ export { |
| 23 | peekPairingTokenFromUrl, | 23 | peekPairingTokenFromUrl, |
| 24 | PrimaryEnvironmentPairingCredentialRejectedError, | 24 | PrimaryEnvironmentPairingCredentialRejectedError, |
| 25 | PrimaryEnvironmentRequestError, | 25 | PrimaryEnvironmentRequestError, |
| 26 | reauthenticatePrimaryEnvironment, | ||
| 26 | resolveInitialServerAuthGateState, | 27 | resolveInitialServerAuthGateState, |
| 27 | revokeOtherServerClientSessions, | 28 | revokeOtherServerClientSessions, |
| 28 | revokeServerClientSession, | 29 | revokeServerClientSession, |
apps/web/src/environments/primary/target.ts+6-2| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; | 1 | import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | ||
| 4 | const PrimaryEnvironmentTargetSource = Schema.Literals([ | 4 | const PrimaryEnvironmentTargetSource = Schema.Literals([ |
| ... | @@ -76,7 +76,11 @@ export interface PrimaryEnvironmentTarget { | ... | @@ -76,7 +76,11 @@ export interface PrimaryEnvironmentTarget { |
| 76 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); | 76 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
| 77 | 77 | ||
| 78 | function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | null { | 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 | function parseTargetUrl(input: { | 86 | function parseTargetUrl(input: { |
apps/web/src/sidebarProjectGrouping.ts+21-6| ... | @@ -20,6 +20,12 @@ export interface SidebarProjectSnapshot extends Project { | ... | @@ -20,6 +20,12 @@ export interface SidebarProjectSnapshot extends Project { |
| 20 | displayName: string; | 20 | displayName: string; |
| 21 | groupedProjectCount: number; | 21 | groupedProjectCount: number; |
| 22 | environmentPresence: EnvironmentPresence; | 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 | memberProjects: readonly SidebarProjectGroupMember[]; | 29 | memberProjects: readonly SidebarProjectGroupMember[]; |
| 24 | memberProjectRefs: readonly ScopedProjectRef[]; | 30 | memberProjectRefs: readonly ScopedProjectRef[]; |
| 25 | remoteEnvironmentLabels: readonly string[]; | 31 | remoteEnvironmentLabels: readonly string[]; |
| ... | @@ -44,6 +50,11 @@ export function buildSidebarProjectSnapshots(input: { | ... | @@ -44,6 +50,11 @@ export function buildSidebarProjectSnapshots(input: { |
| 44 | settings: ProjectGroupingSettings; | 50 | settings: ProjectGroupingSettings; |
| 45 | primaryEnvironmentId: EnvironmentId | null; | 51 | primaryEnvironmentId: EnvironmentId | null; |
| 46 | resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; | 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 | }): SidebarProjectSnapshot[] { | 58 | }): SidebarProjectSnapshot[] { |
| 48 | const groupedMembers = new Map<string, SidebarProjectGroupMember[]>(); | 59 | const groupedMembers = new Map<string, SidebarProjectGroupMember[]>(); |
| 49 | for (const project of input.projects) { | 60 | for (const project of input.projects) { |
| ... | @@ -86,14 +97,17 @@ export function buildSidebarProjectSnapshots(input: { | ... | @@ -86,14 +97,17 @@ export function buildSidebarProjectSnapshots(input: { |
| 86 | input.primaryEnvironmentId !== null | 97 | input.primaryEnvironmentId !== null |
| 87 | ? members.some((member) => member.environmentId !== input.primaryEnvironmentId) | 98 | ? members.some((member) => member.environmentId !== input.primaryEnvironmentId) |
| 88 | : false; | 99 | : false; |
| 89 | const remoteEnvironmentLabels = members | 100 | const remoteMembers = members.filter( |
| 90 | .filter( | 101 | (member) => |
| 91 | (member) => | 102 | input.primaryEnvironmentId !== null && member.environmentId !== input.primaryEnvironmentId, |
| 92 | input.primaryEnvironmentId !== null && | 103 | ); |
| 93 | member.environmentId !== input.primaryEnvironmentId, | 104 | const remoteEnvironmentLabels = remoteMembers |
| 94 | ) | ||
| 95 | .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : [])) | 105 | .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : [])) |
| 96 | .filter((label, index, labels) => labels.indexOf(label) === index); | 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 | result.push({ | 112 | result.push({ |
| 99 | ...representative, | 113 | ...representative, |
| ... | @@ -108,6 +122,7 @@ export function buildSidebarProjectSnapshots(input: { | ... | @@ -108,6 +122,7 @@ export function buildSidebarProjectSnapshots(input: { |
| 108 | groupedProjectCount: members.length, | 122 | groupedProjectCount: members.length, |
| 109 | environmentPresence: | 123 | environmentPresence: |
| 110 | hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", | 124 | hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", |
| 125 | allRemoteMembersAreDesktopLocal, | ||
| 111 | memberProjects: members, | 126 | memberProjects: members, |
| 112 | memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)), | 127 | memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)), |
| 113 | remoteEnvironmentLabels, | 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,6 +89,21 @@ export const ConnectionRegistration = Schema.Union([ |
| 89 | ]); | 89 | ]); |
| 90 | export type ConnectionRegistration = typeof ConnectionRegistration.Type; | 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 | export function connectionRegistrationTarget( | 107 | export function connectionRegistrationTarget( |
| 93 | registration: ConnectionRegistration | PrimaryConnectionRegistration, | 108 | registration: ConnectionRegistration | PrimaryConnectionRegistration, |
| 94 | ): ConnectionTarget { | 109 | ): ConnectionTarget { |
packages/client-runtime/src/connection/layer.ts+1-1| ... | @@ -35,7 +35,7 @@ const connectionStartupLayer = Layer.effectDiscard( | ... | @@ -35,7 +35,7 @@ const connectionStartupLayer = Layer.effectDiscard( |
| 35 | const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; | 35 | const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; |
| 36 | yield* registry.start; | 36 | yield* registry.start; |
| 37 | yield* platformSource.registrations.pipe( | 37 | yield* platformSource.registrations.pipe( |
| 38 | Stream.runForEach(registry.registerPlatform), | 38 | Stream.runForEach(registry.reconcilePlatform), |
| 39 | Effect.forkScoped, | 39 | Effect.forkScoped, |
| 40 | ); | 40 | ); |
| 41 | }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), | 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,10 +16,12 @@ import * as ClientCapabilities from "../platform/capabilities.ts"; |
| 16 | import { | 16 | import { |
| 17 | type ConnectionCatalogEntry, | 17 | type ConnectionCatalogEntry, |
| 18 | type ConnectionRegistration, | 18 | type ConnectionRegistration, |
| 19 | type PlatformConnectionRegistration, | ||
| 19 | type PrimaryConnectionRegistration, | 20 | type PrimaryConnectionRegistration, |
| 20 | SshConnectionProfile, | 21 | SshConnectionProfile, |
| 21 | connectionRegistrationCatalogEntry, | 22 | connectionRegistrationCatalogEntry, |
| 22 | } from "./catalog.ts"; | 23 | } from "./catalog.ts"; |
| 24 | import * as ConnectionCredentialStore from "./credentialStore.ts"; | ||
| 23 | import * as ConnectionProfileStore from "./profileStore.ts"; | 25 | import * as ConnectionProfileStore from "./profileStore.ts"; |
| 24 | import * as Connectivity from "./connectivity.ts"; | 26 | import * as Connectivity from "./connectivity.ts"; |
| 25 | import type { | 27 | import type { |
| ... | @@ -69,6 +71,9 @@ export class EnvironmentRegistry extends Context.Service< | ... | @@ -69,6 +71,9 @@ export class EnvironmentRegistry extends Context.Service< |
| 69 | registration: ConnectionRegistration, | 71 | registration: ConnectionRegistration, |
| 70 | ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>; | 72 | ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>; |
| 71 | readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect<void>; | 73 | readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect<void>; |
| 74 | readonly reconcilePlatform: ( | ||
| 75 | registrations: ReadonlyArray<PlatformConnectionRegistration>, | ||
| 76 | ) => Effect.Effect<void>; | ||
| 72 | readonly remove: ( | 77 | readonly remove: ( |
| 73 | environmentId: EnvironmentId, | 78 | environmentId: EnvironmentId, |
| 74 | ) => Effect.Effect< | 79 | ) => Effect.Effect< |
| ... | @@ -126,6 +131,7 @@ export const make = Effect.gen(function* () { | ... | @@ -126,6 +131,7 @@ export const make = Effect.gen(function* () { |
| 126 | const cache = yield* Persistence.EnvironmentCacheStore; | 131 | const cache = yield* Persistence.EnvironmentCacheStore; |
| 127 | const ownedDataCleanup = yield* Persistence.EnvironmentOwnedDataCleanup; | 132 | const ownedDataCleanup = yield* Persistence.EnvironmentOwnedDataCleanup; |
| 128 | const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; | 133 | const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; |
| 134 | const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; | ||
| 129 | const connectivity = yield* Connectivity.Connectivity; | 135 | const connectivity = yield* Connectivity.Connectivity; |
| 130 | const driver = yield* ConnectionDriver.ConnectionDriver; | 136 | const driver = yield* ConnectionDriver.ConnectionDriver; |
| 131 | const wakeups = yield* ConnectionWakeups.ConnectionWakeups; | 137 | const wakeups = yield* ConnectionWakeups.ConnectionWakeups; |
| ... | @@ -402,47 +408,137 @@ export const make = Effect.gen(function* () { | ... | @@ -402,47 +408,137 @@ export const make = Effect.gen(function* () { |
| 402 | ); | 408 | ); |
| 403 | }); | 409 | }); |
| 404 | 410 | ||
| 405 | const registerPlatform = Effect.fn("EnvironmentRegistry.registerPlatform")(function* ( | 411 | const installPlatformRegistration = Effect.fn("EnvironmentRegistry.installPlatformRegistration")( |
| 406 | registration: PrimaryConnectionRegistration, | 412 | function* (registration: PlatformConnectionRegistration) { |
| 407 | ) { | 413 | const entry = connectionRegistrationCatalogEntry(registration); |
| 408 | const entry = connectionRegistrationCatalogEntry(registration); | 414 | const target = entry.target; |
| 409 | const target = entry.target; | 415 | yield* withLeaseLock( |
| 410 | yield* withLeaseLock( | 416 | target.environmentId, |
| 411 | target.environmentId, | 417 | Effect.gen(function* () { |
| 412 | Effect.gen(function* () { | 418 | yield* Ref.update(platformEnvironmentIds, (current) => { |
| 413 | yield* Ref.update(platformEnvironmentIds, (current) => { | 419 | const next = new Set(current); |
| 414 | const next = new Set(current); | 420 | next.add(target.environmentId); |
| 415 | next.add(target.environmentId); | 421 | return next; |
| 416 | return next; | 422 | }); |
| 417 | }); | ||
| 418 | 423 | ||
| 419 | const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get( | 424 | // Secondary desktop-local backends (e.g. a parallel WSL backend) live |
| 420 | target.environmentId, | 425 | // on their own loopback origin, so they authenticate with a bearer |
| 421 | ); | 426 | // token instead of the primary's same-origin cookie. Stash it where |
| 422 | if (persistedTarget !== undefined) { | 427 | // the resolver's bearer broker looks it up. |
| 423 | yield* registrations.remove(persistedTarget).pipe( | 428 | if (registration._tag === "BearerConnectionRegistration") { |
| 424 | Effect.tap(() => | 429 | yield* credentials.put(registration.target.connectionId, registration.credential).pipe( |
| 425 | Ref.update(persistedTargetsByEnvironment, (current) => { | 430 | Effect.catch((error) => |
| 426 | const next = new Map(current); | 431 | Effect.logWarning("Could not store the platform bearer credential.", { |
| 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 | { | ||
| 435 | environmentId: target.environmentId, | 432 | environmentId: target.environmentId, |
| 436 | error, | 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 }); | 463 | yield* installEntryLocked(entry, { retainEquivalentRuntime: true }); |
| 444 | }), | 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 | const remove = Effect.fn("EnvironmentRegistry.remove")(function* (environmentId: EnvironmentId) { | 544 | const remove = Effect.fn("EnvironmentRegistry.remove")(function* (environmentId: EnvironmentId) { |
| ... | @@ -567,6 +663,7 @@ export const make = Effect.gen(function* () { | ... | @@ -567,6 +663,7 @@ export const make = Effect.gen(function* () { |
| 567 | start, | 663 | start, |
| 568 | register, | 664 | register, |
| 569 | registerPlatform, | 665 | registerPlatform, |
| 666 | reconcilePlatform, | ||
| 570 | remove, | 667 | remove, |
| 571 | removeRelayEnvironments, | 668 | removeRelayEnvironments, |
| 572 | retryNow, | 669 | retryNow, |
packages/client-runtime/src/platform/source.ts+6-2| ... | @@ -1,11 +1,15 @@ | ... | @@ -1,11 +1,15 @@ |
| 1 | import * as Context from "effect/Context"; | 1 | import * as Context from "effect/Context"; |
| 2 | import type * as Stream from "effect/Stream"; | 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 | export class PlatformConnectionSource extends Context.Service< | 6 | export class PlatformConnectionSource extends Context.Service< |
| 7 | PlatformConnectionSource, | 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 | >()("@t3tools/client-runtime/platform/source/PlatformConnectionSource") {} | 15 | >()("@t3tools/client-runtime/platform/source/PlatformConnectionSource") {} |
packages/contracts/src/desktopBootstrap.ts+4-1| ... | @@ -6,7 +6,10 @@ export const DesktopBackendBootstrap = Schema.Struct({ | ... | @@ -6,7 +6,10 @@ export const DesktopBackendBootstrap = Schema.Struct({ |
| 6 | mode: Schema.Literal("desktop"), | 6 | mode: Schema.Literal("desktop"), |
| 7 | noBrowser: Schema.Boolean, | 7 | noBrowser: Schema.Boolean, |
| 8 | port: PortSchema, | 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 | host: Schema.String, | 13 | host: Schema.String, |
| 11 | desktopBootstrapToken: Schema.String, | 14 | desktopBootstrapToken: Schema.String, |
| 12 | tailscaleServeEnabled: Schema.Boolean, | 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,15 +259,31 @@ export const DesktopUpdateCheckResultSchema = Schema.Struct({ |
| 259 | state: DesktopUpdateStateSchema, | 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 | export interface DesktopEnvironmentBootstrap { | 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 | label: string; | 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 | httpBaseUrl: string | null; | 278 | httpBaseUrl: string | null; |
| 265 | wsBaseUrl: string | null; | 279 | wsBaseUrl: string | null; |
| 266 | bootstrapToken?: string; | 280 | bootstrapToken?: string; |
| 267 | } | 281 | } |
| 268 | 282 | ||
| 269 | export const DesktopEnvironmentBootstrapSchema = Schema.Struct({ | 283 | export const DesktopEnvironmentBootstrapSchema = Schema.Struct({ |
| 284 | id: Schema.String, | ||
| 270 | label: Schema.String, | 285 | label: Schema.String, |
| 286 | runningDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), | ||
| 271 | httpBaseUrl: Schema.NullOr(Schema.String), | 287 | httpBaseUrl: Schema.NullOr(Schema.String), |
| 272 | wsBaseUrl: Schema.NullOr(Schema.String), | 288 | wsBaseUrl: Schema.NullOr(Schema.String), |
| 273 | bootstrapToken: Schema.optionalKey(Schema.String), | 289 | bootstrapToken: Schema.optionalKey(Schema.String), |
| ... | @@ -411,10 +427,59 @@ export const DesktopServerExposureStateSchema = Schema.Struct({ | ... | @@ -411,10 +427,59 @@ export const DesktopServerExposureStateSchema = Schema.Struct({ |
| 411 | 427 | ||
| 412 | export interface PickFolderOptions { | 428 | export interface PickFolderOptions { |
| 413 | initialPath?: string | null; | 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 | export const PickFolderOptionsSchema = Schema.Struct({ | 438 | export const PickFolderOptionsSchema = Schema.Struct({ |
| 417 | initialPath: Schema.optionalKey(Schema.NullOr(Schema.String)), | 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,7 +946,10 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ |
| 881 | 946 | ||
| 882 | export interface DesktopBridge { | 947 | export interface DesktopBridge { |
| 883 | getAppBranding: () => DesktopAppBranding | null; | 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 | getLocalEnvironmentBearerToken: () => Promise<string>; | 953 | getLocalEnvironmentBearerToken: () => Promise<string>; |
| 886 | getClientSettings: () => Promise<ClientSettings | null>; | 954 | getClientSettings: () => Promise<ClientSettings | null>; |
| 887 | setClientSettings: (settings: ClientSettings) => Promise<void>; | 955 | setClientSettings: (settings: ClientSettings) => Promise<void>; |
| ... | @@ -913,6 +981,10 @@ export interface DesktopBridge { | ... | @@ -913,6 +981,10 @@ export interface DesktopBridge { |
| 913 | readonly port?: number; | 981 | readonly port?: number; |
| 914 | }) => Promise<DesktopServerExposureState>; | 982 | }) => Promise<DesktopServerExposureState>; |
| 915 | getAdvertisedEndpoints: () => Promise<readonly AdvertisedEndpoint[]>; | 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 | pickFolder: (options?: PickFolderOptions) => Promise<string | null>; | 988 | pickFolder: (options?: PickFolderOptions) => Promise<string | null>; |
| 917 | confirm: (message: string) => Promise<boolean>; | 989 | confirm: (message: string) => Promise<boolean>; |
| 918 | setTheme: (theme: DesktopTheme) => Promise<void>; | 990 | setTheme: (theme: DesktopTheme) => Promise<void>; |
packages/shared/package.json+4| ... | @@ -178,6 +178,10 @@ | ... | @@ -178,6 +178,10 @@ |
| 178 | "./hostProcess": { | 178 | "./hostProcess": { |
| 179 | "types": "./src/hostProcess.ts", | 179 | "types": "./src/hostProcess.ts", |
| 180 | "import": "./src/hostProcess.ts" | 180 | "import": "./src/hostProcess.ts" |
| 181 | }, | ||
| 182 | "./httpReadiness": { | ||
| 183 | "types": "./src/httpReadiness.ts", | ||
| 184 | "import": "./src/httpReadiness.ts" | ||
| 181 | } | 185 | } |
| 182 | }, | 186 | }, |
| 183 | "scripts": { | 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,24 +2,24 @@ import type { |
| 2 | DesktopSshEnvironmentBootstrap, | 2 | DesktopSshEnvironmentBootstrap, |
| 3 | DesktopSshEnvironmentTarget, | 3 | DesktopSshEnvironmentTarget, |
| 4 | } from "@t3tools/contracts"; | 4 | } from "@t3tools/contracts"; |
| 5 | import { | ||
| 6 | describeReadinessCause, | ||
| 7 | waitForHttpReady as waitForHttpReadyShared, | ||
| 8 | } from "@t3tools/shared/httpReadiness"; | ||
| 5 | import * as NetService from "@t3tools/shared/Net"; | 9 | import * as NetService from "@t3tools/shared/Net"; |
| 6 | import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; | 10 | import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 7 | import { satisfiesSemverRange } from "@t3tools/shared/semver"; | 11 | import { satisfiesSemverRange } from "@t3tools/shared/semver"; |
| 8 | import * as Context from "effect/Context"; | 12 | import * as Context from "effect/Context"; |
| 9 | import * as Deferred from "effect/Deferred"; | 13 | import * as Deferred from "effect/Deferred"; |
| 10 | import * as Duration from "effect/Duration"; | ||
| 11 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 12 | import * as Exit from "effect/Exit"; | 15 | import * as Exit from "effect/Exit"; |
| 13 | import * as FileSystem from "effect/FileSystem"; | 16 | import * as FileSystem from "effect/FileSystem"; |
| 14 | import * as Layer from "effect/Layer"; | 17 | import * as Layer from "effect/Layer"; |
| 15 | import * as Option from "effect/Option"; | ||
| 16 | import * as Path from "effect/Path"; | 18 | import * as Path from "effect/Path"; |
| 17 | import * as Ref from "effect/Ref"; | ||
| 18 | import * as Schedule from "effect/Schedule"; | ||
| 19 | import * as Schema from "effect/Schema"; | 19 | import * as Schema from "effect/Schema"; |
| 20 | import * as Scope from "effect/Scope"; | 20 | import * as Scope from "effect/Scope"; |
| 21 | import * as Stream from "effect/Stream"; | 21 | import * as Stream from "effect/Stream"; |
| 22 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | 22 | import { HttpClient } from "effect/unstable/http"; |
| 23 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 23 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 24 | 24 | ||
| 25 | import { | 25 | import { |
| ... | @@ -232,33 +232,9 @@ function applyScriptPlaceholders( | ... | @@ -232,33 +232,9 @@ function applyScriptPlaceholders( |
| 232 | return result; | 232 | return result; |
| 233 | } | 233 | } |
| 234 | 234 | ||
| 235 | export function describeReadinessCause(cause: unknown): unknown { | 235 | // Re-exported from the shared HTTP readiness module so existing importers |
| 236 | if (cause instanceof SshReadinessError) { | 236 | // (notably tunnel.test.ts) keep resolving it from here. |
| 237 | return { | 237 | export { describeReadinessCause }; |
| 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 | } | ||
| 262 | 238 | ||
| 263 | export const REMOTE_PICK_PORT_SCRIPT = `const fs = require("node:fs"); | 239 | export const REMOTE_PICK_PORT_SCRIPT = `const fs = require("node:fs"); |
| 264 | const net = require("node:net"); | 240 | const net = require("node:net"); |
| ... | @@ -666,7 +642,7 @@ export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string | ... | @@ -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 | return stripTrailingNewlines( | 646 | return stripTrailingNewlines( |
| 671 | applyScriptPlaceholders(REMOTE_NODE_ENV_SCRIPT, { | 647 | applyScriptPlaceholders(REMOTE_NODE_ENV_SCRIPT, { |
| 672 | T3_NODE_ENGINE_RANGE: shellSingleQuote(input?.nodeEngineRange?.trim() || ""), | 648 | T3_NODE_ENGINE_RANGE: shellSingleQuote(input?.nodeEngineRange?.trim() || ""), |
| ... | @@ -865,120 +841,46 @@ const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")( | ... | @@ -865,120 +841,46 @@ const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")( |
| 865 | return result.stdout.trim(); | 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 | readonly baseUrl: string; | 845 | readonly baseUrl: string; |
| 870 | readonly timeoutMs?: number; | 846 | readonly timeoutMs?: number; |
| 871 | readonly intervalMs?: number; | 847 | readonly intervalMs?: number; |
| 872 | readonly probeTimeoutMs?: number; | 848 | readonly probeTimeoutMs?: number; |
| 873 | readonly path?: string; | 849 | readonly path?: string; |
| 874 | }): Effect.fn.Return<void, SshReadinessError, HttpClient.HttpClient> { | 850 | }): Effect.Effect<void, SshReadinessError, HttpClient.HttpClient> => |
| 875 | const timeoutMs = input.timeoutMs ?? 30_000; | 851 | waitForHttpReadyShared({ |
| 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", { | ||
| 887 | baseUrl: input.baseUrl, | 852 | baseUrl: input.baseUrl, |
| 888 | requestUrl, | 853 | ...(input.path === undefined ? {} : { path: input.path }), |
| 889 | timeoutMs, | 854 | ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), |
| 890 | intervalMs, | 855 | ...(input.intervalMs === undefined ? {} : { intervalMs: input.intervalMs }), |
| 891 | probeTimeoutMs, | 856 | probeTimeoutMs: input.probeTimeoutMs ?? SSH_READY_PROBE_TIMEOUT_MS, |
| 892 | }); | 857 | makeError: ({ requestUrl, probeTimeoutMs, cause }) => { |
| 893 | 858 | if (typeof cause === "object" && cause !== null && "kind" in cause) { | |
| 894 | const readinessClient = client.pipe( | 859 | const kind = (cause as { readonly kind?: unknown }).kind; |
| 895 | HttpClient.filterStatusOk, | 860 | if (kind === "probe-timeout") { |
| 896 | HttpClient.transform((effect) => | 861 | return new SshReadinessError({ |
| 897 | Effect.gen(function* () { | 862 | message: `Backend readiness probe exceeded ${probeTimeoutMs}ms at ${requestUrl}.`, |
| 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}.`, | ||
| 950 | cause, | 863 | cause, |
| 951 | }), | 864 | }); |
| 952 | ), | 865 | } |
| 953 | Effect.timeoutOption(Duration.millis(timeoutMs)), | 866 | if (kind === "overall-timeout") { |
| 954 | ); | 867 | const overall = cause as unknown as { |
| 955 | 868 | readonly baseUrl: string; | |
| 956 | return yield* Option.match(result, { | 869 | readonly timeoutMs: number; |
| 957 | onSome: () => | 870 | readonly lastFailure: unknown; |
| 958 | Effect.logDebug("ssh.tunnel.httpReady.succeeded", { | 871 | }; |
| 959 | baseUrl: input.baseUrl, | 872 | return new SshReadinessError({ |
| 960 | requestUrl, | 873 | message: `Timed out waiting ${overall.timeoutMs}ms for backend readiness at ${overall.baseUrl}.`, |
| 961 | attempts: attempt, | 874 | cause: overall.lastFailure, |
| 962 | }), | 875 | }); |
| 963 | onNone: () => | 876 | } |
| 964 | Effect.gen(function* () { | 877 | } |
| 965 | const lastFailure = yield* Ref.get(lastProbeFailure); | 878 | return new SshReadinessError({ |
| 966 | yield* Effect.logWarning("ssh.tunnel.httpReady.timedOut", { | 879 | message: `Backend readiness probe failed at ${requestUrl}.`, |
| 967 | baseUrl: input.baseUrl, | 880 | cause, |
| 968 | requestUrl, | 881 | }); |
| 969 | timeoutMs, | 882 | }, |
| 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 | }), | ||
| 980 | }); | 883 | }); |
| 981 | }); | ||
| 982 | 884 | ||
| 983 | function isLoopbackHostname(hostname: string): boolean { | 885 | function isLoopbackHostname(hostname: string): boolean { |
| 984 | const normalized = hostname | 886 | const normalized = hostname |
pnpm-lock.yaml+117-10| ... | @@ -911,21 +911,25 @@ packages: | ... | @@ -911,21 +911,25 @@ packages: |
| 911 | resolution: {integrity: sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg==} | 911 | resolution: {integrity: sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg==} |
| 912 | cpu: [arm64] | 912 | cpu: [arm64] |
| 913 | os: [linux] | 913 | os: [linux] |
| 914 | libc: [musl] | ||
| 914 | 915 | ||
| 915 | '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170': | 916 | '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170': |
| 916 | resolution: {integrity: sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ==} | 917 | resolution: {integrity: sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ==} |
| 917 | cpu: [arm64] | 918 | cpu: [arm64] |
| 918 | os: [linux] | 919 | os: [linux] |
| 920 | libc: [glibc] | ||
| 919 | 921 | ||
| 920 | '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170': | 922 | '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170': |
| 921 | resolution: {integrity: sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ==} | 923 | resolution: {integrity: sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ==} |
| 922 | cpu: [x64] | 924 | cpu: [x64] |
| 923 | os: [linux] | 925 | os: [linux] |
| 926 | libc: [musl] | ||
| 924 | 927 | ||
| 925 | '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170': | 928 | '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170': |
| 926 | resolution: {integrity: sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg==} | 929 | resolution: {integrity: sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg==} |
| 927 | cpu: [x64] | 930 | cpu: [x64] |
| 928 | os: [linux] | 931 | os: [linux] |
| 932 | libc: [glibc] | ||
| 929 | 933 | ||
| 930 | '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170': | 934 | '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170': |
| 931 | resolution: {integrity: sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg==} | 935 | resolution: {integrity: sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg==} |
| ... | @@ -977,24 +981,28 @@ packages: | ... | @@ -977,24 +981,28 @@ packages: |
| 977 | engines: {node: ^20.19.0 || >=22.12.0} | 981 | engines: {node: ^20.19.0 || >=22.12.0} |
| 978 | cpu: [arm64] | 982 | cpu: [arm64] |
| 979 | os: [linux] | 983 | os: [linux] |
| 984 | libc: [glibc] | ||
| 980 | 985 | ||
| 981 | '@astrojs/compiler-binding-linux-arm64-musl@0.2.3': | 986 | '@astrojs/compiler-binding-linux-arm64-musl@0.2.3': |
| 982 | resolution: {integrity: sha512-O3e2CbN4yTsRguWYNnRd0p5YQ0H3fb7KpcR0W4R319q/gq5B1pJ7eqNbiO3b8g2AuiEcRTiUz5jeGT9j69cxOQ==} | 987 | resolution: {integrity: sha512-O3e2CbN4yTsRguWYNnRd0p5YQ0H3fb7KpcR0W4R319q/gq5B1pJ7eqNbiO3b8g2AuiEcRTiUz5jeGT9j69cxOQ==} |
| 983 | engines: {node: ^20.19.0 || >=22.12.0} | 988 | engines: {node: ^20.19.0 || >=22.12.0} |
| 984 | cpu: [arm64] | 989 | cpu: [arm64] |
| 985 | os: [linux] | 990 | os: [linux] |
| 991 | libc: [musl] | ||
| 986 | 992 | ||
| 987 | '@astrojs/compiler-binding-linux-x64-gnu@0.2.3': | 993 | '@astrojs/compiler-binding-linux-x64-gnu@0.2.3': |
| 988 | resolution: {integrity: sha512-hbLBjXVp+96psMe7/7uqyrquGiULXANrq6REVxxPK/I5VzebZ7LHmSfykmByUbLyR1u+K6CTBKgvdQsK2L+2Xw==} | 994 | resolution: {integrity: sha512-hbLBjXVp+96psMe7/7uqyrquGiULXANrq6REVxxPK/I5VzebZ7LHmSfykmByUbLyR1u+K6CTBKgvdQsK2L+2Xw==} |
| 989 | engines: {node: ^20.19.0 || >=22.12.0} | 995 | engines: {node: ^20.19.0 || >=22.12.0} |
| 990 | cpu: [x64] | 996 | cpu: [x64] |
| 991 | os: [linux] | 997 | os: [linux] |
| 998 | libc: [glibc] | ||
| 992 | 999 | ||
| 993 | '@astrojs/compiler-binding-linux-x64-musl@0.2.3': | 1000 | '@astrojs/compiler-binding-linux-x64-musl@0.2.3': |
| 994 | resolution: {integrity: sha512-vIiEvOwrJfHZMaTmqUCrFTIwMYL0+PD3Rvy7kFDQgERyx3zhaw8CPa01MCCqa+/sj344BGrXKZ6ti37SgNLMhw==} | 1001 | resolution: {integrity: sha512-vIiEvOwrJfHZMaTmqUCrFTIwMYL0+PD3Rvy7kFDQgERyx3zhaw8CPa01MCCqa+/sj344BGrXKZ6ti37SgNLMhw==} |
| 995 | engines: {node: ^20.19.0 || >=22.12.0} | 1002 | engines: {node: ^20.19.0 || >=22.12.0} |
| 996 | cpu: [x64] | 1003 | cpu: [x64] |
| 997 | os: [linux] | 1004 | os: [linux] |
| 1005 | libc: [musl] | ||
| 998 | 1006 | ||
| 999 | '@astrojs/compiler-binding-wasm32-wasi@0.2.3': | 1007 | '@astrojs/compiler-binding-wasm32-wasi@0.2.3': |
| 1000 | resolution: {integrity: sha512-p9S2X8z/mUR2SMzAVJRFMCt8YaalKR+pjl2DgpdjzCQc6ww4bo8kiy54tgKqxZeNF5c+/2tCDTQIxVSm9V1FsA==} | 1008 | resolution: {integrity: sha512-p9S2X8z/mUR2SMzAVJRFMCt8YaalKR+pjl2DgpdjzCQc6ww4bo8kiy54tgKqxZeNF5c+/2tCDTQIxVSm9V1FsA==} |
| ... | @@ -1583,21 +1591,25 @@ packages: | ... | @@ -1583,21 +1591,25 @@ packages: |
| 1583 | resolution: {integrity: sha512-A/pWy8Jb/PhDYc2/JFuYh06gFJcsfBUBDl81YydGYBrL/Z4nItDfhNDNOibyeSN/lKKDRlycIHEIajjErk00sQ==} | 1591 | resolution: {integrity: sha512-A/pWy8Jb/PhDYc2/JFuYh06gFJcsfBUBDl81YydGYBrL/Z4nItDfhNDNOibyeSN/lKKDRlycIHEIajjErk00sQ==} |
| 1584 | cpu: [arm64] | 1592 | cpu: [arm64] |
| 1585 | os: [linux] | 1593 | os: [linux] |
| 1594 | libc: [glibc] | ||
| 1586 | 1595 | ||
| 1587 | '@bruits/satteri-linux-arm64-musl@0.9.3': | 1596 | '@bruits/satteri-linux-arm64-musl@0.9.3': |
| 1588 | resolution: {integrity: sha512-L6YxmyOSickzo4pE5WmZfNTJnjX0MtgKOsuwQfNZECTx9Ir5vl2B37EIwnxe2AybuPPHl+FqVQtthNDUdH4Vgg==} | 1597 | resolution: {integrity: sha512-L6YxmyOSickzo4pE5WmZfNTJnjX0MtgKOsuwQfNZECTx9Ir5vl2B37EIwnxe2AybuPPHl+FqVQtthNDUdH4Vgg==} |
| 1589 | cpu: [arm64] | 1598 | cpu: [arm64] |
| 1590 | os: [linux] | 1599 | os: [linux] |
| 1600 | libc: [musl] | ||
| 1591 | 1601 | ||
| 1592 | '@bruits/satteri-linux-x64-gnu@0.9.3': | 1602 | '@bruits/satteri-linux-x64-gnu@0.9.3': |
| 1593 | resolution: {integrity: sha512-RgH6GPihg9Lzs2yHUsMjqiLxfLyOdmBty8sg9pBY9B4CBnvdOzvg8vklqN+C4qrEEdA9TwpbDpHr1AshLKyRpw==} | 1603 | resolution: {integrity: sha512-RgH6GPihg9Lzs2yHUsMjqiLxfLyOdmBty8sg9pBY9B4CBnvdOzvg8vklqN+C4qrEEdA9TwpbDpHr1AshLKyRpw==} |
| 1594 | cpu: [x64] | 1604 | cpu: [x64] |
| 1595 | os: [linux] | 1605 | os: [linux] |
| 1606 | libc: [glibc] | ||
| 1596 | 1607 | ||
| 1597 | '@bruits/satteri-linux-x64-musl@0.9.3': | 1608 | '@bruits/satteri-linux-x64-musl@0.9.3': |
| 1598 | resolution: {integrity: sha512-BeWhVORjNTIomePznUKiMbHZTqC0j7sMXZFsISmbX+po5d33KLkqBqKh6K332CHJ8KUmCWx16FfPjwsoysttQg==} | 1609 | resolution: {integrity: sha512-BeWhVORjNTIomePznUKiMbHZTqC0j7sMXZFsISmbX+po5d33KLkqBqKh6K332CHJ8KUmCWx16FfPjwsoysttQg==} |
| 1599 | cpu: [x64] | 1610 | cpu: [x64] |
| 1600 | os: [linux] | 1611 | os: [linux] |
| 1612 | libc: [musl] | ||
| 1601 | 1613 | ||
| 1602 | '@bruits/satteri-wasm32-wasi@0.9.3': | 1614 | '@bruits/satteri-wasm32-wasi@0.9.3': |
| 1603 | resolution: {integrity: sha512-dFNcOHKWV2cztCPnYTn7kZ9D7kNOt8N239z5ysFkNHLxJrfK7zaKIXQbfXYN32C+JoVFqAcTIOeWH2+VnsCOHg==} | 1615 | resolution: {integrity: sha512-dFNcOHKWV2cztCPnYTn7kZ9D7kNOt8N239z5ysFkNHLxJrfK7zaKIXQbfXYN32C+JoVFqAcTIOeWH2+VnsCOHg==} |
| ... | @@ -2575,21 +2587,25 @@ packages: | ... | @@ -2575,21 +2587,25 @@ packages: |
| 2575 | resolution: {integrity: sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw==} | 2587 | resolution: {integrity: sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw==} |
| 2576 | cpu: [arm64] | 2588 | cpu: [arm64] |
| 2577 | os: [linux] | 2589 | os: [linux] |
| 2590 | libc: [glibc] | ||
| 2578 | 2591 | ||
| 2579 | '@ff-labs/fff-bin-linux-arm64-musl@0.9.4': | 2592 | '@ff-labs/fff-bin-linux-arm64-musl@0.9.4': |
| 2580 | resolution: {integrity: sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA==} | 2593 | resolution: {integrity: sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA==} |
| 2581 | cpu: [arm64] | 2594 | cpu: [arm64] |
| 2582 | os: [linux] | 2595 | os: [linux] |
| 2596 | libc: [musl] | ||
| 2583 | 2597 | ||
| 2584 | '@ff-labs/fff-bin-linux-x64-gnu@0.9.4': | 2598 | '@ff-labs/fff-bin-linux-x64-gnu@0.9.4': |
| 2585 | resolution: {integrity: sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q==} | 2599 | resolution: {integrity: sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q==} |
| 2586 | cpu: [x64] | 2600 | cpu: [x64] |
| 2587 | os: [linux] | 2601 | os: [linux] |
| 2602 | libc: [glibc] | ||
| 2588 | 2603 | ||
| 2589 | '@ff-labs/fff-bin-linux-x64-musl@0.9.4': | 2604 | '@ff-labs/fff-bin-linux-x64-musl@0.9.4': |
| 2590 | resolution: {integrity: sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w==} | 2605 | resolution: {integrity: sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w==} |
| 2591 | cpu: [x64] | 2606 | cpu: [x64] |
| 2592 | os: [linux] | 2607 | os: [linux] |
| 2608 | libc: [musl] | ||
| 2593 | 2609 | ||
| 2594 | '@ff-labs/fff-bin-win32-arm64@0.9.4': | 2610 | '@ff-labs/fff-bin-win32-arm64@0.9.4': |
| 2595 | resolution: {integrity: sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ==} | 2611 | resolution: {integrity: sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ==} |
| ... | @@ -2676,89 +2692,105 @@ packages: | ... | @@ -2676,89 +2692,105 @@ packages: |
| 2676 | resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} | 2692 | resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} |
| 2677 | cpu: [arm64] | 2693 | cpu: [arm64] |
| 2678 | os: [linux] | 2694 | os: [linux] |
| 2695 | libc: [glibc] | ||
| 2679 | 2696 | ||
| 2680 | '@img/sharp-libvips-linux-arm@1.2.4': | 2697 | '@img/sharp-libvips-linux-arm@1.2.4': |
| 2681 | resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} | 2698 | resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} |
| 2682 | cpu: [arm] | 2699 | cpu: [arm] |
| 2683 | os: [linux] | 2700 | os: [linux] |
| 2701 | libc: [glibc] | ||
| 2684 | 2702 | ||
| 2685 | '@img/sharp-libvips-linux-ppc64@1.2.4': | 2703 | '@img/sharp-libvips-linux-ppc64@1.2.4': |
| 2686 | resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} | 2704 | resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} |
| 2687 | cpu: [ppc64] | 2705 | cpu: [ppc64] |
| 2688 | os: [linux] | 2706 | os: [linux] |
| 2707 | libc: [glibc] | ||
| 2689 | 2708 | ||
| 2690 | '@img/sharp-libvips-linux-riscv64@1.2.4': | 2709 | '@img/sharp-libvips-linux-riscv64@1.2.4': |
| 2691 | resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} | 2710 | resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} |
| 2692 | cpu: [riscv64] | 2711 | cpu: [riscv64] |
| 2693 | os: [linux] | 2712 | os: [linux] |
| 2713 | libc: [glibc] | ||
| 2694 | 2714 | ||
| 2695 | '@img/sharp-libvips-linux-s390x@1.2.4': | 2715 | '@img/sharp-libvips-linux-s390x@1.2.4': |
| 2696 | resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} | 2716 | resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} |
| 2697 | cpu: [s390x] | 2717 | cpu: [s390x] |
| 2698 | os: [linux] | 2718 | os: [linux] |
| 2719 | libc: [glibc] | ||
| 2699 | 2720 | ||
| 2700 | '@img/sharp-libvips-linux-x64@1.2.4': | 2721 | '@img/sharp-libvips-linux-x64@1.2.4': |
| 2701 | resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} | 2722 | resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} |
| 2702 | cpu: [x64] | 2723 | cpu: [x64] |
| 2703 | os: [linux] | 2724 | os: [linux] |
| 2725 | libc: [glibc] | ||
| 2704 | 2726 | ||
| 2705 | '@img/sharp-libvips-linuxmusl-arm64@1.2.4': | 2727 | '@img/sharp-libvips-linuxmusl-arm64@1.2.4': |
| 2706 | resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} | 2728 | resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} |
| 2707 | cpu: [arm64] | 2729 | cpu: [arm64] |
| 2708 | os: [linux] | 2730 | os: [linux] |
| 2731 | libc: [musl] | ||
| 2709 | 2732 | ||
| 2710 | '@img/sharp-libvips-linuxmusl-x64@1.2.4': | 2733 | '@img/sharp-libvips-linuxmusl-x64@1.2.4': |
| 2711 | resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} | 2734 | resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} |
| 2712 | cpu: [x64] | 2735 | cpu: [x64] |
| 2713 | os: [linux] | 2736 | os: [linux] |
| 2737 | libc: [musl] | ||
| 2714 | 2738 | ||
| 2715 | '@img/sharp-linux-arm64@0.34.5': | 2739 | '@img/sharp-linux-arm64@0.34.5': |
| 2716 | resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} | 2740 | resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} |
| 2717 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | 2741 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2718 | cpu: [arm64] | 2742 | cpu: [arm64] |
| 2719 | os: [linux] | 2743 | os: [linux] |
| 2744 | libc: [glibc] | ||
| 2720 | 2745 | ||
| 2721 | '@img/sharp-linux-arm@0.34.5': | 2746 | '@img/sharp-linux-arm@0.34.5': |
| 2722 | resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} | 2747 | resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} |
| 2723 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | 2748 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2724 | cpu: [arm] | 2749 | cpu: [arm] |
| 2725 | os: [linux] | 2750 | os: [linux] |
| 2751 | libc: [glibc] | ||
| 2726 | 2752 | ||
| 2727 | '@img/sharp-linux-ppc64@0.34.5': | 2753 | '@img/sharp-linux-ppc64@0.34.5': |
| 2728 | resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} | 2754 | resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} |
| 2729 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | 2755 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2730 | cpu: [ppc64] | 2756 | cpu: [ppc64] |
| 2731 | os: [linux] | 2757 | os: [linux] |
| 2758 | libc: [glibc] | ||
| 2732 | 2759 | ||
| 2733 | '@img/sharp-linux-riscv64@0.34.5': | 2760 | '@img/sharp-linux-riscv64@0.34.5': |
| 2734 | resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} | 2761 | resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} |
| 2735 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | 2762 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2736 | cpu: [riscv64] | 2763 | cpu: [riscv64] |
| 2737 | os: [linux] | 2764 | os: [linux] |
| 2765 | libc: [glibc] | ||
| 2738 | 2766 | ||
| 2739 | '@img/sharp-linux-s390x@0.34.5': | 2767 | '@img/sharp-linux-s390x@0.34.5': |
| 2740 | resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} | 2768 | resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} |
| 2741 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | 2769 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2742 | cpu: [s390x] | 2770 | cpu: [s390x] |
| 2743 | os: [linux] | 2771 | os: [linux] |
| 2772 | libc: [glibc] | ||
| 2744 | 2773 | ||
| 2745 | '@img/sharp-linux-x64@0.34.5': | 2774 | '@img/sharp-linux-x64@0.34.5': |
| 2746 | resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} | 2775 | resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} |
| 2747 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | 2776 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2748 | cpu: [x64] | 2777 | cpu: [x64] |
| 2749 | os: [linux] | 2778 | os: [linux] |
| 2779 | libc: [glibc] | ||
| 2750 | 2780 | ||
| 2751 | '@img/sharp-linuxmusl-arm64@0.34.5': | 2781 | '@img/sharp-linuxmusl-arm64@0.34.5': |
| 2752 | resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} | 2782 | resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} |
| 2753 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | 2783 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2754 | cpu: [arm64] | 2784 | cpu: [arm64] |
| 2755 | os: [linux] | 2785 | os: [linux] |
| 2786 | libc: [musl] | ||
| 2756 | 2787 | ||
| 2757 | '@img/sharp-linuxmusl-x64@0.34.5': | 2788 | '@img/sharp-linuxmusl-x64@0.34.5': |
| 2758 | resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} | 2789 | resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} |
| 2759 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} | 2790 | engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} |
| 2760 | cpu: [x64] | 2791 | cpu: [x64] |
| 2761 | os: [linux] | 2792 | os: [linux] |
| 2793 | libc: [musl] | ||
| 2762 | 2794 | ||
| 2763 | '@img/sharp-wasm32@0.34.5': | 2795 | '@img/sharp-wasm32@0.34.5': |
| 2764 | resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} | 2796 | resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} |
| ... | @@ -3217,48 +3249,56 @@ packages: | ... | @@ -3217,48 +3249,56 @@ packages: |
| 3217 | engines: {node: ^20.19.0 || >=22.12.0} | 3249 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3218 | cpu: [arm64] | 3250 | cpu: [arm64] |
| 3219 | os: [linux] | 3251 | os: [linux] |
| 3252 | libc: [glibc] | ||
| 3220 | 3253 | ||
| 3221 | '@oxfmt/binding-linux-arm64-musl@0.55.0': | 3254 | '@oxfmt/binding-linux-arm64-musl@0.55.0': |
| 3222 | resolution: {integrity: sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA==} | 3255 | resolution: {integrity: sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA==} |
| 3223 | engines: {node: ^20.19.0 || >=22.12.0} | 3256 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3224 | cpu: [arm64] | 3257 | cpu: [arm64] |
| 3225 | os: [linux] | 3258 | os: [linux] |
| 3259 | libc: [musl] | ||
| 3226 | 3260 | ||
| 3227 | '@oxfmt/binding-linux-ppc64-gnu@0.55.0': | 3261 | '@oxfmt/binding-linux-ppc64-gnu@0.55.0': |
| 3228 | resolution: {integrity: sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ==} | 3262 | resolution: {integrity: sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ==} |
| 3229 | engines: {node: ^20.19.0 || >=22.12.0} | 3263 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3230 | cpu: [ppc64] | 3264 | cpu: [ppc64] |
| 3231 | os: [linux] | 3265 | os: [linux] |
| 3266 | libc: [glibc] | ||
| 3232 | 3267 | ||
| 3233 | '@oxfmt/binding-linux-riscv64-gnu@0.55.0': | 3268 | '@oxfmt/binding-linux-riscv64-gnu@0.55.0': |
| 3234 | resolution: {integrity: sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A==} | 3269 | resolution: {integrity: sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A==} |
| 3235 | engines: {node: ^20.19.0 || >=22.12.0} | 3270 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3236 | cpu: [riscv64] | 3271 | cpu: [riscv64] |
| 3237 | os: [linux] | 3272 | os: [linux] |
| 3273 | libc: [glibc] | ||
| 3238 | 3274 | ||
| 3239 | '@oxfmt/binding-linux-riscv64-musl@0.55.0': | 3275 | '@oxfmt/binding-linux-riscv64-musl@0.55.0': |
| 3240 | resolution: {integrity: sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA==} | 3276 | resolution: {integrity: sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA==} |
| 3241 | engines: {node: ^20.19.0 || >=22.12.0} | 3277 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3242 | cpu: [riscv64] | 3278 | cpu: [riscv64] |
| 3243 | os: [linux] | 3279 | os: [linux] |
| 3280 | libc: [musl] | ||
| 3244 | 3281 | ||
| 3245 | '@oxfmt/binding-linux-s390x-gnu@0.55.0': | 3282 | '@oxfmt/binding-linux-s390x-gnu@0.55.0': |
| 3246 | resolution: {integrity: sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ==} | 3283 | resolution: {integrity: sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ==} |
| 3247 | engines: {node: ^20.19.0 || >=22.12.0} | 3284 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3248 | cpu: [s390x] | 3285 | cpu: [s390x] |
| 3249 | os: [linux] | 3286 | os: [linux] |
| 3287 | libc: [glibc] | ||
| 3250 | 3288 | ||
| 3251 | '@oxfmt/binding-linux-x64-gnu@0.55.0': | 3289 | '@oxfmt/binding-linux-x64-gnu@0.55.0': |
| 3252 | resolution: {integrity: sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA==} | 3290 | resolution: {integrity: sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA==} |
| 3253 | engines: {node: ^20.19.0 || >=22.12.0} | 3291 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3254 | cpu: [x64] | 3292 | cpu: [x64] |
| 3255 | os: [linux] | 3293 | os: [linux] |
| 3294 | libc: [glibc] | ||
| 3256 | 3295 | ||
| 3257 | '@oxfmt/binding-linux-x64-musl@0.55.0': | 3296 | '@oxfmt/binding-linux-x64-musl@0.55.0': |
| 3258 | resolution: {integrity: sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g==} | 3297 | resolution: {integrity: sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g==} |
| 3259 | engines: {node: ^20.19.0 || >=22.12.0} | 3298 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3260 | cpu: [x64] | 3299 | cpu: [x64] |
| 3261 | os: [linux] | 3300 | os: [linux] |
| 3301 | libc: [musl] | ||
| 3262 | 3302 | ||
| 3263 | '@oxfmt/binding-openharmony-arm64@0.55.0': | 3303 | '@oxfmt/binding-openharmony-arm64@0.55.0': |
| 3264 | resolution: {integrity: sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg==} | 3304 | resolution: {integrity: sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg==} |
| ... | @@ -3361,48 +3401,56 @@ packages: | ... | @@ -3361,48 +3401,56 @@ packages: |
| 3361 | engines: {node: ^20.19.0 || >=22.12.0} | 3401 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3362 | cpu: [arm64] | 3402 | cpu: [arm64] |
| 3363 | os: [linux] | 3403 | os: [linux] |
| 3404 | libc: [glibc] | ||
| 3364 | 3405 | ||
| 3365 | '@oxlint/binding-linux-arm64-musl@1.70.0': | 3406 | '@oxlint/binding-linux-arm64-musl@1.70.0': |
| 3366 | resolution: {integrity: sha512-JiylyurlB0CLSedNtx1gzv3FvfWPF1h/2Y3BJszPLNt5XQFlBsH5ke0Jle3iJb3uqu5m2e7A/DwzpuCAHdiU+A==} | 3407 | resolution: {integrity: sha512-JiylyurlB0CLSedNtx1gzv3FvfWPF1h/2Y3BJszPLNt5XQFlBsH5ke0Jle3iJb3uqu5m2e7A/DwzpuCAHdiU+A==} |
| 3367 | engines: {node: ^20.19.0 || >=22.12.0} | 3408 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3368 | cpu: [arm64] | 3409 | cpu: [arm64] |
| 3369 | os: [linux] | 3410 | os: [linux] |
| 3411 | libc: [musl] | ||
| 3370 | 3412 | ||
| 3371 | '@oxlint/binding-linux-ppc64-gnu@1.70.0': | 3413 | '@oxlint/binding-linux-ppc64-gnu@1.70.0': |
| 3372 | resolution: {integrity: sha512-J8VPG7I3/HmgaU4u8pNU2kFx2+0U+vPLS1dXFxXOaR/2TQ0f8AC7DRz0SRGRI1bfphnX2hVYTTtLuhL4nYKL+Q==} | 3414 | resolution: {integrity: sha512-J8VPG7I3/HmgaU4u8pNU2kFx2+0U+vPLS1dXFxXOaR/2TQ0f8AC7DRz0SRGRI1bfphnX2hVYTTtLuhL4nYKL+Q==} |
| 3373 | engines: {node: ^20.19.0 || >=22.12.0} | 3415 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3374 | cpu: [ppc64] | 3416 | cpu: [ppc64] |
| 3375 | os: [linux] | 3417 | os: [linux] |
| 3418 | libc: [glibc] | ||
| 3376 | 3419 | ||
| 3377 | '@oxlint/binding-linux-riscv64-gnu@1.70.0': | 3420 | '@oxlint/binding-linux-riscv64-gnu@1.70.0': |
| 3378 | resolution: {integrity: sha512-N2+4lV2KLN+oXTIIIwmWDhwkrnvqf5oX7Hw0zPjk+RuIVgiBQSOlJWF7uQoFx2siEYX0ZQ5cfSbEAHm+J3t7Wg==} | 3421 | resolution: {integrity: sha512-N2+4lV2KLN+oXTIIIwmWDhwkrnvqf5oX7Hw0zPjk+RuIVgiBQSOlJWF7uQoFx2siEYX0ZQ5cfSbEAHm+J3t7Wg==} |
| 3379 | engines: {node: ^20.19.0 || >=22.12.0} | 3422 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3380 | cpu: [riscv64] | 3423 | cpu: [riscv64] |
| 3381 | os: [linux] | 3424 | os: [linux] |
| 3425 | libc: [glibc] | ||
| 3382 | 3426 | ||
| 3383 | '@oxlint/binding-linux-riscv64-musl@1.70.0': | 3427 | '@oxlint/binding-linux-riscv64-musl@1.70.0': |
| 3384 | resolution: {integrity: sha512-1e2L7cFCvx9QDzq6NPP+0tABKb5z6nWHyddWTNKprEsjO9xNrAtPowuCGpjNXxkTdsMiZ4jc8YQ5SstZd4XK6g==} | 3428 | resolution: {integrity: sha512-1e2L7cFCvx9QDzq6NPP+0tABKb5z6nWHyddWTNKprEsjO9xNrAtPowuCGpjNXxkTdsMiZ4jc8YQ5SstZd4XK6g==} |
| 3385 | engines: {node: ^20.19.0 || >=22.12.0} | 3429 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3386 | cpu: [riscv64] | 3430 | cpu: [riscv64] |
| 3387 | os: [linux] | 3431 | os: [linux] |
| 3432 | libc: [musl] | ||
| 3388 | 3433 | ||
| 3389 | '@oxlint/binding-linux-s390x-gnu@1.70.0': | 3434 | '@oxlint/binding-linux-s390x-gnu@1.70.0': |
| 3390 | resolution: {integrity: sha512-Kwu/l/8GcYibCWA9m9N5pRXMIKVSsL/YbgpLzYkqDhWTiqdRfnNJ/+nqIKRKQiFbHWsdlHEhzMwruJK+qcEruA==} | 3435 | resolution: {integrity: sha512-Kwu/l/8GcYibCWA9m9N5pRXMIKVSsL/YbgpLzYkqDhWTiqdRfnNJ/+nqIKRKQiFbHWsdlHEhzMwruJK+qcEruA==} |
| 3391 | engines: {node: ^20.19.0 || >=22.12.0} | 3436 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3392 | cpu: [s390x] | 3437 | cpu: [s390x] |
| 3393 | os: [linux] | 3438 | os: [linux] |
| 3439 | libc: [glibc] | ||
| 3394 | 3440 | ||
| 3395 | '@oxlint/binding-linux-x64-gnu@1.70.0': | 3441 | '@oxlint/binding-linux-x64-gnu@1.70.0': |
| 3396 | resolution: {integrity: sha512-tap04CsHYOl0nSAQJfPNIuBxqEPB2HnhQqwaOXLg1jnp2XfRo8Fa814dA4QC4zpvTWXCjAAaCY1W5LOORkEQuQ==} | 3442 | resolution: {integrity: sha512-tap04CsHYOl0nSAQJfPNIuBxqEPB2HnhQqwaOXLg1jnp2XfRo8Fa814dA4QC4zpvTWXCjAAaCY1W5LOORkEQuQ==} |
| 3397 | engines: {node: ^20.19.0 || >=22.12.0} | 3443 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3398 | cpu: [x64] | 3444 | cpu: [x64] |
| 3399 | os: [linux] | 3445 | os: [linux] |
| 3446 | libc: [glibc] | ||
| 3400 | 3447 | ||
| 3401 | '@oxlint/binding-linux-x64-musl@1.70.0': | 3448 | '@oxlint/binding-linux-x64-musl@1.70.0': |
| 3402 | resolution: {integrity: sha512-hzJa/WgvtJpbBD9rgfy0qe+MjbxOXNUT0bfR1S6EQQzfTtBFA9xg5q8KSwRrQ2QfSS+TaP4j+4mVPQrfNc6UNg==} | 3449 | resolution: {integrity: sha512-hzJa/WgvtJpbBD9rgfy0qe+MjbxOXNUT0bfR1S6EQQzfTtBFA9xg5q8KSwRrQ2QfSS+TaP4j+4mVPQrfNc6UNg==} |
| 3403 | engines: {node: ^20.19.0 || >=22.12.0} | 3450 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3404 | cpu: [x64] | 3451 | cpu: [x64] |
| 3405 | os: [linux] | 3452 | os: [linux] |
| 3453 | libc: [musl] | ||
| 3406 | 3454 | ||
| 3407 | '@oxlint/binding-openharmony-arm64@1.70.0': | 3455 | '@oxlint/binding-openharmony-arm64@1.70.0': |
| 3408 | resolution: {integrity: sha512-xbsaNSNzVSnaJACCUYr1HQMyY/Q/Q1LkePmHG3UvZPvGCYGNxrsZp9OmtA6ick8xH47ltRRbRrPCM1YXYcyC+A==} | 3456 | resolution: {integrity: sha512-xbsaNSNzVSnaJACCUYr1HQMyY/Q/Q1LkePmHG3UvZPvGCYGNxrsZp9OmtA6ick8xH47ltRRbRrPCM1YXYcyC+A==} |
| ... | @@ -3889,108 +3937,126 @@ packages: | ... | @@ -3889,108 +3937,126 @@ packages: |
| 3889 | engines: {node: ^20.19.0 || >=22.12.0} | 3937 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3890 | cpu: [arm64] | 3938 | cpu: [arm64] |
| 3891 | os: [linux] | 3939 | os: [linux] |
| 3940 | libc: [glibc] | ||
| 3892 | 3941 | ||
| 3893 | '@rolldown/binding-linux-arm64-gnu@1.0.1': | 3942 | '@rolldown/binding-linux-arm64-gnu@1.0.1': |
| 3894 | resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} | 3943 | resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} |
| 3895 | engines: {node: ^20.19.0 || >=22.12.0} | 3944 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3896 | cpu: [arm64] | 3945 | cpu: [arm64] |
| 3897 | os: [linux] | 3946 | os: [linux] |
| 3947 | libc: [glibc] | ||
| 3898 | 3948 | ||
| 3899 | '@rolldown/binding-linux-arm64-gnu@1.1.3': | 3949 | '@rolldown/binding-linux-arm64-gnu@1.1.3': |
| 3900 | resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} | 3950 | resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} |
| 3901 | engines: {node: ^20.19.0 || >=22.12.0} | 3951 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3902 | cpu: [arm64] | 3952 | cpu: [arm64] |
| 3903 | os: [linux] | 3953 | os: [linux] |
| 3954 | libc: [glibc] | ||
| 3904 | 3955 | ||
| 3905 | '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': | 3956 | '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': |
| 3906 | resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} | 3957 | resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} |
| 3907 | engines: {node: ^20.19.0 || >=22.12.0} | 3958 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3908 | cpu: [arm64] | 3959 | cpu: [arm64] |
| 3909 | os: [linux] | 3960 | os: [linux] |
| 3961 | libc: [musl] | ||
| 3910 | 3962 | ||
| 3911 | '@rolldown/binding-linux-arm64-musl@1.0.1': | 3963 | '@rolldown/binding-linux-arm64-musl@1.0.1': |
| 3912 | resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} | 3964 | resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} |
| 3913 | engines: {node: ^20.19.0 || >=22.12.0} | 3965 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3914 | cpu: [arm64] | 3966 | cpu: [arm64] |
| 3915 | os: [linux] | 3967 | os: [linux] |
| 3968 | libc: [musl] | ||
| 3916 | 3969 | ||
| 3917 | '@rolldown/binding-linux-arm64-musl@1.1.3': | 3970 | '@rolldown/binding-linux-arm64-musl@1.1.3': |
| 3918 | resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} | 3971 | resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} |
| 3919 | engines: {node: ^20.19.0 || >=22.12.0} | 3972 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3920 | cpu: [arm64] | 3973 | cpu: [arm64] |
| 3921 | os: [linux] | 3974 | os: [linux] |
| 3975 | libc: [musl] | ||
| 3922 | 3976 | ||
| 3923 | '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': | 3977 | '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': |
| 3924 | resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} | 3978 | resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} |
| 3925 | engines: {node: ^20.19.0 || >=22.12.0} | 3979 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3926 | cpu: [ppc64] | 3980 | cpu: [ppc64] |
| 3927 | os: [linux] | 3981 | os: [linux] |
| 3982 | libc: [glibc] | ||
| 3928 | 3983 | ||
| 3929 | '@rolldown/binding-linux-ppc64-gnu@1.0.1': | 3984 | '@rolldown/binding-linux-ppc64-gnu@1.0.1': |
| 3930 | resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} | 3985 | resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} |
| 3931 | engines: {node: ^20.19.0 || >=22.12.0} | 3986 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3932 | cpu: [ppc64] | 3987 | cpu: [ppc64] |
| 3933 | os: [linux] | 3988 | os: [linux] |
| 3989 | libc: [glibc] | ||
| 3934 | 3990 | ||
| 3935 | '@rolldown/binding-linux-ppc64-gnu@1.1.3': | 3991 | '@rolldown/binding-linux-ppc64-gnu@1.1.3': |
| 3936 | resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} | 3992 | resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} |
| 3937 | engines: {node: ^20.19.0 || >=22.12.0} | 3993 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3938 | cpu: [ppc64] | 3994 | cpu: [ppc64] |
| 3939 | os: [linux] | 3995 | os: [linux] |
| 3996 | libc: [glibc] | ||
| 3940 | 3997 | ||
| 3941 | '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': | 3998 | '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': |
| 3942 | resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} | 3999 | resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} |
| 3943 | engines: {node: ^20.19.0 || >=22.12.0} | 4000 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3944 | cpu: [s390x] | 4001 | cpu: [s390x] |
| 3945 | os: [linux] | 4002 | os: [linux] |
| 4003 | libc: [glibc] | ||
| 3946 | 4004 | ||
| 3947 | '@rolldown/binding-linux-s390x-gnu@1.0.1': | 4005 | '@rolldown/binding-linux-s390x-gnu@1.0.1': |
| 3948 | resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} | 4006 | resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} |
| 3949 | engines: {node: ^20.19.0 || >=22.12.0} | 4007 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3950 | cpu: [s390x] | 4008 | cpu: [s390x] |
| 3951 | os: [linux] | 4009 | os: [linux] |
| 4010 | libc: [glibc] | ||
| 3952 | 4011 | ||
| 3953 | '@rolldown/binding-linux-s390x-gnu@1.1.3': | 4012 | '@rolldown/binding-linux-s390x-gnu@1.1.3': |
| 3954 | resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} | 4013 | resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} |
| 3955 | engines: {node: ^20.19.0 || >=22.12.0} | 4014 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3956 | cpu: [s390x] | 4015 | cpu: [s390x] |
| 3957 | os: [linux] | 4016 | os: [linux] |
| 4017 | libc: [glibc] | ||
| 3958 | 4018 | ||
| 3959 | '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': | 4019 | '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': |
| 3960 | resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} | 4020 | resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} |
| 3961 | engines: {node: ^20.19.0 || >=22.12.0} | 4021 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3962 | cpu: [x64] | 4022 | cpu: [x64] |
| 3963 | os: [linux] | 4023 | os: [linux] |
| 4024 | libc: [glibc] | ||
| 3964 | 4025 | ||
| 3965 | '@rolldown/binding-linux-x64-gnu@1.0.1': | 4026 | '@rolldown/binding-linux-x64-gnu@1.0.1': |
| 3966 | resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} | 4027 | resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} |
| 3967 | engines: {node: ^20.19.0 || >=22.12.0} | 4028 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3968 | cpu: [x64] | 4029 | cpu: [x64] |
| 3969 | os: [linux] | 4030 | os: [linux] |
| 4031 | libc: [glibc] | ||
| 3970 | 4032 | ||
| 3971 | '@rolldown/binding-linux-x64-gnu@1.1.3': | 4033 | '@rolldown/binding-linux-x64-gnu@1.1.3': |
| 3972 | resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} | 4034 | resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} |
| 3973 | engines: {node: ^20.19.0 || >=22.12.0} | 4035 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3974 | cpu: [x64] | 4036 | cpu: [x64] |
| 3975 | os: [linux] | 4037 | os: [linux] |
| 4038 | libc: [glibc] | ||
| 3976 | 4039 | ||
| 3977 | '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': | 4040 | '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': |
| 3978 | resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} | 4041 | resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} |
| 3979 | engines: {node: ^20.19.0 || >=22.12.0} | 4042 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3980 | cpu: [x64] | 4043 | cpu: [x64] |
| 3981 | os: [linux] | 4044 | os: [linux] |
| 4045 | libc: [musl] | ||
| 3982 | 4046 | ||
| 3983 | '@rolldown/binding-linux-x64-musl@1.0.1': | 4047 | '@rolldown/binding-linux-x64-musl@1.0.1': |
| 3984 | resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} | 4048 | resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} |
| 3985 | engines: {node: ^20.19.0 || >=22.12.0} | 4049 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3986 | cpu: [x64] | 4050 | cpu: [x64] |
| 3987 | os: [linux] | 4051 | os: [linux] |
| 4052 | libc: [musl] | ||
| 3988 | 4053 | ||
| 3989 | '@rolldown/binding-linux-x64-musl@1.1.3': | 4054 | '@rolldown/binding-linux-x64-musl@1.1.3': |
| 3990 | resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} | 4055 | resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} |
| 3991 | engines: {node: ^20.19.0 || >=22.12.0} | 4056 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3992 | cpu: [x64] | 4057 | cpu: [x64] |
| 3993 | os: [linux] | 4058 | os: [linux] |
| 4059 | libc: [musl] | ||
| 3994 | 4060 | ||
| 3995 | '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': | 4061 | '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': |
| 3996 | resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} | 4062 | resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} |
| ... | @@ -4127,66 +4193,79 @@ packages: | ... | @@ -4127,66 +4193,79 @@ packages: |
| 4127 | resolution: {integrity: sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==} | 4193 | resolution: {integrity: sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==} |
| 4128 | cpu: [arm] | 4194 | cpu: [arm] |
| 4129 | os: [linux] | 4195 | os: [linux] |
| 4196 | libc: [glibc] | ||
| 4130 | 4197 | ||
| 4131 | '@rollup/rollup-linux-arm-musleabihf@4.61.0': | 4198 | '@rollup/rollup-linux-arm-musleabihf@4.61.0': |
| 4132 | resolution: {integrity: sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==} | 4199 | resolution: {integrity: sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==} |
| 4133 | cpu: [arm] | 4200 | cpu: [arm] |
| 4134 | os: [linux] | 4201 | os: [linux] |
| 4202 | libc: [musl] | ||
| 4135 | 4203 | ||
| 4136 | '@rollup/rollup-linux-arm64-gnu@4.61.0': | 4204 | '@rollup/rollup-linux-arm64-gnu@4.61.0': |
| 4137 | resolution: {integrity: sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==} | 4205 | resolution: {integrity: sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==} |
| 4138 | cpu: [arm64] | 4206 | cpu: [arm64] |
| 4139 | os: [linux] | 4207 | os: [linux] |
| 4208 | libc: [glibc] | ||
| 4140 | 4209 | ||
| 4141 | '@rollup/rollup-linux-arm64-musl@4.61.0': | 4210 | '@rollup/rollup-linux-arm64-musl@4.61.0': |
| 4142 | resolution: {integrity: sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==} | 4211 | resolution: {integrity: sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==} |
| 4143 | cpu: [arm64] | 4212 | cpu: [arm64] |
| 4144 | os: [linux] | 4213 | os: [linux] |
| 4214 | libc: [musl] | ||
| 4145 | 4215 | ||
| 4146 | '@rollup/rollup-linux-loong64-gnu@4.61.0': | 4216 | '@rollup/rollup-linux-loong64-gnu@4.61.0': |
| 4147 | resolution: {integrity: sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==} | 4217 | resolution: {integrity: sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==} |
| 4148 | cpu: [loong64] | 4218 | cpu: [loong64] |
| 4149 | os: [linux] | 4219 | os: [linux] |
| 4220 | libc: [glibc] | ||
| 4150 | 4221 | ||
| 4151 | '@rollup/rollup-linux-loong64-musl@4.61.0': | 4222 | '@rollup/rollup-linux-loong64-musl@4.61.0': |
| 4152 | resolution: {integrity: sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==} | 4223 | resolution: {integrity: sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==} |
| 4153 | cpu: [loong64] | 4224 | cpu: [loong64] |
| 4154 | os: [linux] | 4225 | os: [linux] |
| 4226 | libc: [musl] | ||
| 4155 | 4227 | ||
| 4156 | '@rollup/rollup-linux-ppc64-gnu@4.61.0': | 4228 | '@rollup/rollup-linux-ppc64-gnu@4.61.0': |
| 4157 | resolution: {integrity: sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==} | 4229 | resolution: {integrity: sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==} |
| 4158 | cpu: [ppc64] | 4230 | cpu: [ppc64] |
| 4159 | os: [linux] | 4231 | os: [linux] |
| 4232 | libc: [glibc] | ||
| 4160 | 4233 | ||
| 4161 | '@rollup/rollup-linux-ppc64-musl@4.61.0': | 4234 | '@rollup/rollup-linux-ppc64-musl@4.61.0': |
| 4162 | resolution: {integrity: sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==} | 4235 | resolution: {integrity: sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==} |
| 4163 | cpu: [ppc64] | 4236 | cpu: [ppc64] |
| 4164 | os: [linux] | 4237 | os: [linux] |
| 4238 | libc: [musl] | ||
| 4165 | 4239 | ||
| 4166 | '@rollup/rollup-linux-riscv64-gnu@4.61.0': | 4240 | '@rollup/rollup-linux-riscv64-gnu@4.61.0': |
| 4167 | resolution: {integrity: sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==} | 4241 | resolution: {integrity: sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==} |
| 4168 | cpu: [riscv64] | 4242 | cpu: [riscv64] |
| 4169 | os: [linux] | 4243 | os: [linux] |
| 4244 | libc: [glibc] | ||
| 4170 | 4245 | ||
| 4171 | '@rollup/rollup-linux-riscv64-musl@4.61.0': | 4246 | '@rollup/rollup-linux-riscv64-musl@4.61.0': |
| 4172 | resolution: {integrity: sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==} | 4247 | resolution: {integrity: sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==} |
| 4173 | cpu: [riscv64] | 4248 | cpu: [riscv64] |
| 4174 | os: [linux] | 4249 | os: [linux] |
| 4250 | libc: [musl] | ||
| 4175 | 4251 | ||
| 4176 | '@rollup/rollup-linux-s390x-gnu@4.61.0': | 4252 | '@rollup/rollup-linux-s390x-gnu@4.61.0': |
| 4177 | resolution: {integrity: sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==} | 4253 | resolution: {integrity: sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==} |
| 4178 | cpu: [s390x] | 4254 | cpu: [s390x] |
| 4179 | os: [linux] | 4255 | os: [linux] |
| 4256 | libc: [glibc] | ||
| 4180 | 4257 | ||
| 4181 | '@rollup/rollup-linux-x64-gnu@4.61.0': | 4258 | '@rollup/rollup-linux-x64-gnu@4.61.0': |
| 4182 | resolution: {integrity: sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==} | 4259 | resolution: {integrity: sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==} |
| 4183 | cpu: [x64] | 4260 | cpu: [x64] |
| 4184 | os: [linux] | 4261 | os: [linux] |
| 4262 | libc: [glibc] | ||
| 4185 | 4263 | ||
| 4186 | '@rollup/rollup-linux-x64-musl@4.61.0': | 4264 | '@rollup/rollup-linux-x64-musl@4.61.0': |
| 4187 | resolution: {integrity: sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==} | 4265 | resolution: {integrity: sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==} |
| 4188 | cpu: [x64] | 4266 | cpu: [x64] |
| 4189 | os: [linux] | 4267 | os: [linux] |
| 4268 | libc: [musl] | ||
| 4190 | 4269 | ||
| 4191 | '@rollup/rollup-openbsd-x64@4.61.0': | 4270 | '@rollup/rollup-openbsd-x64@4.61.0': |
| 4192 | resolution: {integrity: sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==} | 4271 | resolution: {integrity: sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==} |
| ... | @@ -4421,48 +4500,56 @@ packages: | ... | @@ -4421,48 +4500,56 @@ packages: |
| 4421 | engines: {node: '>= 20'} | 4500 | engines: {node: '>= 20'} |
| 4422 | cpu: [arm64] | 4501 | cpu: [arm64] |
| 4423 | os: [linux] | 4502 | os: [linux] |
| 4503 | libc: [glibc] | ||
| 4424 | 4504 | ||
| 4425 | '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': | 4505 | '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': |
| 4426 | resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} | 4506 | resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} |
| 4427 | engines: {node: '>= 20'} | 4507 | engines: {node: '>= 20'} |
| 4428 | cpu: [arm64] | 4508 | cpu: [arm64] |
| 4429 | os: [linux] | 4509 | os: [linux] |
| 4510 | libc: [glibc] | ||
| 4430 | 4511 | ||
| 4431 | '@tailwindcss/oxide-linux-arm64-musl@4.2.1': | 4512 | '@tailwindcss/oxide-linux-arm64-musl@4.2.1': |
| 4432 | resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} | 4513 | resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} |
| 4433 | engines: {node: '>= 20'} | 4514 | engines: {node: '>= 20'} |
| 4434 | cpu: [arm64] | 4515 | cpu: [arm64] |
| 4435 | os: [linux] | 4516 | os: [linux] |
| 4517 | libc: [musl] | ||
| 4436 | 4518 | ||
| 4437 | '@tailwindcss/oxide-linux-arm64-musl@4.3.0': | 4519 | '@tailwindcss/oxide-linux-arm64-musl@4.3.0': |
| 4438 | resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} | 4520 | resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} |
| 4439 | engines: {node: '>= 20'} | 4521 | engines: {node: '>= 20'} |
| 4440 | cpu: [arm64] | 4522 | cpu: [arm64] |
| 4441 | os: [linux] | 4523 | os: [linux] |
| 4524 | libc: [musl] | ||
| 4442 | 4525 | ||
| 4443 | '@tailwindcss/oxide-linux-x64-gnu@4.2.1': | 4526 | '@tailwindcss/oxide-linux-x64-gnu@4.2.1': |
| 4444 | resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} | 4527 | resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} |
| 4445 | engines: {node: '>= 20'} | 4528 | engines: {node: '>= 20'} |
| 4446 | cpu: [x64] | 4529 | cpu: [x64] |
| 4447 | os: [linux] | 4530 | os: [linux] |
| 4531 | libc: [glibc] | ||
| 4448 | 4532 | ||
| 4449 | '@tailwindcss/oxide-linux-x64-gnu@4.3.0': | 4533 | '@tailwindcss/oxide-linux-x64-gnu@4.3.0': |
| 4450 | resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} | 4534 | resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} |
| 4451 | engines: {node: '>= 20'} | 4535 | engines: {node: '>= 20'} |
| 4452 | cpu: [x64] | 4536 | cpu: [x64] |
| 4453 | os: [linux] | 4537 | os: [linux] |
| 4538 | libc: [glibc] | ||
| 4454 | 4539 | ||
| 4455 | '@tailwindcss/oxide-linux-x64-musl@4.2.1': | 4540 | '@tailwindcss/oxide-linux-x64-musl@4.2.1': |
| 4456 | resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} | 4541 | resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} |
| 4457 | engines: {node: '>= 20'} | 4542 | engines: {node: '>= 20'} |
| 4458 | cpu: [x64] | 4543 | cpu: [x64] |
| 4459 | os: [linux] | 4544 | os: [linux] |
| 4545 | libc: [musl] | ||
| 4460 | 4546 | ||
| 4461 | '@tailwindcss/oxide-linux-x64-musl@4.3.0': | 4547 | '@tailwindcss/oxide-linux-x64-musl@4.3.0': |
| 4462 | resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} | 4548 | resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} |
| 4463 | engines: {node: '>= 20'} | 4549 | engines: {node: '>= 20'} |
| 4464 | cpu: [x64] | 4550 | cpu: [x64] |
| 4465 | os: [linux] | 4551 | os: [linux] |
| 4552 | libc: [musl] | ||
| 4466 | 4553 | ||
| 4467 | '@tailwindcss/oxide-wasm32-wasi@4.2.1': | 4554 | '@tailwindcss/oxide-wasm32-wasi@4.2.1': |
| 4468 | resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} | 4555 | resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} |
| ... | @@ -4935,24 +5022,28 @@ packages: | ... | @@ -4935,24 +5022,28 @@ packages: |
| 4935 | engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} | 5022 | engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} |
| 4936 | cpu: [arm64] | 5023 | cpu: [arm64] |
| 4937 | os: [linux] | 5024 | os: [linux] |
| 5025 | libc: [glibc] | ||
| 4938 | 5026 | ||
| 4939 | '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.1': | 5027 | '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.1': |
| 4940 | resolution: {integrity: sha512-vUY7hYycZW0qEevpl7ImzZJFnOEKRYCaCOX4TBW0vk6MJZ+zj/xW7e0LOggzJcz2wbYAgLDqp5h+b8wV9dguDA==} | 5028 | resolution: {integrity: sha512-vUY7hYycZW0qEevpl7ImzZJFnOEKRYCaCOX4TBW0vk6MJZ+zj/xW7e0LOggzJcz2wbYAgLDqp5h+b8wV9dguDA==} |
| 4941 | engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} | 5029 | engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} |
| 4942 | cpu: [arm64] | 5030 | cpu: [arm64] |
| 4943 | os: [linux] | 5031 | os: [linux] |
| 5032 | libc: [musl] | ||
| 4944 | 5033 | ||
| 4945 | '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.1': | 5034 | '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.1': |
| 4946 | resolution: {integrity: sha512-tFxpToEaykBGxMQHp8M/qmr1yruRRED+c9gA1h9kmplqot04OxuqzRCWu/IiIvMJ0v3JFdOP3gqkyjXLLJhxIA==} | 5035 | resolution: {integrity: sha512-tFxpToEaykBGxMQHp8M/qmr1yruRRED+c9gA1h9kmplqot04OxuqzRCWu/IiIvMJ0v3JFdOP3gqkyjXLLJhxIA==} |
| 4947 | engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} | 5036 | engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} |
| 4948 | cpu: [x64] | 5037 | cpu: [x64] |
| 4949 | os: [linux] | 5038 | os: [linux] |
| 5039 | libc: [glibc] | ||
| 4950 | 5040 | ||
| 4951 | '@voidzero-dev/vite-plus-linux-x64-musl@0.2.1': | 5041 | '@voidzero-dev/vite-plus-linux-x64-musl@0.2.1': |
| 4952 | resolution: {integrity: sha512-2scSS7wEbLO2758fqr1/bAULg7nLCFa5V8LO2b5w3g1CrTYdMTDt2WX1ghPesIi+70pYGydRbXo6iaaN43zfMg==} | 5042 | resolution: {integrity: sha512-2scSS7wEbLO2758fqr1/bAULg7nLCFa5V8LO2b5w3g1CrTYdMTDt2WX1ghPesIi+70pYGydRbXo6iaaN43zfMg==} |
| 4953 | engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} | 5043 | engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} |
| 4954 | cpu: [x64] | 5044 | cpu: [x64] |
| 4955 | os: [linux] | 5045 | os: [linux] |
| 5046 | libc: [musl] | ||
| 4956 | 5047 | ||
| 4957 | '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.1': | 5048 | '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.1': |
| 4958 | resolution: {integrity: sha512-3+5FJYhi9SqBszjngI2LBmvoiqEwxJWyQ5UsOUtNz6/d+yDrDw+tOgHLl4OKIh5aVNZeIGXzxvP6h24kcEqIyg==} | 5049 | resolution: {integrity: sha512-3+5FJYhi9SqBszjngI2LBmvoiqEwxJWyQ5UsOUtNz6/d+yDrDw+tOgHLl4OKIh5aVNZeIGXzxvP6h24kcEqIyg==} |
| ... | @@ -5035,24 +5126,28 @@ packages: | ... | @@ -5035,24 +5126,28 @@ packages: |
| 5035 | engines: {node: '>= 12'} | 5126 | engines: {node: '>= 12'} |
| 5036 | cpu: [arm64] | 5127 | cpu: [arm64] |
| 5037 | os: [linux] | 5128 | os: [linux] |
| 5129 | libc: [glibc] | ||
| 5038 | 5130 | ||
| 5039 | '@yuuang/ffi-rs-linux-arm64-musl@1.3.2': | 5131 | '@yuuang/ffi-rs-linux-arm64-musl@1.3.2': |
| 5040 | resolution: {integrity: sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA==} | 5132 | resolution: {integrity: sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA==} |
| 5041 | engines: {node: '>= 12'} | 5133 | engines: {node: '>= 12'} |
| 5042 | cpu: [arm64] | 5134 | cpu: [arm64] |
| 5043 | os: [linux] | 5135 | os: [linux] |
| 5136 | libc: [musl] | ||
| 5044 | 5137 | ||
| 5045 | '@yuuang/ffi-rs-linux-x64-gnu@1.3.2': | 5138 | '@yuuang/ffi-rs-linux-x64-gnu@1.3.2': |
| 5046 | resolution: {integrity: sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g==} | 5139 | resolution: {integrity: sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g==} |
| 5047 | engines: {node: '>= 12'} | 5140 | engines: {node: '>= 12'} |
| 5048 | cpu: [x64] | 5141 | cpu: [x64] |
| 5049 | os: [linux] | 5142 | os: [linux] |
| 5143 | libc: [glibc] | ||
| 5050 | 5144 | ||
| 5051 | '@yuuang/ffi-rs-linux-x64-musl@1.3.2': | 5145 | '@yuuang/ffi-rs-linux-x64-musl@1.3.2': |
| 5052 | resolution: {integrity: sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w==} | 5146 | resolution: {integrity: sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w==} |
| 5053 | engines: {node: '>= 12'} | 5147 | engines: {node: '>= 12'} |
| 5054 | cpu: [x64] | 5148 | cpu: [x64] |
| 5055 | os: [linux] | 5149 | os: [linux] |
| 5150 | libc: [glibc] | ||
| 5056 | 5151 | ||
| 5057 | '@yuuang/ffi-rs-win32-arm64-msvc@1.3.2': | 5152 | '@yuuang/ffi-rs-win32-arm64-msvc@1.3.2': |
| 5058 | resolution: {integrity: sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg==} | 5153 | resolution: {integrity: sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg==} |
| ... | @@ -7416,72 +7511,84 @@ packages: | ... | @@ -7416,72 +7511,84 @@ packages: |
| 7416 | engines: {node: '>= 12.0.0'} | 7511 | engines: {node: '>= 12.0.0'} |
| 7417 | cpu: [arm64] | 7512 | cpu: [arm64] |
| 7418 | os: [linux] | 7513 | os: [linux] |
| 7514 | libc: [glibc] | ||
| 7419 | 7515 | ||
| 7420 | lightningcss-linux-arm64-gnu@1.31.1: | 7516 | lightningcss-linux-arm64-gnu@1.31.1: |
| 7421 | resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} | 7517 | resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} |
| 7422 | engines: {node: '>= 12.0.0'} | 7518 | engines: {node: '>= 12.0.0'} |
| 7423 | cpu: [arm64] | 7519 | cpu: [arm64] |
| 7424 | os: [linux] | 7520 | os: [linux] |
| 7521 | libc: [glibc] | ||
| 7425 | 7522 | ||
| 7426 | lightningcss-linux-arm64-gnu@1.32.0: | 7523 | lightningcss-linux-arm64-gnu@1.32.0: |
| 7427 | resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} | 7524 | resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} |
| 7428 | engines: {node: '>= 12.0.0'} | 7525 | engines: {node: '>= 12.0.0'} |
| 7429 | cpu: [arm64] | 7526 | cpu: [arm64] |
| 7430 | os: [linux] | 7527 | os: [linux] |
| 7528 | libc: [glibc] | ||
| 7431 | 7529 | ||
| 7432 | lightningcss-linux-arm64-musl@1.30.1: | 7530 | lightningcss-linux-arm64-musl@1.30.1: |
| 7433 | resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} | 7531 | resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} |
| 7434 | engines: {node: '>= 12.0.0'} | 7532 | engines: {node: '>= 12.0.0'} |
| 7435 | cpu: [arm64] | 7533 | cpu: [arm64] |
| 7436 | os: [linux] | 7534 | os: [linux] |
| 7535 | libc: [musl] | ||
| 7437 | 7536 | ||
| 7438 | lightningcss-linux-arm64-musl@1.31.1: | 7537 | lightningcss-linux-arm64-musl@1.31.1: |
| 7439 | resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} | 7538 | resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} |
| 7440 | engines: {node: '>= 12.0.0'} | 7539 | engines: {node: '>= 12.0.0'} |
| 7441 | cpu: [arm64] | 7540 | cpu: [arm64] |
| 7442 | os: [linux] | 7541 | os: [linux] |
| 7542 | libc: [musl] | ||
| 7443 | 7543 | ||
| 7444 | lightningcss-linux-arm64-musl@1.32.0: | 7544 | lightningcss-linux-arm64-musl@1.32.0: |
| 7445 | resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} | 7545 | resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} |
| 7446 | engines: {node: '>= 12.0.0'} | 7546 | engines: {node: '>= 12.0.0'} |
| 7447 | cpu: [arm64] | 7547 | cpu: [arm64] |
| 7448 | os: [linux] | 7548 | os: [linux] |
| 7549 | libc: [musl] | ||
| 7449 | 7550 | ||
| 7450 | lightningcss-linux-x64-gnu@1.30.1: | 7551 | lightningcss-linux-x64-gnu@1.30.1: |
| 7451 | resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} | 7552 | resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} |
| 7452 | engines: {node: '>= 12.0.0'} | 7553 | engines: {node: '>= 12.0.0'} |
| 7453 | cpu: [x64] | 7554 | cpu: [x64] |
| 7454 | os: [linux] | 7555 | os: [linux] |
| 7556 | libc: [glibc] | ||
| 7455 | 7557 | ||
| 7456 | lightningcss-linux-x64-gnu@1.31.1: | 7558 | lightningcss-linux-x64-gnu@1.31.1: |
| 7457 | resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} | 7559 | resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} |
| 7458 | engines: {node: '>= 12.0.0'} | 7560 | engines: {node: '>= 12.0.0'} |
| 7459 | cpu: [x64] | 7561 | cpu: [x64] |
| 7460 | os: [linux] | 7562 | os: [linux] |
| 7563 | libc: [glibc] | ||
| 7461 | 7564 | ||
| 7462 | lightningcss-linux-x64-gnu@1.32.0: | 7565 | lightningcss-linux-x64-gnu@1.32.0: |
| 7463 | resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} | 7566 | resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} |
| 7464 | engines: {node: '>= 12.0.0'} | 7567 | engines: {node: '>= 12.0.0'} |
| 7465 | cpu: [x64] | 7568 | cpu: [x64] |
| 7466 | os: [linux] | 7569 | os: [linux] |
| 7570 | libc: [glibc] | ||
| 7467 | 7571 | ||
| 7468 | lightningcss-linux-x64-musl@1.30.1: | 7572 | lightningcss-linux-x64-musl@1.30.1: |
| 7469 | resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} | 7573 | resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} |
| 7470 | engines: {node: '>= 12.0.0'} | 7574 | engines: {node: '>= 12.0.0'} |
| 7471 | cpu: [x64] | 7575 | cpu: [x64] |
| 7472 | os: [linux] | 7576 | os: [linux] |
| 7577 | libc: [musl] | ||
| 7473 | 7578 | ||
| 7474 | lightningcss-linux-x64-musl@1.31.1: | 7579 | lightningcss-linux-x64-musl@1.31.1: |
| 7475 | resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} | 7580 | resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} |
| 7476 | engines: {node: '>= 12.0.0'} | 7581 | engines: {node: '>= 12.0.0'} |
| 7477 | cpu: [x64] | 7582 | cpu: [x64] |
| 7478 | os: [linux] | 7583 | os: [linux] |
| 7584 | libc: [musl] | ||
| 7479 | 7585 | ||
| 7480 | lightningcss-linux-x64-musl@1.32.0: | 7586 | lightningcss-linux-x64-musl@1.32.0: |
| 7481 | resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} | 7587 | resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} |
| 7482 | engines: {node: '>= 12.0.0'} | 7588 | engines: {node: '>= 12.0.0'} |
| 7483 | cpu: [x64] | 7589 | cpu: [x64] |
| 7484 | os: [linux] | 7590 | os: [linux] |
| 7591 | libc: [musl] | ||
| 7485 | 7592 | ||
| 7486 | lightningcss-win32-arm64-msvc@1.30.1: | 7593 | lightningcss-win32-arm64-msvc@1.30.1: |
| 7487 | resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} | 7594 | resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} |
| ... | @@ -14269,19 +14376,19 @@ snapshots: | ... | @@ -14269,19 +14376,19 @@ snapshots: |
| 14269 | '@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) | 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 | babel-plugin-react-compiler: 1.0.0 | 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 | dependencies: | 14380 | dependencies: |
| 14274 | '@testing-library/dom': 10.4.1 | 14381 | '@testing-library/dom': 10.4.1 |
| 14275 | '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) | 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))) | 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) |
| 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)) | 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 | transitivePeerDependencies: | 14385 | transitivePeerDependencies: |
| 14279 | - bufferutil | 14386 | - bufferutil |
| 14280 | - msw | 14387 | - msw |
| 14281 | - utf-8-validate | 14388 | - utf-8-validate |
| 14282 | - vite | 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 | dependencies: | 14392 | dependencies: |
| 14286 | '@blazediff/core': 1.9.1 | 14393 | '@blazediff/core': 1.9.1 |
| 14287 | '@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)) | 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,7 +14397,7 @@ snapshots: |
| 14290 | pngjs: 7.0.0 | 14397 | pngjs: 7.0.0 |
| 14291 | sirv: 3.0.2 | 14398 | sirv: 3.0.2 |
| 14292 | tinyrainbow: 3.1.0 | 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 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 14401 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 14295 | transitivePeerDependencies: | 14402 | transitivePeerDependencies: |
| 14296 | - bufferutil | 14403 | - bufferutil |
| ... | @@ -19894,8 +20001,8 @@ snapshots: | ... | @@ -19894,8 +20001,8 @@ snapshots: |
| 19894 | dependencies: | 20001 | dependencies: |
| 19895 | '@oxc-project/types': 0.136.0 | 20002 | '@oxc-project/types': 0.136.0 |
| 19896 | '@oxlint/plugins': 1.68.0 | 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))) | 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) |
| 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))) | 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 | '@vitest/expect': 4.1.9 | 20006 | '@vitest/expect': 4.1.9 |
| 19900 | '@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)) | 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 | '@vitest/pretty-format': 4.1.9 | 20008 | '@vitest/pretty-format': 4.1.9 |
| ... | @@ -19908,7 +20015,7 @@ snapshots: | ... | @@ -19908,7 +20015,7 @@ snapshots: |
| 19908 | 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)) | 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 | oxlint-tsgolint: 0.23.0 | 20016 | oxlint-tsgolint: 0.23.0 |
| 19910 | 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)' | 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 | optionalDependencies: | 20019 | optionalDependencies: |
| 19913 | '@voidzero-dev/vite-plus-darwin-arm64': 0.2.1 | 20020 | '@voidzero-dev/vite-plus-darwin-arm64': 0.2.1 |
| 19914 | '@voidzero-dev/vite-plus-darwin-x64': 0.2.1 | 20021 | '@voidzero-dev/vite-plus-darwin-x64': 0.2.1 |
| ... | @@ -19954,7 +20061,7 @@ snapshots: | ... | @@ -19954,7 +20061,7 @@ snapshots: |
| 19954 | optionalDependencies: | 20061 | optionalDependencies: |
| 19955 | 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)' | 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 | dependencies: | 20065 | dependencies: |
| 19959 | '@vitest/expect': 4.1.9 | 20066 | '@vitest/expect': 4.1.9 |
| 19960 | '@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)) | 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,7 +20085,7 @@ snapshots: |
| 19978 | why-is-node-running: 2.3.0 | 20085 | why-is-node-running: 2.3.0 |
| 19979 | optionalDependencies: | 20086 | optionalDependencies: |
| 19980 | '@types/node': 24.12.4 | 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 | transitivePeerDependencies: | 20089 | transitivePeerDependencies: |
| 19983 | - msw | 20090 | - msw |
| 19984 | 20091 |
pnpm-workspace.yaml+14| ... | @@ -5,6 +5,20 @@ packages: | ... | @@ -5,6 +5,20 @@ packages: |
| 5 | - packages/* | 5 | - packages/* |
| 6 | - scripts | 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 | catalog: | 22 | catalog: |
| 9 | "@clerk/backend": 3.8.3-snapshot.v20260622234151 | 23 | "@clerk/backend": 3.8.3-snapshot.v20260622234151 |
| 10 | "@clerk/clerk-js": 6.21.0-snapshot.v20260622234151 | 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,10 +210,20 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 210 | cpu: ["x64"], | 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 | assert.deepStrictEqual(createStageWorkspaceConfig("win", "arm64"), { | 222 | assert.deepStrictEqual(createStageWorkspaceConfig("win", "arm64"), { |
| 214 | supportedArchitectures: { | 223 | supportedArchitectures: { |
| 215 | os: ["win32"], | 224 | os: ["win32", "linux"], |
| 216 | cpu: ["arm64"], | 225 | cpu: ["arm64"], |
| 226 | libc: ["glibc"], | ||
| 217 | }, | 227 | }, |
| 218 | }); | 228 | }); |
| 219 | assert.deepStrictEqual(createStageWorkspaceConfig("mac", "universal"), { | 229 | assert.deepStrictEqual(createStageWorkspaceConfig("mac", "universal"), { |
| ... | @@ -400,6 +410,25 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -400,6 +410,25 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 400 | }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), | 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 | it("promotes target fff binaries to direct staged dependencies", () => { | 432 | it("promotes target fff binaries to direct staged dependencies", () => { |
| 404 | assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), { | 433 | assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), { |
| 405 | "@ff-labs/fff-bin-darwin-arm64": "0.9.4", | 434 | "@ff-labs/fff-bin-darwin-arm64": "0.9.4", |
| ... | @@ -411,6 +440,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -411,6 +440,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 411 | assert.deepStrictEqual(resolveFffNativeDependencies("win", "x64", "0.9.4"), { | 440 | assert.deepStrictEqual(resolveFffNativeDependencies("win", "x64", "0.9.4"), { |
| 412 | "@ff-labs/fff-bin-win32-x64": "0.9.4", | 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 | assert.deepStrictEqual(resolveFffNativeDependencies("linux", "arm64", "0.9.4"), { | 447 | assert.deepStrictEqual(resolveFffNativeDependencies("linux", "arm64", "0.9.4"), { |
| 415 | "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", | 448 | "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", |
| 416 | "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", | 449 | "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", |
| ... | @@ -496,6 +529,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -496,6 +529,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 496 | verbose: Option.none(), | 529 | verbose: Option.none(), |
| 497 | mockUpdates: Option.none(), | 530 | mockUpdates: Option.none(), |
| 498 | mockUpdateServerPort: Option.none(), | 531 | mockUpdateServerPort: Option.none(), |
| 532 | wslPrebuild: Option.none(), | ||
| 499 | }).pipe( | 533 | }).pipe( |
| 500 | Effect.provide( | 534 | Effect.provide( |
| 501 | Layer.mergeAll( | 535 | Layer.mergeAll( |
| ... | @@ -533,6 +567,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -533,6 +567,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 533 | verbose: Option.some(false), | 567 | verbose: Option.some(false), |
| 534 | mockUpdates: Option.some(false), | 568 | mockUpdates: Option.some(false), |
| 535 | mockUpdateServerPort: Option.none(), | 569 | mockUpdateServerPort: Option.none(), |
| 570 | wslPrebuild: Option.none(), | ||
| 536 | }).pipe( | 571 | }).pipe( |
| 537 | Effect.provide( | 572 | Effect.provide( |
| 538 | ConfigProvider.layer( | 573 | ConfigProvider.layer( |
scripts/build-desktop-artifact.ts+171-5| ... | @@ -47,6 +47,7 @@ const StageWorkspaceConfig = Schema.Struct({ | ... | @@ -47,6 +47,7 @@ const StageWorkspaceConfig = Schema.Struct({ |
| 47 | supportedArchitectures: Schema.Struct({ | 47 | supportedArchitectures: Schema.Struct({ |
| 48 | os: Schema.Array(Schema.String), | 48 | os: Schema.Array(Schema.String), |
| 49 | cpu: Schema.Array(Schema.String), | 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,6 +56,9 @@ const RepoRoot = Effect.service(Path.Path).pipe( |
| 55 | ); | 56 | ); |
| 56 | const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); | 57 | const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); |
| 57 | const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); | 58 | const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); |
| 59 | const decodeNodePtyManifest = Schema.decodeUnknownEffect( | ||
| 60 | Schema.fromJsonString(Schema.Struct({ version: Schema.String })), | ||
| 61 | ); | ||
| 58 | const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig)); | 62 | const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig)); |
| 59 | 63 | ||
| 60 | const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { | 64 | const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { |
| ... | @@ -107,6 +111,7 @@ interface BuildCliInput { | ... | @@ -107,6 +111,7 @@ interface BuildCliInput { |
| 107 | readonly verbose: Option.Option<boolean>; | 111 | readonly verbose: Option.Option<boolean>; |
| 108 | readonly mockUpdates: Option.Option<boolean>; | 112 | readonly mockUpdates: Option.Option<boolean>; |
| 109 | readonly mockUpdateServerPort: Option.Option<number>; | 113 | readonly mockUpdateServerPort: Option.Option<number>; |
| 114 | readonly wslPrebuild: Option.Option<string>; | ||
| 110 | } | 115 | } |
| 111 | 116 | ||
| 112 | function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { | 117 | function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { |
| ... | @@ -360,6 +365,29 @@ export class DesktopBuildNoArtifactsProducedError extends Schema.TaggedErrorClas | ... | @@ -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 | export class LinuxIconResizeError extends Schema.TaggedErrorClass<LinuxIconResizeError>()( | 391 | export class LinuxIconResizeError extends Schema.TaggedErrorClass<LinuxIconResizeError>()( |
| 364 | "LinuxIconResizeError", | 392 | "LinuxIconResizeError", |
| 365 | { | 393 | { |
| ... | @@ -517,6 +545,7 @@ interface ResolvedBuildOptions { | ... | @@ -517,6 +545,7 @@ interface ResolvedBuildOptions { |
| 517 | readonly verbose: boolean; | 545 | readonly verbose: boolean; |
| 518 | readonly mockUpdates: boolean; | 546 | readonly mockUpdates: boolean; |
| 519 | readonly mockUpdateServerPort: number | undefined; | 547 | readonly mockUpdateServerPort: number | undefined; |
| 548 | readonly wslPrebuild: string | undefined; | ||
| 520 | } | 549 | } |
| 521 | 550 | ||
| 522 | interface StagePackageJson { | 551 | interface StagePackageJson { |
| ... | @@ -851,10 +880,26 @@ export function createStageWorkspaceConfig( | ... | @@ -851,10 +880,26 @@ export function createStageWorkspaceConfig( |
| 851 | platform: typeof BuildPlatform.Type, | 880 | platform: typeof BuildPlatform.Type, |
| 852 | arch: typeof BuildArch.Type, | 881 | arch: typeof BuildArch.Type, |
| 853 | ): typeof StageWorkspaceConfig.Type { | 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 | return { | 899 | return { |
| 855 | supportedArchitectures: { | 900 | supportedArchitectures: { |
| 856 | os: [platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"], | 901 | os: [hostOs], |
| 857 | cpu: arch === "universal" ? ["arm64", "x64"] : [arch], | 902 | cpu: hostCpu, |
| 858 | }, | 903 | }, |
| 859 | }; | 904 | }; |
| 860 | } | 905 | } |
| ... | @@ -905,6 +950,11 @@ const BuildEnvConfig = Config.all({ | ... | @@ -905,6 +950,11 @@ const BuildEnvConfig = Config.all({ |
| 905 | verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)), | 950 | verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)), |
| 906 | mockUpdates: Config.boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)), | 951 | mockUpdates: Config.boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)), |
| 907 | mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option), | 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 | const MockUpdateServerPortSchema = Schema.NumberFromString.check( | 960 | const MockUpdateServerPortSchema = Schema.NumberFromString.check( |
| ... | @@ -988,6 +1038,9 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( | ... | @@ -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 | return { | 1044 | return { |
| 992 | platform, | 1045 | platform, |
| 993 | target, | 1046 | target, |
| ... | @@ -1000,6 +1053,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( | ... | @@ -1000,6 +1053,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( |
| 1000 | verbose, | 1053 | verbose, |
| 1001 | mockUpdates, | 1054 | mockUpdates, |
| 1002 | mockUpdateServerPort, | 1055 | mockUpdateServerPort, |
| 1056 | wslPrebuild, | ||
| 1003 | } satisfies ResolvedBuildOptions; | 1057 | } satisfies ResolvedBuildOptions; |
| 1004 | }); | 1058 | }); |
| 1005 | 1059 | ||
| ... | @@ -1300,10 +1354,23 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( | ... | @@ -1300,10 +1354,23 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 1300 | appId: DESKTOP_APP_ID, | 1354 | appId: DESKTOP_APP_ID, |
| 1301 | productName: resolveDesktopProductName(version), | 1355 | productName: resolveDesktopProductName(version), |
| 1302 | artifactName: "T3-Code-${version}-${arch}.${ext}", | 1356 | artifactName: "T3-Code-${version}-${arch}.${ext}", |
| 1303 | asarUnpack: [...DESKTOP_ASAR_UNPACK], | ||
| 1304 | directories: { | 1357 | directories: { |
| 1305 | buildResources: "apps/desktop/resources", | 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 | const updateChannel = resolveDesktopUpdateChannel(version); | 1375 | const updateChannel = resolveDesktopUpdateChannel(version); |
| 1309 | const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); | 1376 | const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); |
| ... | @@ -1357,11 +1424,13 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( | ... | @@ -1357,11 +1424,13 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 1357 | const winConfig: Record<string, unknown> = { | 1424 | const winConfig: Record<string, unknown> = { |
| 1358 | target: [target], | 1425 | target: [target], |
| 1359 | icon: "icon.ico", | 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 | if (signed) { | 1432 | if (signed) { |
| 1362 | winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig; | 1433 | winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig; |
| 1363 | } else { | ||
| 1364 | winConfig.signAndEditExecutable = false; | ||
| 1365 | } | 1434 | } |
| 1366 | buildConfig.win = winConfig; | 1435 | buildConfig.win = winConfig; |
| 1367 | } | 1436 | } |
| ... | @@ -1390,6 +1459,76 @@ const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(f | ... | @@ -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 | const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | 1532 | const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 1394 | options: ResolvedBuildOptions, | 1533 | options: ResolvedBuildOptions, |
| 1395 | ) { | 1534 | ) { |
| ... | @@ -1558,6 +1697,17 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -1558,6 +1697,17 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 1558 | options.arch, | 1697 | options.arch, |
| 1559 | serverPackageJson.dependencies["@ff-labs/fff-node"], | 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 | const stagePnpmConfig = createStagePnpmConfig(workspacePatchedDependencies, stageDependencies); | 1712 | const stagePnpmConfig = createStagePnpmConfig(workspacePatchedDependencies, stageDependencies); |
| 1563 | const stagePackageJson: StagePackageJson = { | 1713 | const stagePackageJson: StagePackageJson = { |
| ... | @@ -1616,6 +1766,16 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -1616,6 +1766,16 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 1616 | ); | 1766 | ); |
| 1617 | yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch); | 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 | // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") | 1779 | // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") |
| 1620 | // as enabled, so copy the host env and scrub empty values instead of relying | 1780 | // as enabled, so copy the host env and scrub empty values instead of relying |
| 1621 | // on `extendEnv` merging. | 1781 | // on `extendEnv` merging. |
| ... | @@ -1769,6 +1929,12 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { | ... | @@ -1769,6 +1929,12 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { |
| 1769 | Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."), | 1929 | Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."), |
| 1770 | Flag.optional, | 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 | }).pipe( | 1938 | }).pipe( |
| 1773 | Command.withDescription("Build a desktop artifact for T3 Code."), | 1939 | Command.withDescription("Build a desktop artifact for T3 Code."), |
| 1774 | Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)), | 1940 | Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)), |