authorgravatar for 53677915+Jgratton24@users.noreply.github.comJgratton24 <53677915+Jgratton24@users.noreply.github.com> 2026-06-27 11:53:34-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-27 08:53:34-07:00
loga9b1190a11276927caf573d92c33c5346fc4c076
treecfd50d9e832643984aa6a5ccc646d5fe5d134688
parent44fb34ad5383badfb02c9f7daa1d8486277eeb01
signature Signed by PGP key B5690EEEBB952194

Desktop: parallel WSL + Windows backends with mode picker (#2751)


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"
254254
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: 30314 timeout-minutes: 30
261 env:315 env:
...@@ -323,6 +377,13 @@ jobs:...@@ -323,6 +377,13 @@ jobs:
323 - name: Align package versions to release version377 - 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 }}"
325379
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 libs387 - name: Install Spectre-mitigated MSVC libs
327 if: matrix.platform == 'win'388 if: matrix.platform == 'win'
328 shell: pwsh389 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 fi538 fi
478 elif [[ "${{ matrix.platform }}" == "win" ]]; then539 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";
13import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";13import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
14import * as DesktopClerk from "./DesktopClerk.ts";14import * as DesktopClerk from "./DesktopClerk.ts";
15import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";15import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
16import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";16import * as DesktopWindow from "../window/DesktopWindow.ts";
17import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
17import * as DesktopEnvironment from "./DesktopEnvironment.ts";18import * as DesktopEnvironment from "./DesktopEnvironment.ts";
18import * as DesktopLifecycle from "./DesktopLifecycle.ts";19import * as DesktopLifecycle from "./DesktopLifecycle.ts";
19import * as DesktopObservability from "./DesktopObservability.ts";20import * 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";
23import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";24import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
24import * as DesktopState from "./DesktopState.ts";25import * as DesktopState from "./DesktopState.ts";
25import * as DesktopUpdates from "../updates/DesktopUpdates.ts";26import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
27import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts";
2628
27const DEFAULT_DESKTOP_BACKEND_PORT = 3773;29const DEFAULT_DESKTOP_BACKEND_PORT = 3773;
28const MAX_TCP_PORT = 65_535;30const 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)));
136138
137const bootstrap = Effect.gen(function* () {139const 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");
144149
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");
194199
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"));
200217
...@@ -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 });
242259
243 const shutdown = yield* DesktopShutdown.DesktopShutdown;260 const shutdown = yield* DesktopShutdown.DesktopShutdown;
244 const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
245261
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 );
249276
250 yield* startup;277 yield* startup;
apps/desktop/src/app/DesktopBackendOutputLog.test.ts deleted-122
...@@ -1,122 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Logger from "effect/Logger";
6import * as Path from "effect/Path";
7import * as PlatformError from "effect/PlatformError";
8
9import * as DesktopBackendOutputLog from "./DesktopBackendOutputLog.ts";
10import * as DesktopConfig from "./DesktopConfig.ts";
11import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12
13const LOG_FILE_PATH = "/Users/alice/.t3/userdata/logs/server-child.log";
14
15const 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
27const 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
42const loggedError = (messages: ReadonlyArray<ReadonlyArray<unknown>>): unknown =>
43 messages.flat().find((value) => typeof value === "object" && value !== null && "error" in value)
44 ?.error;
45
46describe("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 @@
1import * as Context from "effect/Context";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
9import * as References from "effect/References";
10import * as Ref from "effect/Ref";
11import * as Schema from "effect/Schema";
12import * as Semaphore from "effect/Semaphore";
13
14import * as DesktopEnvironment from "./DesktopEnvironment.ts";
15
16export const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
17export const DESKTOP_LOG_FILE_MAX_FILES = 10;
18
19const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child";
20
21interface 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
31class 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
43class 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
56export 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
68export 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
81export 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
93export 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
107type DesktopLogFileWriterError =
108 | DesktopLogFileWriterConfigurationError
109 | PlatformError.PlatformError;
110
111const 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
120const encodeDesktopBackendChildLogRecord = Schema.encodeEffect(
121 Schema.fromJsonString(DesktopBackendChildLogRecord),
122);
123
124const DesktopBackendOutputLogNoop: DesktopBackendOutputLog["Service"] = {
125 writeSessionBoundary: () => Effect.void,
126 writeOutputChunk: () => Effect.void,
127};
128
129const textEncoder = new TextEncoder();
130const textDecoder = new TextDecoder();
131
132const 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
138const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim();
139
140const 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
152const 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
266const 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
282const 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
328export 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
385export 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));
126126
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");
150152
...@@ -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 @@
1import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
1import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";2import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";
2import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";3import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
4import * as Context from "effect/Context";
5import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";7import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
6import * as Logger from "effect/Logger";9import * as Logger from "effect/Logger";
7import * as Option from "effect/Option";10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as PlatformError from "effect/PlatformError";
8import * as References from "effect/References";13import * as References from "effect/References";
14import * as Ref from "effect/Ref";
15import * as Schema from "effect/Schema";
16import * as Scope from "effect/Scope";
17import * as Semaphore from "effect/Semaphore";
18import * as SynchronizedRef from "effect/SynchronizedRef";
9import * as Tracer from "effect/Tracer";19import * as Tracer from "effect/Tracer";
10import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";20import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
1121
12import * as DesktopBackendOutputLogModule from "./DesktopBackendOutputLog.ts";
13import * as DesktopEnvironment from "./DesktopEnvironment.ts";22import * as DesktopEnvironment from "./DesktopEnvironment.ts";
1423
24const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
25const DESKTOP_LOG_FILE_MAX_FILES = 10;
26const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child";
15const DESKTOP_TRACE_BATCH_WINDOW_MS = 200;27const DESKTOP_TRACE_BATCH_WINDOW_MS = 200;
1628
17export { DesktopBackendOutputLog } from "./DesktopBackendOutputLog.ts";29export interface RotatingLogFileWriter {
30 readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>;
31 readonly writeText: (chunk: string) => Effect.Effect<void>;
32}
33
34export 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.
54export class DesktopBackendOutputLogFactory extends Context.Service<
55 DesktopBackendOutputLogFactory,
56 {
57 readonly forInstance: (id: string) => Effect.Effect<DesktopBackendOutputLogShape>;
58 }
59>()("@t3tools/desktop/app/DesktopObservability/DesktopBackendOutputLogFactory") {}
60
61const textEncoder = new TextEncoder();
62const textDecoder = new TextDecoder();
1863
19export type DesktopLogAnnotations = Record<string, unknown>;64export type DesktopLogAnnotations = Record<string, unknown>;
2065
...@@ -50,7 +95,166 @@ export function makeComponentLogger(component: string): DesktopComponentLogger {...@@ -50,7 +95,166 @@ export function makeComponentLogger(component: string): DesktopComponentLogger {
50 };95 };
51}96}
5297
53const readPersistedOtlpTracesUrl = Effect.gen(function* () {98class 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
110type DesktopLogFileWriterError =
111 | DesktopLogFileWriterConfigurationError
112 | PlatformError.PlatformError;
113
114const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim();
115
116const 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
125const encodeDesktopBackendChildLogRecord = Schema.encodeEffect(
126 Schema.fromJsonString(DesktopBackendChildLogRecord),
127);
128
129const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = {
130 writeSessionBoundary: () => Effect.void,
131 writeOutputChunk: () => Effect.void,
132};
133
134const 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
140const 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
149const 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
253const 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});
72276
277const 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
286const 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
310const PRIMARY_BACKEND_LOG_INSTANCE_ID = PRIMARY_LOCAL_ENVIRONMENT_ID;
311
312const sanitizeInstanceIdForFileName = (id: string): string => id.replace(/[^a-zA-Z0-9._-]+/g, "_");
313
314const 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.
332const 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
344const 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
391const 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
73const desktopLoggerLayer = Layer.mergeAll(441const 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));
114482
115export const layer = Layer.mergeAll(483export 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";
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Logger from "effect/Logger";6import * as Logger from "effect/Logger";
7import * as ManagedRuntime from "effect/ManagedRuntime";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
7import * as PlatformError from "effect/PlatformError";10import * as PlatformError from "effect/PlatformError";
8import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
12import { ChildProcessSpawner } from "effect/unstable/process";
913
10import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";14import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
11import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";15import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
12import * as DesktopConfig from "../app/DesktopConfig.ts";16import * as DesktopConfig from "../app/DesktopConfig.ts";
13import * as DesktopServerExposure from "./DesktopServerExposure.ts";17import * as DesktopServerExposure from "./DesktopServerExposure.ts";
18import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
19import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
1420
15const PersistedServerObservabilitySettingsDocument = Schema.Struct({21const 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
45function makeEnvironmentLayer(51function 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}
7786
87const 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
78const withHarness = <A, E, R>(95const 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));
103122
104describe("DesktopBackendConfiguration", () => {123describe("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;
110129
111 const first = yield* configuration.resolve;130 const first = yield* configuration.resolvePrimary;
112 const second = yield* configuration.resolve;131 const second = yield* configuration.resolvePrimary;
113132
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 );
135154
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 );
155334
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 );
162341
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;
168347
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", () => {
198378
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 );
230412
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", () => {
237419
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 @@
1import * as NodeOS from "node:os";
2
1import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";3import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
2import * as Context from "effect/Context";4import * as Context from "effect/Context";
3import * as Crypto from "effect/Crypto";5import * 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";
7import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";10import * as Option from "effect/Option";
9import * as PlatformError from "effect/PlatformError";11import * as PlatformError from "effect/PlatformError";
10import * as Ref from "effect/Ref";
11import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
13import * as SynchronizedRef from "effect/SynchronizedRef";
14
15import serverPackageJson from "../../../server/package.json" with { type: "json" };
1216
13import * as DesktopBackendManager from "./DesktopBackendManager.ts";17import * as DesktopBackendManager from "./DesktopBackendManager.ts";
14import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
15import * as DesktopServerExposure from "./DesktopServerExposure.ts";19import * as DesktopServerExposure from "./DesktopServerExposure.ts";
20import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
21import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
1622
17export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass<DesktopBackendObservabilitySettingsReadError>()(23export 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
29export class DesktopBackendConfiguration extends Context.Service<35export 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.PlatformError44 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") {}
3865
...@@ -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;
6188
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.
93const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const;
94
95const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
96
62const backendChildEnvPatch = (): Record<string, string | undefined> =>97const 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]));
6499
100const getWslEnvEntryName = (entry: string): string => {
101 const slashIndex = entry.indexOf("/");
102 return slashIndex === -1 ? entry : entry.slice(0, slashIndex);
103};
104
105const 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
65const logBackendObservabilitySettingsReadFailure = (131const 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});
102168
103const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolveStartConfig")(169interface 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
174interface 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
188interface 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
199const WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT = 12;
200
201const 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.
299const 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
316const 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
327const 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.DesktopServerExposure333 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;
115338
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);
147370
371const 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
148export const make = Effect.gen(function* () {558export 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 });
159599
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 });
164648
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});
182684
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";
5import { assert, describe, it } from "@effect/vitest";5import { assert, describe, it } from "@effect/vitest";
6import * as Cause from "effect/Cause";
7import * as Deferred from "effect/Deferred";6import * as Deferred from "effect/Deferred";
8import * as Duration from "effect/Duration";7import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";9import * as FileSystem from "effect/FileSystem";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";11import * as Option from "effect/Option";
14import * as PlatformError from "effect/PlatformError";
15import * as Queue from "effect/Queue";12import * as Queue from "effect/Queue";
16import * as Ref from "effect/Ref";13import * as Ref from "effect/Ref";
17import * as Schema from "effect/Schema";14import * 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
23import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";20import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2421
25import * as DesktopBackendManager from "./DesktopBackendManager.ts";22import * as DesktopBackendManager from "./DesktopBackendManager.ts";
26import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
27import * as DesktopObservability from "../app/DesktopObservability.ts";23import * as DesktopObservability from "../app/DesktopObservability.ts";
28import * as DesktopState from "../app/DesktopState.ts";
29import * as DesktopWindow from "../window/DesktopWindow.ts";
3024
31const decodeDesktopBackendBootstrap = Schema.decodeEffect(25const decodeDesktopBackendBootstrap = Schema.decodeEffect(
32 Schema.fromJsonString(DesktopBackendBootstrap),26 Schema.fromJsonString(DesktopBackendBootstrap),
33);27);
34const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError);
3528
36const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {29const 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};
5451
55const configWithObservability: DesktopBackendBootstrapValue = {52const configWithObservability: DesktopBackendBootstrapValue = {
...@@ -59,9 +56,9 @@ const configWithObservability: DesktopBackendBootstrapValue = {...@@ -59,9 +56,9 @@ const configWithObservability: DesktopBackendBootstrapValue = {
59};56};
6057
61function makeProcess(options?: {58function 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}
107104
108function makeManagerLayer(input: {105interface 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}
150117
151describe("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 );123function 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 };
162129 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 });
168148
149 return instance.pipe(Effect.provide(servicesLayer));
150}
151
152describe("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>();
176160 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 }
188173
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 );
195180
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
212196 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);
217198
218 assert.equal(readyCount, 1);199 assert.equal(readyCount, 1);
...@@ -235,292 +216,52 @@ describe("DesktopBackendManager", () => {...@@ -235,292 +216,52 @@ describe("DesktopBackendManager", () => {
235 );216 );
236217
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 );
434222
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 );
478243
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);
487252 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
520264 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);
525266
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 );
540281
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);
550291
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);
561302
562 yield* Scope.addFinalizer(scope, close);303 yield* Scope.addFinalizer(scope, close);
563304
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 );
571312
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
582323 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));
592328
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));
596332
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);
600336
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 );
609345
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;
614350 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 );
628374
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 );
633381
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;
637408
638 assert.equal(yield* Queue.take(starts), 1);409 assert.equal(yield* Queue.take(starts), 1);
639410
...@@ -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 );
652618
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>();
658624 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;
665631 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 }),
671649 ),
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 );
684651
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 });
689656
690 yield* Effect.gen(function* () {657 yield* instance.start;
691 const manager = yield* DesktopBackendManager.DesktopBackendManager;
692 yield* manager.start;
693658
694 assert.equal(yield* Queue.take(starts), 1);659 assert.equal(yield* Queue.take(starts), 1);
695660
696 yield* manager.start;661 yield* instance.start;
697 assert.equal(yield* Queue.take(starts), 2);662 assert.equal(yield* Queue.take(starts), 2);
698663
699 yield* manager.stop();664 yield* instance.stop();
700 yield* TestClock.adjust(Duration.millis(500));665 yield* TestClock.adjust(Duration.millis(500));
701666
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 );
706671
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>();
711676 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 );
725691
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 });
730696
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);
735699
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 }
743707
744 yield* manager.stop();708 yield* instance.stop();
745 yield* TestClock.adjust(Duration.millis(500));709 yield* TestClock.adjust(Duration.millis(500));
746710
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
26import * as Brand from "effect/Brand";
1import * as Cause from "effect/Cause";27import * as Cause from "effect/Cause";
2import * as Context from "effect/Context";
3import * as Duration from "effect/Duration";28import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";29import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";30import * as Exit from "effect/Exit";
6import * as Fiber from "effect/Fiber";31import * as Fiber from "effect/Fiber";
7import * as FileSystem from "effect/FileSystem";32import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";33import * as Option from "effect/Option";
10import * as PlatformError from "effect/PlatformError";34import * as PlatformError from "effect/PlatformError";
11import * as Ref from "effect/Ref";35import * as Ref from "effect/Ref";
36import * as Result from "effect/Result";
12import * as Schedule from "effect/Schedule";37import * as Schedule from "effect/Schedule";
13import * as Schema from "effect/Schema";38import * as Schema from "effect/Schema";
14import * as Semaphore from "effect/Semaphore";39import * as Semaphore from "effect/Semaphore";
15import * as Scope from "effect/Scope";40import * as Scope from "effect/Scope";
16import * as Stream from "effect/Stream";41import * as Stream from "effect/Stream";
17import * as HttpClient from "effect/unstable/http/HttpClient";42import { HttpClient } from "effect/unstable/http";
18import * as ChildProcess from "effect/unstable/process/ChildProcess";43import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
19import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
2044
21import {45import {
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";
50import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness";
2551
26import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
27import * as DesktopObservability from "../app/DesktopObservability.ts";52import * as DesktopObservability from "../app/DesktopObservability.ts";
28import * as DesktopState from "../app/DesktopState.ts";
29import * as DesktopWindow from "../window/DesktopWindow.ts";
3053
31const INITIAL_RESTART_DELAY = Duration.millis(500);54const INITIAL_RESTART_DELAY = Duration.millis(500);
32const MAX_RESTART_DELAY = Duration.seconds(10);55const 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.
60const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5;
33const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1);61const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1);
34const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100);62const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100);
35const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1);63const 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;
4270
43export type BackendProcessOutputStream = "stdout" | "stderr";71export type BackendProcessOutputStream = "stdout" | "stderr";
4472
45export interface BackendProcessContext {73export type DesktopBackendBootstrapDelivery = "fd3" | "stdin";
74
75export 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
52export 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.
100export interface PreflightFailure {
101 readonly reason: string;
102 readonly fatal: boolean;
103 readonly retryLimit?: number;
56}104}
57105
58interface BackendProcessExit {106interface 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}
62111
63const backendProcessContextSchema = {112export 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
70export 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}
83122
84export class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()(123class 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}
95134
96export class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()(135class 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
108export 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
122export 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
137export type BackendProcessOutputError =
138 | BackendProcessOutputReadError
139 | BackendProcessOutputHandlingError;
140
141export 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
154export 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}
166146
167export const BackendProcessError = Schema.Union([147type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError;
168 BackendProcessBootstrapEncodeError,
169 BackendProcessSpawnError,
170 BackendProcessExitStatusError,
171]);
172export type BackendProcessError = typeof BackendProcessError.Type;
173148
174interface RunBackendProcessOptions extends DesktopBackendStartConfig {149interface 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}
185159
186export interface DesktopBackendSnapshot {160export 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}
193167
194export 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>>;173export type BackendInstanceId = string & Brand.Brand<"BackendInstanceId">;
200 readonly snapshot: Effect.Effect<DesktopBackendSnapshot>;174export const BackendInstanceId = Brand.nominal<BackendInstanceId>();
201 }175
202>()("@t3tools/desktop/backend/DesktopBackendManager") {}176export 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.
183export 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}
203196
204const { 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.
203export 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}
206222
207interface ActiveBackendRun {223interface 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};
261281
262export const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* (282const 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};
271296
272 yield* client.get(readinessUrl).pipe(297function 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}
289314
290function drainBackendOutput(315function 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}
328325
329const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));326const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
330327
331export const runBackendProcess = Effect.fn("runBackendProcess")(function* (328const 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 );
370357
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 );
383365
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 );
422379
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});
441382
442export 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).
388export 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);
453407
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));
456413
...@@ -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 }
492449
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));
508470
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 }));
516481
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 ]);
536568
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 }
591623
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* () {
598630
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 }
630662
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 } }));
668689
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* () {
686707
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 });
734749
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 });
767790
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());
769809
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
778export const layer = Layer.effect(DesktopBackendManager, make);
apps/desktop/src/backend/DesktopBackendPool.test.ts created+137
...@@ -0,0 +1,137 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Ref from "effect/Ref";
8import { HttpClient } from "effect/unstable/http";
9import { ChildProcessSpawner } from "effect/unstable/process";
10
11import * as DesktopObservability from "../app/DesktopObservability.ts";
12import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
13import * as ElectronDialog from "../electron/ElectronDialog.ts";
14import * as DesktopWindow from "../window/DesktopWindow.ts";
15import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
16import * as DesktopBackendPool from "./DesktopBackendPool.ts";
17import type { DesktopBackendSnapshot, DesktopBackendStartConfig } from "./DesktopBackendManager.ts";
18
19function 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
41function 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
87describe("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
82import * as Context from "effect/Context";
83import * as Deferred from "effect/Deferred";
84import * as Effect from "effect/Effect";
85import * as Exit from "effect/Exit";
86import * as Layer from "effect/Layer";
87import * as Option from "effect/Option";
88import * as Schema from "effect/Schema";
89import * as Scope from "effect/Scope";
90import * as SynchronizedRef from "effect/SynchronizedRef";
91
92import * as FileSystem from "effect/FileSystem";
93import { HttpClient } from "effect/unstable/http";
94import { ChildProcessSpawner } from "effect/unstable/process";
95
96import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
97import * as DesktopBackendManager from "./DesktopBackendManager.ts";
98import * as DesktopObservability from "../app/DesktopObservability.ts";
99import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
100import * as DesktopWindow from "../window/DesktopWindow.ts";
101import * as ElectronDialog from "../electron/ElectronDialog.ts";
102
103const { logWarning: logBackendPoolWarning } =
104 DesktopObservability.makeComponentLogger("desktop-backend-pool");
105
106export type BackendInstanceId = DesktopBackendManager.BackendInstanceId;
107export const BackendInstanceId = DesktopBackendManager.BackendInstanceId;
108export const PRIMARY_INSTANCE_ID = DesktopBackendManager.PRIMARY_INSTANCE_ID;
109export type DesktopBackendInstance = DesktopBackendManager.DesktopBackendInstance;
110export 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.
115export 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".
129export 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
138export 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.
175export type BackendInstanceFactoryRequirements =
176 | FileSystem.FileSystem
177 | ChildProcessSpawner.ChildProcessSpawner
178 | HttpClient.HttpClient
179 | DesktopObservability.DesktopBackendOutputLogFactory;
180
181interface 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
190interface ClosingRegisteredInstance {
191 readonly _tag: "Closing";
192 readonly done: Deferred.Deferred<void>;
193}
194
195type RegisteredInstance = ActiveRegisteredInstance | ClosingRegisteredInstance;
196
197type RegisterAction =
198 | { readonly _tag: "Registered"; readonly instance: DesktopBackendInstance }
199 | { readonly _tag: "Wait"; readonly done: Deferred.Deferred<void> };
200
201type UnregisterAction =
202 | { readonly _tag: "Absent" }
203 | { readonly _tag: "Wait"; readonly done: Deferred.Deferred<void> }
204 | { readonly _tag: "Close"; readonly entry: ActiveRegisteredInstance };
205
206export 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.
449export 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";
5import * as Ref from "effect/Ref";5import * as Ref from "effect/Ref";
6import * as HttpClient from "effect/unstable/http/HttpClient";6import * as HttpClient from "effect/unstable/http/HttpClient";
7import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";7import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
8import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
89
9import * as DesktopBackendManager from "./DesktopBackendManager.ts";10import * as DesktopBackendPool from "./DesktopBackendPool.ts";
10import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts";11import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts";
1112
12const config: DesktopBackendManager.DesktopBackendStartConfig = {13const 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 );
7270
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 @@
1import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization";1import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization";
2import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
2import * as Context from "effect/Context";3import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";5import * 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";
8import * as Semaphore from "effect/Semaphore";9import * as Semaphore from "effect/Semaphore";
9import * as HttpClient from "effect/unstable/http/HttpClient";10import * as HttpClient from "effect/unstable/http/HttpClient";
1011
11import * as DesktopBackendManager from "./DesktopBackendManager.ts";12import * as DesktopBackendPool from "./DesktopBackendPool.ts";
1213
13export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedErrorClass<DesktopLocalEnvironmentAuthBackendNotConfiguredError>()(14export 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") {}
4344
44export const make = Effect.gen(function* () {45export 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 }
5758
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"]);
257262
258 return withHarness(263 return withHarness(
apps/desktop/src/ipc/DesktopIpcHandlers.ts+8-2
...@@ -33,21 +33,22 @@ import {...@@ -33,21 +33,22 @@ import {
33import {33import {
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";
43import * as PreviewIpc from "./methods/preview.ts";43import * as PreviewIpc from "./methods/preview.ts";
44import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts";
4445
45export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {46export 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();
4849
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);
5253
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);
7273
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";
11export const UPDATE_INSTALL_CHANNEL = "desktop:update-install";11export const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
12export const UPDATE_CHECK_CHANNEL = "desktop:update-check";12export const UPDATE_CHECK_CHANNEL = "desktop:update-check";
13export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";13export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
14export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";14export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps";
15export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL =15export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL =
16 "desktop:get-local-environment-bearer-token";16 "desktop:get-local-environment-bearer-token";
17export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";17export 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
32export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";32export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
33export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";33export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";
34export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";34export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
35export const GET_WSL_STATE_CHANNEL = "desktop:get-wsl-state";
36export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled";
37export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro";
38export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only";
35export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";39export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
36export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab";40export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab";
37export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab";41export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab";
apps/desktop/src/ipc/methods/window.test.ts created+128
...@@ -0,0 +1,128 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4
5import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
6import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
7import { getLocalEnvironmentBootstraps } from "./window.ts";
8
9const 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
32const 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
48describe("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";
8import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";11import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
1113
12import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";14import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
13import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts";15import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts";
14import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";16import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
17import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
18import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
19import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
15import * as ElectronDialog from "../../electron/ElectronDialog.ts";20import * as ElectronDialog from "../../electron/ElectronDialog.ts";
16import * as ElectronMenu from "../../electron/ElectronMenu.ts";21import * as ElectronMenu from "../../electron/ElectronMenu.ts";
17import * as ElectronShell from "../../electron/ElectronShell.ts";22import * 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";
19import * as ElectronWindow from "../../electron/ElectronWindow.ts";24import * as ElectronWindow from "../../electron/ElectronWindow.ts";
20import * as IpcChannels from "../channels.ts";25import * as IpcChannels from "../channels.ts";
21import * as DesktopIpc from "../DesktopIpc.ts";26import * as DesktopIpc from "../DesktopIpc.ts";
27import {
28 extractDistroFromUncPath,
29 resolveWslPickFolderDefaultPath,
30 wslUncPathToLinuxPath,
31} from "../../wsl/wslPathParsing.ts";
2232
23const ContextMenuPosition = Schema.Struct({33const 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});
4757
48export const getLocalEnvironmentBootstrap = DesktopIpc.makeSyncIpcMethod({58export 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.desktopBootstrapToken110 ...(bootstrap.desktopBootstrapToken
61 ? { bootstrapToken: bootstrap.desktopBootstrapToken }111 ? { bootstrapToken: bootstrap.desktopBootstrapToken }
62 : {}),112 : {}),
63 }),113 });
64 });114 }
115 return bootstraps;
65 }),116 }),
66});117});
67118
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.
123function 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
68export const getLocalEnvironmentBearerToken = DesktopIpc.makeIpcMethod({131export 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});
93207
apps/desktop/src/ipc/methods/wsl.test.ts created+274
...@@ -0,0 +1,274 @@
1import { DesktopWslStateSchema } from "@t3tools/contracts";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7
8import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
9import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts";
10import * as DesktopShutdown from "../../app/DesktopShutdown.ts";
11import * as DesktopState from "../../app/DesktopState.ts";
12import * as ElectronApp from "../../electron/ElectronApp.ts";
13import * as ElectronTheme from "../../electron/ElectronTheme.ts";
14import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
15import * as DesktopWindow from "../../window/DesktopWindow.ts";
16import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
17import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
18import { setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts";
19
20const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema);
21
22const invokeSetWslBackendEnabled = (enabled: boolean) =>
23 setWslBackendEnabled.handler(enabled).pipe(Effect.flatMap(decodeWslState));
24const invokeSetWslDistro = (distro: string | null) =>
25 setWslDistro.handler(distro).pipe(Effect.flatMap(decodeWslState));
26const invokeSetWslOnly = (enabled: boolean) =>
27 setWslOnly.handler(enabled).pipe(Effect.flatMap(decodeWslState));
28
29function 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
39function 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
52const 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
75describe("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 @@
1import { DesktopWslStateSchema, type DesktopWslState } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5
6import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts";
7import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
8import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
9import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
10import * as IpcChannels from "../channels.ts";
11import { makeIpcMethod } from "../DesktopIpc.ts";
12
13const 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
41export 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
50export 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
81export 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
104export 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";
31import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";31import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";
32import * as DesktopAssets from "./app/DesktopAssets.ts";32import * as DesktopAssets from "./app/DesktopAssets.ts";
33import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";33import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";
34import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts";34import * as DesktopBackendPool from "./backend/DesktopBackendPool.ts";
35import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts";35import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts";
36import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts";36import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts";
37import * as DesktopEnvironment from "./app/DesktopEnvironment.ts";37import * 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";
50import * as BrowserSession from "./preview/BrowserSession.ts";50import * as BrowserSession from "./preview/BrowserSession.ts";
51import * as PreviewManager from "./preview/Manager.ts";51import * as PreviewManager from "./preview/Manager.ts";
52import * as DesktopWindow from "./window/DesktopWindow.ts";52import * as DesktopWindow from "./window/DesktopWindow.ts";
53import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";
54import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts";
5355
54const desktopEnvironmentLayer = Layer.unwrap(56const 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);
145147
146const 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.
153const 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);
151159
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.
163const desktopWslBackendLayer = DesktopWslBackend.layer.pipe(
164 Layer.provideMerge(desktopBackendLayer),
165);
166
152const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe(167const 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);
165181
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});
2024
21const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch));25const 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);
118128
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";
1717
18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
19import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts";19import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts";
20import { isValidDistroName } from "../wsl/wslPathParsing.ts";
2021
21export interface DesktopSettings {22export 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}
2844
29export interface DesktopSettingsChange {45export 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};
4362
44const DesktopSettingsDocument = Schema.Struct({63const 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});
5177
52type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type;78type 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") {}
103143
...@@ -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}
116156
157function normalizeWslDistro(value: unknown): string | null {
158 return typeof value === "string" && isValidDistroName(value) ? value : null;
159}
160
117function normalizeDesktopSettingsDocument(161function 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"));
127171
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}
139193
...@@ -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 }
161224
162 return document;225 return document;
163}226}
...@@ -204,6 +267,38 @@ function setUpdateChannel(...@@ -204,6 +267,38 @@ function setUpdateChannel(
204 };267 };
205}268}
206269
270function setWslBackendEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings {
271 return settings.wslBackendEnabled === enabled
272 ? settings
273 : {
274 ...settings,
275 wslBackendEnabled: enabled,
276 };
277}
278
279function 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
289function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSettings {
290 return settings.wslOnly === enabled
291 ? settings
292 : {
293 ...settings,
294 wslOnly: enabled,
295 };
296}
297
298function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings {
299 return setWslOnly(setWslBackendEnabled(settings, false), false);
300}
301
207function readSettings(302function 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);
289384
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});
347468
...@@ -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";
13import * as Ref from "effect/Ref";13import * as Ref from "effect/Ref";
14import * as TestClock from "effect/testing/TestClock";14import * as TestClock from "effect/testing/TestClock";
1515
16import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";16import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
17import * as DesktopConfig from "../app/DesktopConfig.ts";17import * as DesktopConfig from "../app/DesktopConfig.ts";
18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
19import * as ElectronUpdater from "../electron/ElectronUpdater.ts";19import * 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"]);
109109
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]);
122126
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;
157166
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";
18import * as Schema from "effect/Schema";18import * as Schema from "effect/Schema";
19import * as Scope from "effect/Scope";19import * as Scope from "effect/Scope";
2020
21import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";21import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
22import * as DesktopConfig from "../app/DesktopConfig.ts";22import * as DesktopConfig from "../app/DesktopConfig.ts";
23import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";23import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
24import * as DesktopObservability from "../app/DesktopObservability.ts";24import * as DesktopObservability from "../app/DesktopObservability.ts";
...@@ -241,7 +241,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean...@@ -241,7 +241,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean
241241
242export const make = Effect.gen(function* () {242export 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);
459459
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";
28import * as ElectronShell from "../electron/ElectronShell.ts";28import * as ElectronShell from "../electron/ElectronShell.ts";
29import * as ElectronTheme from "../electron/ElectronTheme.ts";29import * as ElectronTheme from "../electron/ElectronTheme.ts";
30import * as ElectronWindow from "../electron/ElectronWindow.ts";30import * as ElectronWindow from "../electron/ElectronWindow.ts";
31import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts";
31import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";32import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
32import * as DesktopWindow from "./DesktopWindow.ts";33import * as DesktopWindow from "./DesktopWindow.ts";
33import * as PreviewManager from "../preview/Manager.ts";34import * as PreviewManager from "../preview/Manager.ts";
...@@ -62,6 +63,7 @@ function makeFakeBrowserWindow() {...@@ -62,6 +63,7 @@ function makeFakeBrowserWindow() {
62 };63 };
6364
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}
193196
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.
202const 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
194describe("DesktopWindow", () => {291describe("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);
233330
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", () => {
254351
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"));
258355
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", () => {
323420
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"));
327424
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";
10import * as DesktopAssets from "../app/DesktopAssets.ts";10import * as DesktopAssets from "../app/DesktopAssets.ts";
11import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";11import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
12import { makeComponentLogger } from "../app/DesktopObservability.ts";12import { makeComponentLogger } from "../app/DesktopObservability.ts";
13import * as DesktopState from "../app/DesktopState.ts";
14import * as ElectronMenu from "../electron/ElectronMenu.ts";13import * as ElectronMenu from "../electron/ElectronMenu.ts";
15import { getDesktopUrl } from "../electron/ElectronProtocol.ts";14import { getDesktopUrl } from "../electron/ElectronProtocol.ts";
16import * as ElectronShell from "../electron/ElectronShell.ts";15import * as ElectronShell from "../electron/ElectronShell.ts";
...@@ -42,7 +41,6 @@ type WindowTitleBarOptions = Pick<...@@ -42,7 +41,6 @@ type WindowTitleBarOptions = Pick<
42type DesktopWindowRuntimeServices =41type DesktopWindowRuntimeServices =
43 | DesktopEnvironment.DesktopEnvironment42 | DesktopEnvironment.DesktopEnvironment
44 | DesktopAssets.DesktopAssets43 | DesktopAssets.DesktopAssets
45 | DesktopState.DesktopState
46 | ElectronMenu.ElectronMenu44 | ElectronMenu.ElectronMenu
47 | ElectronShell.ElectronShell45 | ElectronShell.ElectronShell
48 | ElectronTheme.ElectronTheme46 | 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}
88101
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.
105function 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
89export function isSameOriginRendererNavigation(input: {114export 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);
184217
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 DesktopWindowError246 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 });
407468
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"));
427488
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"));
441502
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"));
449510
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;
472599
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 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6
7import * as NetService from "@t3tools/shared/Net";
8
9import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts";
10import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
11import type {
12 DesktopBackendSnapshot,
13 DesktopBackendStartConfig,
14} from "../backend/DesktopBackendManager.ts";
15import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
16import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
17import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts";
18import * as DesktopWslBackend from "./DesktopWslBackend.ts";
19
20function 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
37const idleSnapshot: DesktopBackendSnapshot = {
38 desiredRunning: false,
39 ready: false,
40 activePid: Option.none(),
41 restartAttempt: 5,
42 restartScheduled: false,
43};
44
45const primarySnapshot: DesktopBackendSnapshot = {
46 desiredRunning: true,
47 ready: true,
48 activePid: Option.some(123),
49 restartAttempt: 0,
50 restartScheduled: false,
51};
52
53const 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
68const 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
77const 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
84describe("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
23import * as Cause from "effect/Cause";
24import * as Context from "effect/Context";
25import * as Effect from "effect/Effect";
26import * as Layer from "effect/Layer";
27import * as Option from "effect/Option";
28import * as Ref from "effect/Ref";
29import * as Semaphore from "effect/Semaphore";
30
31import * as NetService from "@t3tools/shared/Net";
32
33import * as DesktopObservability from "../app/DesktopObservability.ts";
34import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts";
35import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
36import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
37import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
38import * 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.
44export const WSL_INSTANCE_ID_PREFIX = "wsl:";
45const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`;
46const MAX_TCP_PORT = 65_535;
47
48export 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
63const { logInfo: logWslBackendInfo, logWarning: logWslBackendWarning } =
64 DesktopObservability.makeComponentLogger("desktop-wsl-backend");
65
66const resolveTargetInstanceId = (distro: string | null): DesktopBackendPool.BackendInstanceId =>
67 DesktopBackendPool.BackendInstanceId(
68 distro === null ? WSL_DEFAULT_DISTRO_ID : `${WSL_INSTANCE_ID_PREFIX}${distro}`,
69 );
70
71const isWslInstanceId = (id: DesktopBackendPool.BackendInstanceId): boolean =>
72 id.startsWith(WSL_INSTANCE_ID_PREFIX);
73
74const 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.
80const 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
94export 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 @@
1import { describe, it } from "@effect/vitest";
2import { expect } from "vite-plus/test";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import * as Sink from "effect/Sink";
8import * as Stream from "effect/Stream";
9import * as TestClock from "effect/testing/TestClock";
10import { ChildProcessSpawner } from "effect/unstable/process";
11
12import {
13 buildWslNodeEnvPreamble,
14 DesktopWslDistroListError,
15 formatMissingToolsReason,
16 formatNodePtyProbeFailureReason,
17 formatWslShellTransportFailureReason,
18 parseNodePath,
19 parseResolvedPath,
20 parseToolchainReport,
21 probeWslDistros,
22} from "./DesktopWslEnvironment.ts";
23
24const encoder = new TextEncoder();
25
26const 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
48describe("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
90describe("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
103describe("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
112describe("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
127describe("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
156describe("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
176describe("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
196describe("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 @@
1import * as Context from "effect/Context";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import * as Stream from "effect/Stream";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11
12import { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel";
13import { satisfiesSemverRange } from "@t3tools/shared/semver";
14
15import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
16import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts";
17
18const PROCESS_TERMINATE_GRACE = Duration.seconds(1);
19const LIST_TIMEOUT = Duration.seconds(8);
20const PRE_WARM_TIMEOUT = Duration.seconds(10);
21const WSLPATH_TIMEOUT = Duration.seconds(10);
22const PROBE_TIMEOUT = Duration.seconds(10);
23const TOOLCHAIN_TIMEOUT = Duration.seconds(10);
24const BUILD_TIMEOUT = Duration.minutes(5);
25const USER_HOME_TIMEOUT = Duration.seconds(5);
26const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12;
27const BUILD_TRANSPORT_RETRY_LIMIT = 2;
28
29export interface EnsureWslNodePtyOptions {
30 readonly allowBuild?: boolean;
31 readonly nodeEngineRange?: string | null;
32}
33
34export 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
43export 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
52const isDesktopWslDistroListError = Schema.is(DesktopWslDistroListError);
53
54export 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
86const buildDistroArgs = (distro: string | null): ReadonlyArray<string> =>
87 distro ? ["-d", distro] : [];
88
89const 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
101const decodeUtf8 = (bytes: Uint8Array): string => new TextDecoder("utf-8").decode(bytes);
102
103interface ShellResult {
104 readonly exitCode: number;
105 readonly stdout: string;
106 readonly stderr: string;
107 readonly transportFailure: "timeout" | "spawn" | "process" | null;
108}
109
110const TIMEOUT_RESULT: ShellResult = {
111 exitCode: 124,
112 stdout: "",
113 stderr: "\n[timeout]",
114 transportFailure: "timeout",
115};
116
117export 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.
135export const buildWslNodeEnvPreamble = (
136 nodeEngineRange?: string | null,
137): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })}
138ensure_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.
144const 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
213const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
214
215const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4;
216
217export 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
222const NODE_PTY_PROBE_SCRIPT = (
223 linuxServerDir: string,
224) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)"
225printf 'resolvedPath:%s\\n' "$PATH"
226cd ${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).
235try { require.resolve("effect"); } catch (_e) { process.exit(3); }
236const fs = require("node:fs");
237const path = require("node:path");
238const 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.
245const expected = {
246 arch: process.arch,
247 nodePtyVersion: require("node-pty/package.json").version,
248};
249const prebuildDir = path.join(pkgDir, "prebuilds", "linux-" + process.arch);
250const marker = path.join(prebuildDir, "t3code-wsl-node-pty.json");
251const binary = path.join(prebuildDir, "pty.node");
252if (!fs.existsSync(marker) || !fs.existsSync(binary)) process.exit(${NODE_PTY_PREBUILD_MISSING_EXIT_CODE});
253require("node-pty");
254const actual = JSON.parse(fs.readFileSync(marker, "utf8"));
255for (const key of Object.keys(expected)) {
256 if (actual[key] !== expected[key]) process.exit(2);
257}
258NODE`;
259
260const 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
270const 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
287export interface ToolchainReport {
288 readonly missingTools: ReadonlyArray<string>;
289 readonly nodeVersion: string | null;
290}
291
292export 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.
311export 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.
324export 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
332export 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
373const 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
545export 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
589const 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
611const 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
645const IPV4_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
646
647const 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
682const 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
720const 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
732export 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
746export 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
773export 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 @@
1import { describe, it, expect } from "vite-plus/test";
2
3import {
4 DISTRO_NAME_PATTERN,
5 extractDistroFromUncPath,
6 isValidDistroName,
7 parseWslDistroList,
8 resolveWslHomeUncPath,
9 resolveWslPickFolderDefaultPath,
10 wslUncPathToLinuxPath,
11} from "./wslPathParsing.ts";
12
13function makeUtf16LeBuffer(text: string): Buffer {
14 return Buffer.from("" + text, "utf16le");
15}
16
17describe("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
81describe("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
101describe("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
119describe("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
142describe("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
187describe("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 @@
1export interface WslDistro {
2 readonly name: string;
3 readonly isDefault: boolean;
4 readonly version: 1 | 2;
5}
6
7export 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.
13export const DISTRO_NAME_PATTERN = /^\w(?:[\w \-.]*\w)?$/;
14
15export 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.
44export 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
51export 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
64export 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
72export 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
127export 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 );
139139
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");
145146
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;
171173
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"));
174182
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 };
235235
236const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);236const 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.
245const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24);
237const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";246const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
238const PAIRING_TOKEN_LENGTH = 12;247const PAIRING_TOKEN_LENGTH = 12;
239const PAIRING_TOKEN_REJECTION_LIMIT =248const 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 }
303317
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};
224224
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.
225const isBootstrapFdPathDuplicationError = Predicate.compose(228const 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);
229232
230function resolveFdPath(fd: number, platform: NodeJS.Platform): string | undefined {233function 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";
17import * as Stream from "effect/Stream";17import * as Stream from "effect/Stream";
18import { HttpClient, HttpClientResponse } from "effect/unstable/http";18import { HttpClient, HttpClientResponse } from "effect/unstable/http";
19import { ChildProcessSpawner } from "effect/unstable/process";19import { ChildProcessSpawner } from "effect/unstable/process";
20import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
21import { SpawnExecutableResolution } from "@t3tools/shared/shell";
2022
21import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts";23import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts";
22import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts";24import * 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");
35const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode");37const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode");
36const encoder = new TextEncoder();38const encoder = new TextEncoder();
3739
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.
44const NonWindowsPlatform = Layer.succeed(HostProcessPlatform, "linux");
45
38function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities {46function 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";
10import { resolveSpawnCommand } from "@t3tools/shared/shell";
10import * as Cause from "effect/Cause";11import * as Cause from "effect/Cause";
11import * as Context from "effect/Context";12import * as Context from "effect/Context";
12import * as Data from "effect/Data";13import * 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.spawner85 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 );
36823682
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();
36863690
3687 const first = yield* bootstrapBrowserSession();3691 const first = yield* bootstrapBrowserSession();
3688 const second = yield* bootstrapBrowserSession();3692 const second = yield* bootstrapBrowserSession();
36893693
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 );
36973698
apps/web/src/authBootstrap.test.ts+43-15
...@@ -74,13 +74,16 @@ function installTestBrowser(url: string) {...@@ -74,13 +74,16 @@ function installTestBrowser(url: string) {
74function installDesktopBootstrap() {74function 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}
8588
86function sequence<A>(...values: ReadonlyArray<A>) {89function 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 });
145148
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;
147161
148 const { resolveInitialServerAuthGateState } = await import("./environments/primary");162 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
149163
...@@ -192,12 +206,15 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -192,12 +206,15 @@ describe("resolveInitialServerAuthGateState", () => {
192206
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;
201218
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 });
375392
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;
377405
378 const { resolveInitialServerAuthGateState } = await import("./environments/primary");406 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
379407
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";
9import {9import {
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";
19import { useNavigate, useParams } from "@tanstack/react-router";21import { useNavigate, useParams } from "@tanstack/react-router";
20import * as Option from "effect/Option";22import * as Option from "effect/Option";
...@@ -44,9 +46,12 @@ import {...@@ -44,9 +46,12 @@ import {
44} from "react";46} from "react";
45import { useAtomValue } from "@effect/atom-react";47import { useAtomValue } from "@effect/atom-react";
46import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext";48import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext";
49import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
50import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
47import { useHandleNewThread } from "../hooks/useHandleNewThread";51import { useHandleNewThread } from "../hooks/useHandleNewThread";
48import { useClientSettings } from "../hooks/useSettings";52import { useClientSettings } from "../hooks/useSettings";
49import { readLocalApi } from "../localApi";53import { readLocalApi } from "../localApi";
54import { desktopLocalBackendId } from "../connection/desktopLocal";
50import { filesystemEnvironment } from "../state/filesystem";55import { filesystemEnvironment } from "../state/filesystem";
51import { projectEnvironment } from "../state/projects";56import { projectEnvironment } from "../state/projects";
52import { useEnvironmentQuery } from "../state/query";57import { useEnvironmentQuery } from "../state/query";
...@@ -79,6 +84,12 @@ import { getLatestThreadForProject } from "../lib/threadSort";...@@ -79,6 +84,12 @@ import { getLatestThreadForProject } from "../lib/threadSort";
79import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils";84import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils";
80import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";85import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
81import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";86import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
87import {
88 applyWslEnvironmentConfiguration,
89 parseWslUncPath,
90 resolveProjectPickerTarget,
91 resolveWslProjectSelection,
92} from "../wslPaths";
82import {93import {
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 === null553 browseEnvironmentId === null
508 ? null554 ? null
...@@ -984,6 +1030,21 @@ function OpenCommandPaletteDialog(props: {...@@ -984,6 +1030,21 @@ function OpenCommandPaletteDialog(props: {
984 },1030 },
985 });1031 });
9861032
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 });
10181079
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;
10221088
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 }
10331099
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 }
10441110
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;
10471113
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: {
10841150
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 }
11121178
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 );
11421205
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: {
14681554
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 ]);
14911656
1492 return (1657 return (
apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx created+226
...@@ -0,0 +1,226 @@
1import type { Dispatch, ReactElement, SetStateAction } from "react";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3import {
4 type EnvironmentId,
5 ProviderDriverKind,
6 ProviderInstanceId,
7 type ServerProvider,
8} from "@t3tools/contracts";
9import { AsyncResult } from "effect/unstable/reactivity";
10
11import type {
12 LocalEnvironmentUpdateGroup,
13 ProviderUpdateCandidate,
14 ProviderUpdateRowStatus,
15} from "./ProviderUpdateLaunchNotification.logic";
16
17const testState = vi.hoisted(() => ({
18 groups: [] as LocalEnvironmentUpdateGroup[],
19 updateProvider: vi.fn(),
20}));
21
22const 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
74vi.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
85vi.mock("react/compiler-runtime", () => ({
86 c: hooks.useMemoCache,
87}));
88
89vi.mock("~/state/server", () => ({
90 serverEnvironment: { updateProvider: Symbol("updateProvider") },
91}));
92
93vi.mock("~/state/use-atom-command", () => ({
94 useAtomCommand: () => testState.updateProvider,
95}));
96
97vi.mock("./ProviderUpdateLaunchNotification.environments", () => ({
98 useLocalEnvironmentUpdateGroups: () => ({
99 groups: testState.groups,
100 isAnySettling: false,
101 }),
102}));
103
104import { ProviderUpdateEnvironmentRows } from "./ProviderUpdateEnvironmentRows";
105
106const environmentId = "env-wsl" as EnvironmentId;
107const pendingExpiryMs = 6 * 60_000;
108
109function 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
147function 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
155type RowElement = ReactElement<{
156 readonly status: ProviderUpdateRowStatus;
157 readonly onUpdate: () => void;
158}>;
159
160function 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
169async function flushPromises(): Promise<void> {
170 await Promise.resolve();
171 await Promise.resolve();
172 await Promise.resolve();
173}
174
175describe("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 @@
1import { CheckIcon } from "lucide-react";
2import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
3import type { EnvironmentId, ServerProvider } from "@t3tools/contracts";
4import {
5 isAtomCommandInterrupted,
6 squashAtomCommandFailure,
7 type AtomCommandResult,
8} from "@t3tools/client-runtime/state/runtime";
9
10import { cn } from "~/lib/utils";
11import { serverEnvironment } from "~/state/server";
12import { useAtomCommand } from "~/state/use-atom-command";
13import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments";
14import {
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";
27import { Button } from "./ui/button";
28import { Spinner } from "./ui/spinner";
29
30type 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 */
41function 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.
94const PENDING_EXPIRY_MS = 6 * 60_000;
95
96function 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
109function 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 */
159export 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 @@
1import type { ConnectionCatalogEntry } from "@t3tools/client-runtime/connection";
2import type { ServerConfig } from "@t3tools/contracts";
3import { useMemo } from "react";
4
5import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
6import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
7import {
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 */
21function isLocalConnectionTarget(target: ConnectionCatalogEntry["target"]): boolean {
22 return target._tag === "PrimaryConnectionTarget" || isDesktopLocalConnectionTarget(target);
23}
24
25function 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 */
50export 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 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";2import {
3 type EnvironmentId,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 type ServerProvider,
7} from "@t3tools/contracts";
3import * as Cause from "effect/Cause";8import * as Cause from "effect/Cause";
4import { AsyncResult } from "effect/unstable/reactivity";9import { AsyncResult } from "effect/unstable/reactivity";
510
6import {11import {
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";
2141
22const checkedAt = "2026-04-23T10:00:00.000Z";42const 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 @@
1import {1import {
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}
3032
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 */
40export function isTerminalProviderUpdatePhase(phase: ProviderUpdateToastPhase): boolean {
41 return phase === "succeeded" || phase === "failed" || phase === "unchanged";
42}
43
31export type ProviderUpdateSidebarPillTone = "loading" | "warning" | "error" | "success";44export type ProviderUpdateSidebarPillTone = "loading" | "warning" | "error" | "success";
3245
33export interface ProviderUpdateSidebarPillView {46export 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 */
574export 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.
584const PROVIDER_UPDATE_STATUS_SEVERITY: Record<string, number> = {
585 succeeded: 1,
586 queued: 2,
587 running: 2,
588 unchanged: 3,
589 failed: 4,
590};
591
592function providerUpdateOutcomeSeverity(provider: ServerProvider): number {
593 return PROVIDER_UPDATE_STATUS_SEVERITY[provider.updateState?.status ?? ""] ?? 0;
594}
595
596export 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 */
614export 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 */
640export 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
659const WSL_INSTANCE_ID_PREFIX = "wsl:";
660
661/** The distro name from a WSL backend instance id ("wsl:ubuntu" -> "ubuntu"), or null for the default. */
662export 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 */
676export 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. */
698export type EnvironmentUpdateConnectionState = "connecting" | "ready" | "disconnected" | "error";
699
700export 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
708export 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 */
725export 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). */
745export 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 */
756export 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
771export type ProviderUpdateRowStatusKind = "idle" | "loading" | "success" | "failed" | "unchanged";
772
773export interface ProviderUpdateRowStatus {
774 readonly kind: ProviderUpdateRowStatusKind;
775 readonly text: string;
776}
777
778function 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 */
795export 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 @@
1import { useNavigate } from "@tanstack/react-router";1import { useNavigate } from "@tanstack/react-router";
2import { useAtomValue } from "@effect/atom-react";
3import { DownloadIcon } from "lucide-react";2import { DownloadIcon } from "lucide-react";
4import { useCallback, useEffect, useMemo, useRef } from "react";3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";
64
7import { primaryServerProvidersAtom, serverEnvironment } from "../state/server";5import { useEnvironments } from "~/state/environments";
8import { usePrimaryEnvironment } from "../state/environments";6import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
9import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal";7import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal";
10import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils";8import { ProviderUpdateEnvironmentRows } from "./ProviderUpdateEnvironmentRows";
9import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments";
11import {10import {
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";
16import { ProviderUpdatePrimaryNotification } from "./ProviderUpdatePrimaryNotification";
23import { stackedThreadToast, toastManager } from "./ui/toast";17import { stackedThreadToast, toastManager } from "./ui/toast";
24import { useAtomCommand } from "../state/use-atom-command";
2518
26const seenProviderUpdateNotificationKeys = new Set<string>();19/**
27type ProviderUpdateToastId = ReturnType<typeof toastManager.add>;20 * True when a desktop-local secondary backend (the parallel WSL backend) is
2821 * present alongside the primary. Local secondaries connect over loopback with a
29type 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;26function 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)),
3831 [environments],
39function 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}
5934
60function 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") {40export 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 }
8042
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}
9949
100function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) {50const seenProviderUpdateNotificationKeys = new Set<string>();
101 return view.phase === "failed" || view.phase === "unchanged" || view.phase === "succeeded";51type ProviderUpdateToastId = ReturnType<typeof toastManager.add>;
102}
10352
104export 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.
56const SETTLING_GRACE_MS = 30_000;
57
58function 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();
11463
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
12574 // 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 );
14197
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 }
147105 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]);
165118
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 }
172142
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 }
181146
182 seenProviderUpdateNotificationKeys.add(notificationKey);147 seenProviderUpdateNotificationKeys.add(notificationKey);
148 hasInteractedRef.current = false;
183149
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.
192153 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 };
264159
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 > 0176 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 ]);
312196
313 return null;197 return null;
apps/web/src/components/ProviderUpdatePrimaryNotification.tsx created+331
...@@ -0,0 +1,331 @@
1import { useNavigate } from "@tanstack/react-router";
2import { useAtomValue } from "@effect/atom-react";
3import { DownloadIcon } from "lucide-react";
4import { useCallback, useEffect, useMemo, useRef } from "react";
5import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";
6
7import { primaryServerProvidersAtom, serverEnvironment } from "../state/server";
8import { usePrimaryEnvironment } from "../state/environments";
9import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal";
10import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils";
11import {
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";
23import { stackedThreadToast, toastManager } from "./ui/toast";
24import { useAtomCommand } from "../state/use-atom-command";
25
26const seenProviderUpdateNotificationKeys = new Set<string>();
27type ProviderUpdateToastId = ReturnType<typeof toastManager.add>;
28
29type 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
39function 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
60function 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
100function 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 */
109export 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";
74import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
75import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
72import { isElectron } from "../env";76import { isElectron } from "../env";
73import { APP_STAGE_LABEL } from "../branding";77import { APP_STAGE_LABEL } from "../branding";
74import { useOpenPrLink } from "../lib/openPullRequestLink";78import { 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 environment415 // For grouped projects, the thread may belong to a different environment
403 // than the representative project. Look up the thread's own project cwd416 // 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 <TooltipTrigger839 <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 <span2263 <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});
24542473
2474function 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
2455type SortableProjectHandleProps = Pick<2549type 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 ]);
31013212
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 @@
1import type { DesktopWslState } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3import { applyWslEnableSelection } from "./ConnectionsSettings.logic";
4
5const baseWslState: DesktopWslState = {
6 enabled: false,
7 distro: null,
8 available: true,
9 wslOnly: true,
10 distros: [],
11 preflightError: null,
12};
13
14describe("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 @@
1import type { DesktopBridge, DesktopWslState } from "@t3tools/contracts";
2
3type WslEnableBridge = Pick<DesktopBridge, "setWslBackendEnabled" | "setWslDistro" | "setWslOnly">;
4
5export 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";
32import {33import {
...@@ -48,6 +49,7 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";...@@ -48,6 +49,7 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
48import { cn } from "../../lib/utils";49import { cn } from "../../lib/utils";
49import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";50import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
50import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";51import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
52import { applyWslEnableSelection } from "./ConnectionsSettings.logic";
51import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig";53import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig";
52import {54import {
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";
82import { QRCodeSvg } from "../ui/qr-code";84import { QRCodeSvg } from "../ui/qr-code";
83import { Skeleton } from "../ui/skeleton";85import { Skeleton } from "../ui/skeleton";
84import { Spinner } from "../ui/spinner";86import { Spinner } from "../ui/spinner";
87import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
85import { Switch } from "../ui/switch";88import { Switch } from "../ui/switch";
86import { stackedThreadToast, toastManager } from "../ui/toast";89import { stackedThreadToast, toastManager } from "../ui/toast";
87import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";90import { 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";
117import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
114import { useUiStateStore } from "~/uiStateStore";118import { useUiStateStore } from "~/uiStateStore";
115import { resolveServerConfigVersionMismatch } from "~/versionSkew";119import { resolveServerConfigVersionMismatch } from "~/versionSkew";
116import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState";120import { 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";
134import { desktopSshHostsStateAtom } from "~/state/desktopSshHosts";138import { desktopSshHostsStateAtom } from "~/state/desktopSshHosts";
139import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState";
135import {140import {
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;
145const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = [];150const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = [];
146const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray<DesktopDiscoveredSshHost> = [];151const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray<DesktopDiscoveredSshHost> = [];
147152
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.
156const BACKEND_VALUE_DEFAULT_WSL = "backend:default-wsl";
157const BACKEND_VALUE_WSL_OFF = "backend:wsl-off";
158
148const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, {159const 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);
14631474
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 <Button1523 {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 {isConnected1529 </Button>
1513 ? removingEnvironmentId === environmentId1530 }
1514 ? "Disconnecting…"1531 />
1515 : "Disconnect"1532 <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight">
1516 : isConnecting1533 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 ? desktopSshHostsStateAtom2160 ? 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 <SettingsRow3168 <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 <AlertDialog3494 <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 @@
1import {
2 BearerConnectionTarget,
3 PrimaryConnectionTarget,
4} from "@t3tools/client-runtime/connection";
5import { EnvironmentId, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";
7
8import {
9 createDesktopSecondaryBootstrapsReader,
10 desktopLocalBackendId,
11 desktopLocalConnectionId,
12 isDesktopLocalConnectionTarget,
13} from "./desktopLocal";
14
15describe("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
40describe("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 @@
1import type { ConnectionTarget } from "@t3tools/client-runtime/connection";
2import {
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 */
20export const DESKTOP_LOCAL_CONNECTION_ID_PREFIX = "local:";
21
22export function desktopLocalConnectionId(backendId: string): string {
23 return `${DESKTOP_LOCAL_CONNECTION_ID_PREFIX}${backendId}`;
24}
25
26export 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
35export function desktopLocalBackendId(target: ConnectionTarget): string | null {
36 return isDesktopLocalConnectionTarget(target)
37 ? target.connectionId.slice(DESKTOP_LOCAL_CONNECTION_ID_PREFIX.length)
38 : null;
39}
40
41export type DesktopSecondaryBootstrapsRead =
42 | {
43 readonly _tag: "Success";
44 readonly bootstraps: ReadonlyArray<DesktopEnvironmentBootstrap>;
45 }
46 | {
47 readonly _tag: "Failure";
48 readonly cause: unknown;
49 };
50
51export 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 */
62export 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
92const desktopSecondaryBootstrapsReader = createDesktopSecondaryBootstrapsReader(
93 () => window.desktopBridge,
94);
95
96/** Read the topology while preserving failures for platform cache policy. */
97export function readDesktopSecondaryBootstrapsResult(): DesktopSecondaryBootstrapsRead {
98 return desktopSecondaryBootstrapsReader.readResult();
99}
100
101/** Read the latest successful topology snapshot for renderer consumers. */
102export 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 @@
1import {1import {
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";
7import { describe, expect, it } from "@effect/vitest";8import { describe, expect, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
910
10import { provisionDesktopSshEnvironment } from "./platform.ts";11import {
12 canRetainCachedPlatformRegistrationAfterRefreshFailure,
13 canReuseCachedPlatformRegistration,
14 primaryRegistrationToRetainAfterTopologyRead,
15 provisionDesktopSshEnvironment,
16 readPrimaryEnvironmentTargetResult,
17 secondaryRegistrationsToRetainAfterTopologyRead,
18 secondaryBearerExpiresAtEpochMs,
19 secondaryBearerRefreshAtEpochMs,
20} from "./platform.ts";
1121
12const TARGET: DesktopSshEnvironmentTarget = {22const 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
100describe("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
190describe("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";
10import {10import {
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";
24import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization";
19import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";25import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
20import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay";26import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
21import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc";27import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc";
22import {28import {
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";
35import * as Clock from "effect/Clock";
27import * as Context from "effect/Context";36import * as Context from "effect/Context";
28import * as Effect from "effect/Effect";37import * as Effect from "effect/Effect";
29import * as Layer from "effect/Layer";38import * as Layer from "effect/Layer";
30import * as Option from "effect/Option";39import * as Option from "effect/Option";
31import * as Queue from "effect/Queue";40import * as Queue from "effect/Queue";
32import * as Schedule from "effect/Schedule";41import * as Ref from "effect/Ref";
33import * as Stream from "effect/Stream";42import * as Stream from "effect/Stream";
43import { FetchHttpClient } from "effect/unstable/http";
3444
35import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth";45import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth";
36import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer";46import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer";
37import { readPrimaryEnvironmentTarget } from "../environments/primary/target";47import {
48 readPrimaryEnvironmentTarget,
49 type PrimaryEnvironmentTarget,
50} from "../environments/primary/target";
38import { clearComposerDraftsEnvironment } from "../composerDraftStore";51import { clearComposerDraftsEnvironment } from "../composerDraftStore";
39import { isHostedStaticApp } from "../hostedPairing";52import { isHostedStaticApp } from "../hostedPairing";
40import { appAtomRegistry } from "../rpc/atomRegistry";53import { appAtomRegistry } from "../rpc/atomRegistry";
41import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState";54import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState";
55import {
56 desktopLocalConnectionId,
57 readDesktopSecondaryBootstrapsResult,
58 type DesktopSecondaryBootstrapsRead,
59} from "./desktopLocal";
42import { connectionStorageLayer } from "./storage";60import { connectionStorageLayer } from "./storage";
4361
44let nextObservedRpcRequestId = 0;62let nextObservedRpcRequestId = 0;
...@@ -267,14 +285,7 @@ const capabilitiesLayer = Layer.effectContext(...@@ -267,14 +285,7 @@ const capabilitiesLayer = Layer.effectContext(
267285
268const loadPrimaryConnectionRegistration = Effect.fn(286const 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});
290301
291const 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.
294305const loadSecondaryConnectionRegistration = Effect.fn(
295const 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});
313359
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.
363const PLATFORM_POLL_INTERVAL = "3 seconds";
364const SECONDARY_BEARER_REFRESH_SKEW_MS = 5_000;
365
366export function secondaryBearerExpiresAtEpochMs(
367 issuedAtEpochMs: number,
368 expiresInSeconds: number,
369): number {
370 return issuedAtEpochMs + Math.max(0, expiresInSeconds * 1_000);
371}
372
373export 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
384interface CachedPlatformRegistration {
385 readonly signature: string;
386 readonly registration: PlatformConnectionRegistration;
387 readonly expiresAtEpochMs?: number;
388 readonly refreshAtEpochMs?: number;
389}
390
391export type PrimaryEnvironmentTargetRead =
392 | {
393 readonly _tag: "Success";
394 readonly target: PrimaryEnvironmentTarget | null;
395 }
396 | {
397 readonly _tag: "Failure";
398 readonly cause: unknown;
399 };
400
401export 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
411export 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
418export 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
429export 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
441export 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
456const 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
314const environmentOwnedDataCleanupLayer = Layer.succeed(576const 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 @@
1import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts";
2import { useEffect, useState } from "react";
3
4import { readDesktopSecondaryBootstraps } from "./desktopLocal";
5
6const 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 */
15export 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";
10import { EnvironmentHttpCommonError } from "@t3tools/contracts";10import { EnvironmentHttpCommonError, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
11import type { EnvironmentHttpCommonError as EnvironmentHttpCommonErrorType } from "@t3tools/contracts";11import type { EnvironmentHttpCommonError as EnvironmentHttpCommonErrorType } from "@t3tools/contracts";
12import * as DateTime from "effect/DateTime";12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";13import * 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}
177177
178function getDesktopBootstrapCredential(): string | null {178function 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 > 0180 // mints one tokenRef and feeds it to both resolvers), so picking the
181 ? bootstrap.bootstrapToken181 // 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}
184188
...@@ -526,6 +530,16 @@ export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGat...@@ -526,6 +530,16 @@ export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGat
526 });530 });
527}531}
528532
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.
537export async function reauthenticatePrimaryEnvironment(): Promise<ServerAuthGateState> {
538 resolvedAuthenticatedGateState = null;
539 bootstrapPromise = null;
540 return resolveInitialServerAuthGateState();
541}
542
529export function __resetServerAuthBootstrapForTests() {543export 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 });
222228
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 @@
1import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts";1import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
33
4const PrimaryEnvironmentTargetSource = Schema.Literals([4const PrimaryEnvironmentTargetSource = Schema.Literals([
...@@ -76,7 +76,11 @@ export interface PrimaryEnvironmentTarget {...@@ -76,7 +76,11 @@ export interface PrimaryEnvironmentTarget {
76const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);76const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
7777
78function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | null {78function 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}
8185
82function parseTargetUrl(input: {86function 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 !== null97 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 = members100 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));
97111
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 @@
1import type { DesktopWslState } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import { AtomRegistry } from "effect/unstable/reactivity";
5import { describe, expect, it, vi } from "vite-plus/test";
6
7import { createDesktopWslStateAtom } from "./desktopWslState";
8
9const 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
24describe("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 @@
1import type { DesktopBridge, DesktopWslState } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import { Atom } from "effect/unstable/reactivity";
5
6import { appAtomRegistry } from "~/rpc/atomRegistry";
7
8const DESKTOP_WSL_STATE_STALE_TIME_MS = 30_000;
9
10type DesktopWslStateBridge = Pick<DesktopBridge, "getWslState">;
11
12class DesktopWslStateUnavailableError extends Schema.TaggedErrorClass<DesktopWslStateUnavailableError>()(
13 "DesktopWslStateUnavailableError",
14 {},
15) {
16 override get message(): string {
17 return "Desktop WSL state is unavailable.";
18 }
19}
20
21class 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
30function getDesktopWslStateBridge(): DesktopWslStateBridge | undefined {
31 return typeof window === "undefined" ? undefined : window.desktopBridge;
32}
33
34export 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
56export const desktopWslStateAtom = createDesktopWslStateAtom(getDesktopWslStateBridge);
57
58export function refreshDesktopWslState(): void {
59 appAtomRegistry.refresh(desktopWslStateAtom);
60}
apps/web/src/wslPaths.test.ts created+297
...@@ -0,0 +1,297 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 applyWslEnvironmentConfiguration,
5 parseWslUncPath,
6 resolveProjectPickerTarget,
7 resolveWslProjectSelection,
8} from "./wslPaths";
9
10describe("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
31describe("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
88describe("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
198describe("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 @@
1export interface WslUncPath {
2 readonly distro: string;
3 readonly linuxPath: string;
4}
5
6export interface WslEnvironmentCandidate<TEnvironmentId extends string = string> {
7 readonly environmentId: TEnvironmentId;
8 readonly backendId: string;
9 readonly runningDistro: string | null;
10}
11
12export 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
22export interface WslProjectSelection<TEnvironmentId extends string = string> extends WslUncPath {
23 readonly environmentId: TEnvironmentId;
24}
25
26const WSL_UNC_PREFIXES = ["\\\\wsl.localhost\\", "\\\\wsl$\\"] as const;
27const WSL_DISTRO_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
28const WSL_DEFAULT_BACKEND_ID = "wsl:default";
29
30export 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
52export 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
77interface ConfiguredWslBackend {
78 readonly backendId: string;
79 readonly runningDistro: string | null;
80}
81
82function 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
105export 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
127export 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]);
90export type ConnectionRegistration = typeof ConnectionRegistration.Type;90export type ConnectionRegistration = typeof ConnectionRegistration.Type;
9191
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 */
101export const PlatformConnectionRegistration = Schema.Union([
102 PrimaryConnectionRegistration,
103 BearerConnectionRegistration,
104]);
105export type PlatformConnectionRegistration = typeof PlatformConnectionRegistration.Type;
106
92export function connectionRegistrationTarget(107export 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";
16import {16import {
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";
24import * as ConnectionCredentialStore from "./credentialStore.ts";
23import * as ConnectionProfileStore from "./profileStore.ts";25import * as ConnectionProfileStore from "./profileStore.ts";
24import * as Connectivity from "./connectivity.ts";26import * as Connectivity from "./connectivity.ts";
25import type {27import 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 });
404410
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 });
418423
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 }
442462
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 });
447543
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 @@
1import * as Context from "effect/Context";1import * as Context from "effect/Context";
2import type * as Stream from "effect/Stream";2import type * as Stream from "effect/Stream";
33
4import type { PrimaryConnectionRegistration } from "../connection/catalog.ts";4import type { PlatformConnectionRegistration } from "../connection/catalog.ts";
55
6export class PlatformConnectionSource extends Context.Service<6export 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 @@
1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";
3
4import { DesktopEnvironmentBootstrapSchema } from "./ipc.ts";
5
6describe("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});
261261
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.
266export const PRIMARY_LOCAL_ENVIRONMENT_ID = "primary";
267
262export interface DesktopEnvironmentBootstrap {268export 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}
268282
269export const DesktopEnvironmentBootstrapSchema = Schema.Struct({283export 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({
411427
412export interface PickFolderOptions {428export 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}
415437
416export const PickFolderOptionsSchema = Schema.Struct({438export 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
443export interface DesktopWslDistro {
444 name: string;
445 isDefault: boolean;
446 version: 1 | 2;
447}
448
449export const DesktopWslDistroSchema = Schema.Struct({
450 name: Schema.String,
451 isDefault: Schema.Boolean,
452 version: Schema.Literals([1, 2]),
453});
454
455export 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
476export 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});
419484
420/**485/**
...@@ -881,7 +946,10 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({...@@ -881,7 +946,10 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({
881946
882export interface DesktopBridge {947export 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 @@
1import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Ref from "effect/Ref";
5import * as Schedule from "effect/Schedule";
6import { HttpClient, HttpClientRequest } from "effect/unstable/http";
7
8export 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 */
16export 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 */
51export 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";
5import {
6 describeReadinessCause,
7 waitForHttpReady as waitForHttpReadyShared,
8} from "@t3tools/shared/httpReadiness";
5import * as NetService from "@t3tools/shared/Net";9import * as NetService from "@t3tools/shared/Net";
6import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson";10import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson";
7import { satisfiesSemverRange } from "@t3tools/shared/semver";11import { satisfiesSemverRange } from "@t3tools/shared/semver";
8import * as Context from "effect/Context";12import * as Context from "effect/Context";
9import * as Deferred from "effect/Deferred";13import * as Deferred from "effect/Deferred";
10import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";15import * as Exit from "effect/Exit";
13import * as FileSystem from "effect/FileSystem";16import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";17import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Path from "effect/Path";18import * as Path from "effect/Path";
17import * as Ref from "effect/Ref";
18import * as Schedule from "effect/Schedule";
19import * as Schema from "effect/Schema";19import * as Schema from "effect/Schema";
20import * as Scope from "effect/Scope";20import * as Scope from "effect/Scope";
21import * as Stream from "effect/Stream";21import * as Stream from "effect/Stream";
22import { HttpClient, HttpClientRequest } from "effect/unstable/http";22import { HttpClient } from "effect/unstable/http";
23import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";23import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2424
25import {25import {
...@@ -232,33 +232,9 @@ function applyScriptPlaceholders(...@@ -232,33 +232,9 @@ function applyScriptPlaceholders(
232 return result;232 return result;
233}233}
234234
235export 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 {237export { 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}
262238
263export const REMOTE_PICK_PORT_SCRIPT = `const fs = require("node:fs");239export const REMOTE_PICK_PORT_SCRIPT = `const fs = require("node:fs");
264const net = require("node:net");240const 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}
668644
669function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string {645export 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});
867843
868export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(function* (input: {844export 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 }) => {
893858 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 {
955868 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});
982884
983function isLoopbackHostname(hostname: string): boolean {885function isLoopbackHostname(hostname: string): boolean {
984 const normalized = hostname886 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]
914915
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]
919921
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]
924927
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]
929933
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]
980985
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]
986992
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]
992999
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]
9981006
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]
15861595
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]
15911601
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]
15961607
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]
16011613
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]
25782591
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]
25832597
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]
25882603
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]
25932609
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]
26792696
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]
26842702
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]
26892708
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]
26942714
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]
26992720
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]
27042726
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]
27092732
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]
27142738
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]
27202745
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]
27262752
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]
27322759
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]
27382766
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]
27442773
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]
27502780
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]
27562787
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]
27622794
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]
32203253
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]
32263260
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]
32323267
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]
32383274
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]
32443281
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]
32503288
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]
32563295
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]
32623302
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]
33643405
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]
33703412
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]
33763419
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]
33823426
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]
33883433
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]
33943440
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]
34003447
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]
34063454
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]
38923941
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]
38983948
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]
39043955
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]
39103962
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]
39163969
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]
39223976
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]
39283983
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]
39343990
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]
39403997
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]
39464004
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]
39524011
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]
39584018
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]
39644025
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]
39704032
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]
39764039
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]
39824046
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]
39884053
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]
39944060
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]
41304197
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]
41354203
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]
41404209
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]
41454215
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]
41504221
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]
41554227
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]
41604233
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]
41654239
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]
41704245
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]
41754251
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]
41804257
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]
41854263
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]
41904269
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]
44244504
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]
44304511
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]
44364518
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]
44424525
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]
44484532
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]
44544539
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]
44604546
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]
44664553
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]
49385026
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]
49445033
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]
49505040
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]
49565047
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]
50385130
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]
50445137
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]
50505144
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]
50565151
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]
74197515
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]
74257522
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]
74317529
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]
74377536
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]
74437543
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]
74497550
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]
74557557
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]
74617564
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]
74677571
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]
74737578
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]
74797585
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]
74857592
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.014377 babel-plugin-react-compiler: 1.0.0
1427114378
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.114381 '@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 - bufferutil14386 - bufferutil
14280 - msw14387 - msw
14281 - utf-8-validate14388 - utf-8-validate
14282 - vite14389 - vite
1428314390
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.114393 '@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.014397 pngjs: 7.0.0
14291 sirv: 3.0.214398 sirv: 3.0.2
14292 tinyrainbow: 3.1.014399 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 - bufferutil14403 - bufferutil
...@@ -19894,8 +20001,8 @@ snapshots:...@@ -19894,8 +20001,8 @@ snapshots:
19894 dependencies:20001 dependencies:
19895 '@oxc-project/types': 0.136.020002 '@oxc-project/types': 0.136.0
19896 '@oxlint/plugins': 1.68.020003 '@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.920006 '@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.920008 '@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.020016 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.120020 '@voidzero-dev/vite-plus-darwin-arm64': 0.2.1
19914 '@voidzero-dev/vite-plus-darwin-x64': 0.2.120021 '@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)'
1995620063
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.920066 '@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.020085 why-is-node-running: 2.3.0
19979 optionalDependencies:20086 optionalDependencies:
19980 '@types/node': 24.12.420087 '@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 - msw20090 - msw
1998420091
pnpm-workspace.yaml+14
...@@ -5,6 +5,20 @@ packages:...@@ -5,6 +5,20 @@ packages:
5 - packages/*5 - packages/*
6 - scripts6 - scripts
77
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.
11supportedArchitectures:
12 os:
13 - current
14 - linux
15 cpu:
16 - current
17 - x64
18 libc:
19 - current
20 - glibc
21
8catalog:22catalog:
9 "@clerk/backend": 3.8.3-snapshot.v2026062223415123 "@clerk/backend": 3.8.3-snapshot.v20260622234151
10 "@clerk/clerk-js": 6.21.0-snapshot.v2026062223415124 "@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 );
402412
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});
5253
...@@ -55,6 +56,9 @@ const RepoRoot = Effect.service(Path.Path).pipe(...@@ -55,6 +56,9 @@ const RepoRoot = Effect.service(Path.Path).pipe(
55);56);
56const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString);57const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString);
57const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig));58const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig));
59const decodeNodePtyManifest = Schema.decodeUnknownEffect(
60 Schema.fromJsonString(Schema.Struct({ version: Schema.String })),
61);
58const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig));62const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig));
5963
60const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () {64const 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}
111116
112function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {117function 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}
362367
368export 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
379export 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
363export class LinuxIconResizeError extends Schema.TaggedErrorClass<LinuxIconResizeError>()(391export 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}
521550
522interface StagePackageJson {551interface 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});
909959
910const MockUpdateServerPortSchema = Schema.NumberFromString.check(960const 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 ));
9901040
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});
10051059
...@@ -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});
13921461
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.
1470const 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
1393const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (1532const 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);
16181768
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 relying1780 // 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)),