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:
252252 echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT"
253253 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
255302 build:
256303 name: Build ${{ matrix.label }}
257 needs: [preflight, relay_public_config]
258 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }}
304 # build_wsl_node_pty stays in `needs` so it runs first and its artifact is
305 # available to download, but only the Windows matrix entry consumes it. We
306 # therefore gate the job on preflight + relay (must succeed) WITHOUT requiring
307 # build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux
308 # builds. `!cancelled()` (not `!failure()`) lets the job run even when
309 # build_wsl_node_pty failed; the Windows-only download step below then fails
310 # that single platform if the prebuild is missing.
311 needs: [preflight, relay_public_config, build_wsl_node_pty]
312 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }}
259313 runs-on: ${{ matrix.runner }}
260314 timeout-minutes: 30
261315 env:
......@@ -323,6 +377,13 @@ jobs:
323377 - name: Align package versions to release version
324378 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
326387 - name: Install Spectre-mitigated MSVC libs
327388 if: matrix.platform == 'win'
328389 shell: pwsh
......@@ -476,6 +537,10 @@ jobs:
476537 echo "macOS signing disabled (missing one or more Apple signing secrets)."
477538 fi
478539 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")
479544 if has_all \
480545 "$AZURE_TENANT_ID" \
481546 "$AZURE_CLIENT_ID" \
apps/desktop/src/app/DesktopApp.ts+32-5
......@@ -13,7 +13,8 @@ import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
1313import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
1414import * as DesktopClerk from "./DesktopClerk.ts";
1515import * 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";
1718import * as DesktopEnvironment from "./DesktopEnvironment.ts";
1819import * as DesktopLifecycle from "./DesktopLifecycle.ts";
1920import * as DesktopObservability from "./DesktopObservability.ts";
......@@ -23,6 +24,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
2324import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
2425import * as DesktopState from "./DesktopState.ts";
2526import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
27import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts";
2628
2729const DEFAULT_DESKTOP_BACKEND_PORT = 3773;
2830const MAX_TCP_PORT = 65_535;
......@@ -135,11 +137,14 @@ const fatalStartupCause = <E>(stage: string, cause: Cause.Cause<E>) =>
135137 handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause)));
136138
137139const bootstrap = Effect.gen(function* () {
138 const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
140 const pool = yield* DesktopBackendPool.DesktopBackendPool;
141 const primaryBackend = yield* pool.primary;
139142 const state = yield* DesktopState.DesktopState;
140143 const environment = yield* DesktopEnvironment.DesktopEnvironment;
141144 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
142145 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
146 const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;
147 const desktopWindow = yield* DesktopWindow.DesktopWindow;
143148 yield* logBootstrapInfo("bootstrap start");
144149
145150 if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) {
......@@ -193,8 +198,20 @@ const bootstrap = Effect.gen(function* () {
193198 yield* logBootstrapInfo("bootstrap ipc handlers registered");
194199
195200 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;
197209 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);
198215 }
199216}).pipe(Effect.withSpan("desktop.bootstrap"));
200217
......@@ -241,10 +258,20 @@ const scopedProgram = Effect.scoped(
241258 yield* Effect.annotateCurrentSpan({ scope: "desktop", runId });
242259
243260 const shutdown = yield* DesktopShutdown.DesktopShutdown;
244 const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
245261
246262 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)),
248275 );
249276
250277 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", () => {
125125 }).pipe(Effect.provide(environmentLayer));
126126
127127 yield* Effect.gen(function* () {
128 const outputLog = yield* DesktopObservability.DesktopBackendOutputLog;
128 const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
129 const outputLog = yield* factory.forInstance("primary");
129130 yield* outputLog.writeSessionBoundary({
130131 phase: "START",
131132 details: "pid=123 port=3773 cwd=/repo",
......@@ -145,6 +146,7 @@ describe("DesktopObservability", () => {
145146 assert.equal(boundary.level, "INFO");
146147 assert.equal(boundary.annotations.component, "desktop-backend-child");
147148 assert.equal(boundary.annotations.runId, "test-run");
149 assert.equal(boundary.annotations.instanceId, "primary");
148150 assert.equal(boundary.annotations.phase, "START");
149151 assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo");
150152
......@@ -152,6 +154,7 @@ describe("DesktopObservability", () => {
152154 assert.equal(output.level, "INFO");
153155 assert.equal(output.annotations.component, "desktop-backend-child");
154156 assert.equal(output.annotations.runId, "test-run");
157 assert.equal(output.annotations.instanceId, "primary");
155158 assert.equal(output.annotations.stream, "stdout");
156159 assert.equal(output.annotations.text, "hello server\n");
157160 }).pipe(
apps/desktop/src/app/DesktopObservability.ts+376-8
......@@ -1,20 +1,65 @@
1import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
12import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";
23import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
4import * as Context from "effect/Context";
5import * as DateTime from "effect/DateTime";
36import * as Effect from "effect/Effect";
47import * as FileSystem from "effect/FileSystem";
58import * as Layer from "effect/Layer";
69import * as Logger from "effect/Logger";
710import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as PlatformError from "effect/PlatformError";
813import * 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";
919import * as Tracer from "effect/Tracer";
1020import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
1121
12import * as DesktopBackendOutputLogModule from "./DesktopBackendOutputLog.ts";
1322import * 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";
1527const 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
1964export type DesktopLogAnnotations = Record<string, unknown>;
2065
......@@ -50,7 +95,166 @@ export function makeComponentLogger(component: string): DesktopComponentLogger {
5095 };
5196}
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* () {
54258 const fileSystem = yield* FileSystem.FileSystem;
55259 const environment = yield* DesktopEnvironment.DesktopEnvironment;
56260 const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option);
......@@ -70,6 +274,170 @@ const resolveOtlpTracesUrl = Effect.gen(function* () {
70274 return yield* readPersistedOtlpTracesUrl;
71275});
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
73441const desktopLoggerLayer = Layer.mergeAll(
74442 Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }),
75443 Layer.succeed(References.MinimumLogLevel, "Info"),
......@@ -82,8 +450,8 @@ const tracerLayer = Layer.unwrap(
82450 const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson");
83451 const sink = yield* makeTraceSink({
84452 filePath: tracePath,
85 maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES,
86 maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES,
453 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,
454 maxFiles: DESKTOP_LOG_FILE_MAX_FILES,
87455 batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,
88456 });
89457 const delegate = Option.isNone(otlpTracesUrl)
......@@ -101,8 +469,8 @@ const tracerLayer = Layer.unwrap(
101469 });
102470 const tracer = yield* makeLocalFileTracer({
103471 filePath: tracePath,
104 maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES,
105 maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES,
472 maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,
473 maxFiles: DESKTOP_LOG_FILE_MAX_FILES,
106474 batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,
107475 sink,
108476 ...(delegate ? { delegate } : {}),
......@@ -113,7 +481,7 @@ const tracerLayer = Layer.unwrap(
113481).pipe(Layer.provideMerge(OtlpSerialization.layerJson));
114482
115483export const layer = Layer.mergeAll(
116 DesktopBackendOutputLogModule.layer,
484 backendOutputLogFactoryLayer,
117485 desktopLoggerLayer,
118486 tracerLayer,
119487 Layer.succeed(Tracer.MinimumTraceLevel, "Info"),
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+556-14
......@@ -4,13 +4,19 @@ import * as Effect from "effect/Effect";
44import * as FileSystem from "effect/FileSystem";
55import * as Layer from "effect/Layer";
66import * as Logger from "effect/Logger";
7import * as ManagedRuntime from "effect/ManagedRuntime";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
710import * as PlatformError from "effect/PlatformError";
811import * as Schema from "effect/Schema";
12import { ChildProcessSpawner } from "effect/unstable/process";
913
1014import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1115import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
1216import * as DesktopConfig from "../app/DesktopConfig.ts";
1317import * as DesktopServerExposure from "./DesktopServerExposure.ts";
18import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
19import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
1420
1521const PersistedServerObservabilitySettingsDocument = Schema.Struct({
1622 observability: Schema.Struct({
......@@ -45,19 +51,22 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp
4551function makeEnvironmentLayer(
4652 baseDir: string,
4753 options?: {
54 readonly appPath?: string;
4855 readonly isPackaged?: boolean;
4956 readonly devServerUrl?: string;
57 readonly platform?: NodeJS.Platform;
58 readonly resourcesPath?: string;
5059 },
5160) {
5261 return DesktopEnvironment.layer({
5362 dirname: "/repo/apps/desktop/src",
5463 homeDirectory: baseDir,
55 platform: "darwin",
64 platform: options?.platform ?? "darwin",
5665 processArch: "x64",
5766 appVersion: "1.2.3",
58 appPath: "/repo",
67 appPath: options?.appPath ?? "/repo",
5968 isPackaged: options?.isPackaged ?? true,
60 resourcesPath: "/missing/resources",
69 resourcesPath: options?.resourcesPath ?? "/missing/resources",
6170 runningUnderArm64Translation: false,
6271 }).pipe(
6372 Layer.provide(
......@@ -75,6 +84,14 @@ function makeEnvironmentLayer(
7584 );
7685}
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
7895const withHarness = <A, E, R>(
7996 effect: Effect.Effect<
8097 A,
......@@ -95,6 +112,8 @@ const withHarness = <A, E, R>(
95112 Effect.provide(
96113 DesktopBackendConfiguration.layer.pipe(
97114 Layer.provideMerge(serverExposureLayer),
115 Layer.provideMerge(DesktopAppSettings.layerTest()),
116 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
98117 Layer.provideMerge(makeEnvironmentLayer(baseDir)),
99118 ),
100119 ),
......@@ -102,14 +121,14 @@ const withHarness = <A, E, R>(
102121 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
103122
104123describe("DesktopBackendConfiguration", () => {
105 it.effect("resolves backend start config with a stable scoped bootstrap token", () =>
124 it.effect("resolvePrimary produces a stable scoped bootstrap token", () =>
106125 withHarness(
107126 Effect.gen(function* () {
108127 const environment = yield* DesktopEnvironment.DesktopEnvironment;
109128 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
110129
111 const first = yield* configuration.resolve;
112 const second = yield* configuration.resolve;
130 const first = yield* configuration.resolvePrimary;
131 const second = yield* configuration.resolvePrimary;
113132
114133 assert.equal(first.executablePath, process.execPath);
115134 assert.equal(first.entryPath, environment.backendEntryPath);
......@@ -133,7 +152,167 @@ describe("DesktopBackendConfiguration", () => {
133152 ),
134153 );
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", () =>
137316 withHarness(
138317 Effect.gen(function* () {
139318 const fileSystem = yield* FileSystem.FileSystem;
......@@ -153,18 +332,18 @@ describe("DesktopBackendConfiguration", () => {
153332 }),
154333 );
155334
156 const config = yield* configuration.resolve;
335 const config = yield* configuration.resolvePrimary;
157336 assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces");
158337 assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics");
159338 }),
160339 ),
161340 );
162341
163 it.effect("omits backend observability endpoints when settings are missing", () =>
342 it.effect("resolvePrimary omits backend observability endpoints when settings are missing", () =>
164343 withHarness(
165344 Effect.gen(function* () {
166345 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
167 const config = yield* configuration.resolve;
346 const config = yield* configuration.resolvePrimary;
168347
169348 assert.isUndefined(config.bootstrap.otlpTracesUrl);
170349 assert.isUndefined(config.bootstrap.otlpMetricsUrl);
......@@ -175,10 +354,11 @@ describe("DesktopBackendConfiguration", () => {
175354 it.effect("logs structured context when persisted observability settings cannot be read", () =>
176355 Effect.gen(function* () {
177356 const fileSystem = yield* FileSystem.FileSystem;
357 const path = yield* Path.Path;
178358 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
179359 prefix: "t3-desktop-backend-config-test-",
180360 });
181 const settingsPath = `${baseDir}/userdata/settings.json`;
361 const settingsPath = path.join(baseDir, "userdata", "settings.json");
182362 const cause = PlatformError.systemError({
183363 _tag: "PermissionDenied",
184364 module: "FileSystem",
......@@ -198,12 +378,14 @@ describe("DesktopBackendConfiguration", () => {
198378
199379 const config = yield* Effect.gen(function* () {
200380 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
201 return yield* configuration.resolve;
381 return yield* configuration.resolvePrimary;
202382 }).pipe(
203383 Effect.provide(
204384 Layer.mergeAll(
205385 DesktopBackendConfiguration.layer.pipe(
206386 Layer.provideMerge(serverExposureLayer),
387 Layer.provideMerge(DesktopAppSettings.layerTest()),
388 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
207389 Layer.provideMerge(makeEnvironmentLayer(baseDir)),
208390 Layer.provideMerge(failingFileSystemLayer),
209391 ),
......@@ -228,7 +410,7 @@ describe("DesktopBackendConfiguration", () => {
228410 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
229411 );
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", () =>
232414 Effect.gen(function* () {
233415 const fileSystem = yield* FileSystem.FileSystem;
234416 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
......@@ -237,12 +419,14 @@ describe("DesktopBackendConfiguration", () => {
237419
238420 yield* Effect.gen(function* () {
239421 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
240 const config = yield* configuration.resolve;
422 const config = yield* configuration.resolvePrimary;
241423 assert.equal(config.captureOutput, true);
242424 }).pipe(
243425 Effect.provide(
244426 DesktopBackendConfiguration.layer.pipe(
245427 Layer.provideMerge(serverExposureLayer),
428 Layer.provideMerge(DesktopAppSettings.layerTest()),
429 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
246430 Layer.provideMerge(
247431 makeEnvironmentLayer(baseDir, {
248432 isPackaged: false,
......@@ -254,4 +438,362 @@ describe("DesktopBackendConfiguration", () => {
254438 );
255439 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
256440 );
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 });
257799});
apps/desktop/src/backend/DesktopBackendConfiguration.ts+550-48
......@@ -1,3 +1,5 @@
1import * as NodeOS from "node:os";
2
13import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
24import * as Context from "effect/Context";
35import * as Crypto from "effect/Crypto";
......@@ -7,12 +9,16 @@ import * as FileSystem from "effect/FileSystem";
79import * as Layer from "effect/Layer";
810import * as Option from "effect/Option";
911import * as PlatformError from "effect/PlatformError";
10import * as Ref from "effect/Ref";
1112import * as Schema from "effect/Schema";
13import * as SynchronizedRef from "effect/SynchronizedRef";
14
15import serverPackageJson from "../../../server/package.json" with { type: "json" };
1216
1317import * as DesktopBackendManager from "./DesktopBackendManager.ts";
1418import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1519import * as DesktopServerExposure from "./DesktopServerExposure.ts";
20import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
21import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
1622
1723export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass<DesktopBackendObservabilitySettingsReadError>()(
1824 "DesktopBackendObservabilitySettingsReadError",
......@@ -29,10 +35,31 @@ export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedE
2935export class DesktopBackendConfiguration extends Context.Service<
3036 DesktopBackendConfiguration,
3137 {
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<
3343 DesktopBackendManager.DesktopBackendStartConfig,
3444 PlatformError.PlatformError
3545 >;
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>;
3663 }
3764>()("@t3tools/desktop/backend/DesktopBackendConfiguration") {}
3865
......@@ -59,9 +86,48 @@ const DESKTOP_BACKEND_ENV_NAMES = [
5986 "T3CODE_TAILSCALE_SERVE_PORT",
6087] 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
6297const backendChildEnvPatch = (): Record<string, string | undefined> =>
6398 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
65131const logBackendObservabilitySettingsReadFailure = (
66132 settingsPath: string,
67133 cause: PlatformError.PlatformError,
......@@ -100,11 +166,168 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () {
100166 };
101167});
102168
103const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolveStartConfig")(
104 function* (input: {
105 readonly bootstrapToken: string;
106 readonly observabilitySettings: BackendObservabilitySettings;
107 }): Effect.fn.Return<
169interface SharedBootstrapInput {
170 readonly bootstrapToken: string;
171 readonly observabilitySettings: BackendObservabilitySettings;
172}
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<
108331 DesktopBackendManager.DesktopBackendStartConfig,
109332 never,
110333 DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure
......@@ -113,70 +336,349 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv
113336 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
114337 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
116351 return {
117352 executablePath: process.execPath,
353 args: [environment.backendEntryPath, "--bootstrap-fd", "3"],
118354 entryPath: environment.backendEntryPath,
119355 cwd: environment.backendCwd,
120356 env: {
121357 ...backendChildEnvPatch(),
122358 ELECTRON_RUN_AS_NODE: "1",
123359 },
124 bootstrap: {
125 mode: "desktop",
126 noBrowser: true,
127 port: backendExposure.port,
128 t3Home: environment.baseDir,
129 host: backendExposure.bindHost,
130 desktopBootstrapToken: input.bootstrapToken,
131 tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,
132 tailscaleServePort: backendExposure.tailscaleServePort,
133 ...Option.match(input.observabilitySettings.otlpTracesUrl, {
134 onNone: () => ({}),
135 onSome: (otlpTracesUrl) => ({ otlpTracesUrl }),
136 }),
137 ...Option.match(input.observabilitySettings.otlpMetricsUrl, {
138 onNone: () => ({}),
139 onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }),
140 }),
141 },
360 // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.).
361 extendEnv: true,
362 bootstrap,
363 bootstrapDelivery: "fd3",
142364 httpBaseUrl: backendExposure.httpBaseUrl,
143365 captureOutput: true,
144 };
366 preflightFailure: Option.none(),
367 } satisfies DesktopBackendManager.DesktopBackendStartConfig;
145368 },
146369);
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
148558export const make = Effect.gen(function* () {
149559 const environment = yield* DesktopEnvironment.DesktopEnvironment;
150560 const fileSystem = yield* FileSystem.FileSystem;
151561 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
562 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
563 const settings = yield* DesktopAppSettings.DesktopAppSettings;
152564 const crypto = yield* Crypto.Crypto;
153 const tokenRef = yield* Ref.make(Option.none<string>());
154 const getOrCreateBootstrapToken = Effect.gen(function* () {
155 const existing = yield* Ref.get(tokenRef);
156 if (Option.isSome(existing)) {
157 return existing.value;
158 }
565 // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic.
566 // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can
567 // resolve concurrently; with a plain Ref both could observe None, generate
568 // distinct tokens, and one would overwrite the other — leaving the two
569 // backends holding mismatched tokens and breaking the shared-token
570 // invariant the renderer relies on. modifyEffect serializes the whole
571 // get-or-create so the first caller wins and the rest reuse its token.
572 const tokenRef = yield* SynchronizedRef.make(Option.none<string>());
573 const getOrCreateBootstrapToken = SynchronizedRef.modifyEffect(tokenRef, (current) =>
574 Option.match(current, {
575 onSome: (token) => Effect.succeed([token, current] as const),
576 onNone: () =>
577 crypto.randomBytes(24).pipe(
578 Effect.map((bytes) => {
579 const token = Encoding.encodeHex(bytes);
580 return [token, Option.some(token)] as const;
581 }),
582 ),
583 }),
584 );
585
586 // Both resolvers share the same bootstrap token: the renderer holds a
587 // single token and uses it against whichever backend it's currently
588 // talking to. Observability settings get re-read each resolve so a
589 // hot-swap of the server-settings file is picked up on the next
590 // restart cycle without having to bounce the desktop process.
591 const sharedInputs = Effect.gen(function* () {
592 const bootstrapToken = yield* getOrCreateBootstrapToken;
593 const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe(
594 Effect.provideService(FileSystem.FileSystem, fileSystem),
595 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
596 );
597 return { bootstrapToken, observabilitySettings } satisfies SharedBootstrapInput;
598 });
159599
160 const token = Encoding.encodeHex(yield* crypto.randomBytes(24));
161 yield* Ref.set(tokenRef, Option.some(token));
162 return token;
600 const buildWslPrimaryConfig = Effect.gen(function* () {
601 // wsl-only mode pipes the WSL backend through the same port the
602 // Windows primary would normally take. That way the renderer
603 // still loads from the local-only endpoint advertised by
604 // DesktopServerExposure, and primary-aware code paths (cookie
605 // auth, the env switcher's "primary" id) keep working without
606 // a parallel "secondary" registration.
607 const backendExposure = yield* serverExposure.backendConfig;
608 const persistedSettings = yield* settings.get;
609 const shared = yield* sharedInputs;
610 return yield* resolveWslStartConfig({
611 ...shared,
612 port: backendExposure.port,
613 distro: persistedSettings.wslDistro,
614 }).pipe(
615 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
616 Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment),
617 Effect.provideService(FileSystem.FileSystem, fileSystem),
618 );
619 });
620
621 const buildWindowsPrimaryConfig = Effect.gen(function* () {
622 const shared = yield* sharedInputs;
623 return yield* resolvePrimaryStartConfig(shared).pipe(
624 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
625 Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure),
626 );
627 });
628
629 // Single source of truth for what the primary actually runs as. Both
630 // the start-config dispatch and the renderer-facing label derive from
631 // this, so they can't disagree — e.g. the label reading "WSL" while the
632 // config silently fell back to Windows because WSL is unavailable.
633 // Dispatch happens at resolve time so toggling wsl-only between restarts
634 // is picked up on the next start cycle (the pool's primary instance is
635 // created once at layer init, but configResolve fires on each restart).
636 const describePrimary = Effect.gen(function* () {
637 const persistedSettings = yield* settings.get;
638 const wslRequested = persistedSettings.wslOnly && persistedSettings.wslBackendEnabled;
639 // Only honor wsl-only when WSL is actually usable. If the user
640 // persisted wsl-only but WSL has since become unavailable (wsl.exe
641 // removed, no distro), fall back to the Windows primary instead of
642 // looping forever on preflight failures: the Connections backend
643 // control is hidden while WSL is unavailable, so a stuck WSL primary
644 // would otherwise leave no in-app way back to Windows.
645 const useWsl = wslRequested && (yield* wslEnvironment.isAvailable);
646 return { useWsl, wslRequested, distro: persistedSettings.wslDistro };
163647 });
164648
165649 return DesktopBackendConfiguration.of({
166 resolve: Effect.gen(function* () {
167 const bootstrapToken = yield* getOrCreateBootstrapToken;
168 const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe(
169 Effect.provideService(FileSystem.FileSystem, fileSystem),
170 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
171 );
172 return yield* resolveBackendStartConfig({
173 bootstrapToken,
174 observabilitySettings,
650 resolvePrimary: Effect.gen(function* () {
651 const { useWsl, wslRequested } = yield* describePrimary;
652 if (useWsl) {
653 return yield* buildWslPrimaryConfig;
654 }
655 if (wslRequested) {
656 yield* Effect.logWarning(
657 "WSL-only backend requested but WSL is unavailable; starting the Windows primary instead.",
658 );
659 }
660 return yield* buildWindowsPrimaryConfig;
661 }).pipe(Effect.withSpan("desktop.backendConfiguration.resolvePrimary")),
662 resolvePrimaryLabel: Effect.gen(function* () {
663 const { useWsl, distro } = yield* describePrimary;
664 if (!useWsl) {
665 return environment.platform === "win32" ? "Windows" : "Local environment";
666 }
667 return distro ? `WSL (${distro})` : "WSL";
668 }).pipe(Effect.withSpan("desktop.backendConfiguration.resolvePrimaryLabel")),
669 resolveWsl: (input) =>
670 Effect.gen(function* () {
671 const shared = yield* sharedInputs;
672 return yield* resolveWslStartConfig({ ...shared, ...input }).pipe(
673 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
674 Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment),
675 Effect.provideService(FileSystem.FileSystem, fileSystem),
676 );
175677 }).pipe(
176 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
177 Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure),
178 );
179 }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")),
678 Effect.withSpan("desktop.backendConfiguration.resolveWsl", {
679 attributes: { port: input.port, distro: input.distro ?? null },
680 }),
681 ),
180682 });
181683});
182684
apps/desktop/src/backend/DesktopBackendManager.test.ts+500-536
......@@ -3,15 +3,12 @@ import {
33 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
44} from "@t3tools/contracts";
55import { assert, describe, it } from "@effect/vitest";
6import * as Cause from "effect/Cause";
76import * as Deferred from "effect/Deferred";
87import * as Duration from "effect/Duration";
98import * as Effect from "effect/Effect";
109import * as FileSystem from "effect/FileSystem";
11import * as Fiber from "effect/Fiber";
1210import * as Layer from "effect/Layer";
1311import * as Option from "effect/Option";
14import * as PlatformError from "effect/PlatformError";
1512import * as Queue from "effect/Queue";
1613import * as Ref from "effect/Ref";
1714import * as Schema from "effect/Schema";
......@@ -23,18 +20,15 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
2320import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2421
2522import * as DesktopBackendManager from "./DesktopBackendManager.ts";
26import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
2723import * as DesktopObservability from "../app/DesktopObservability.ts";
28import * as DesktopState from "../app/DesktopState.ts";
29import * as DesktopWindow from "../window/DesktopWindow.ts";
3024
3125const decodeDesktopBackendBootstrap = Schema.decodeEffect(
3226 Schema.fromJsonString(DesktopBackendBootstrap),
3327);
34const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError);
3528
3629const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {
3730 executablePath: "/electron",
31 args: ["/server/bin.mjs", "--bootstrap-fd", "3"],
3832 entryPath: "/server/bin.mjs",
3933 cwd: "/server",
4034 env: { ELECTRON_RUN_AS_NODE: "1" },
......@@ -48,8 +42,11 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {
4842 tailscaleServeEnabled: false,
4943 tailscaleServePort: 443,
5044 },
45 bootstrapDelivery: "fd3",
46 extendEnv: true,
5147 httpBaseUrl: new URL("http://127.0.0.1:3773"),
5248 captureOutput: true,
49 preflightFailure: Option.none(),
5350};
5451
5552const configWithObservability: DesktopBackendBootstrapValue = {
......@@ -59,9 +56,9 @@ const configWithObservability: DesktopBackendBootstrapValue = {
5956};
6057
6158function makeProcess(options?: {
62 readonly stdout?: Stream.Stream<Uint8Array, PlatformError.PlatformError>;
63 readonly stderr?: Stream.Stream<Uint8Array, PlatformError.PlatformError>;
64 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;
59 readonly stdout?: Stream.Stream<Uint8Array>;
60 readonly stderr?: Stream.Stream<Uint8Array>;
61 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;
6562 readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"];
6663}): ChildProcessSpawner.ChildProcessHandle {
6764 return ChildProcessSpawner.makeHandle({
......@@ -105,114 +102,98 @@ function decodeBootstrap(raw: string) {
105102 return decodeDesktopBackendBootstrap(raw);
106103}
107104
108function makeManagerLayer(input: {
105interface MakeInstanceInput {
109106 readonly spawnerLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
110107 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
111 readonly backendOutputLog?: Partial<DesktopObservability.DesktopBackendOutputLog["Service"]>;
112 readonly desktopState?: DesktopState.DesktopState["Service"];
113 readonly desktopWindow?: Partial<DesktopWindow.DesktopWindow["Service"]>;
108 readonly backendOutputLog?: Partial<DesktopObservability.DesktopBackendOutputLogShape>;
109 readonly onReady?: Effect.Effect<void>;
110 readonly onShutdown?: Effect.Effect<void>;
111 readonly onPreflightFailed?: (
112 failure: DesktopBackendManager.PreflightFailure,
113 ) => Effect.Effect<boolean>;
114114 readonly config?: DesktopBackendManager.DesktopBackendStartConfig;
115}) {
116 return DesktopBackendManager.layer.pipe(
117 Layer.provide(
118 Layer.mergeAll(
119 FileSystem.layerNoop({
120 exists: () => Effect.succeed(true),
121 }),
122 Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, {
123 resolve: Effect.succeed(input.config ?? baseConfig),
124 }),
125 input.spawnerLayer,
126 input.httpClientLayer ?? healthyHttpClientLayer,
127 input.desktopState
128 ? Layer.succeed(DesktopState.DesktopState, input.desktopState)
129 : DesktopState.layer,
130 Layer.succeed(DesktopObservability.DesktopBackendOutputLog, {
131 writeSessionBoundary: () => Effect.void,
132 writeOutputChunk: () => Effect.void,
133 ...input.backendOutputLog,
134 } satisfies DesktopObservability.DesktopBackendOutputLog["Service"]),
135 Layer.succeed(DesktopWindow.DesktopWindow, {
136 createMain: Effect.die("unexpected createMain"),
137 ensureMain: Effect.die("unexpected ensureMain"),
138 revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"),
139 activate: Effect.void,
140 createMainIfBackendReady: Effect.void,
141 handleBackendReady: Effect.void,
142 dispatchMenuAction: () => Effect.void,
143 syncAppearance: Effect.void,
144 ...input.desktopWindow,
145 } satisfies DesktopWindow.DesktopWindow["Service"]),
146 ),
147 ),
148 );
115 readonly configResolve?: Effect.Effect<DesktopBackendManager.DesktopBackendStartConfig>;
149116}
150117
151describe("DesktopBackendManager", () => {
152 it("preserves the complete restart cause and schedule context", () => {
153 const cause = Cause.combine(
154 Cause.fail(new Error("start failed")),
155 Cause.die(new Error("restart defect")),
156 );
157 const error = new DesktopBackendManager.DesktopBackendRestartError({
158 reason: "backend exited with code 1",
159 delayMs: 500,
160 cause,
161 });
162
163 assert.strictEqual(error.cause, cause);
164 assert.equal(error.reason, "backend exited with code 1");
165 assert.equal(error.delayMs, 500);
166 assert.equal(error.message, "Desktop backend restart failed after a scheduled 500ms delay.");
118// Helper that constructs a primary backend instance using the factory
119// directly. The factory's deps (FileSystem, ChildProcessSpawner,
120// HttpClient, DesktopBackendOutputLogFactory) are provided per-test via
121// a scoped layer; tests yield the returned Effect inside `Effect.scoped`
122// to drive the instance's lifecycle.
123function makeTestInstance(input: MakeInstanceInput) {
124 const stubLog: DesktopObservability.DesktopBackendOutputLogShape = {
125 writeSessionBoundary: () => Effect.void,
126 writeOutputChunk: () => Effect.void,
127 ...input.backendOutputLog,
128 };
129 const servicesLayer = Layer.mergeAll(
130 FileSystem.layerNoop({
131 exists: () => Effect.succeed(true),
132 }),
133 input.spawnerLayer,
134 input.httpClientLayer ?? healthyHttpClientLayer,
135 Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, {
136 forInstance: () => Effect.succeed(stubLog),
137 } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]),
138 );
139
140 const instance = DesktopBackendManager.makeBackendInstance({
141 id: DesktopBackendManager.PRIMARY_INSTANCE_ID,
142 label: Effect.succeed("Windows"),
143 configResolve: input.configResolve ?? Effect.succeed(input.config ?? baseConfig),
144 ...(input.onReady ? { onReady: () => input.onReady! } : {}),
145 ...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}),
146 ...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}),
167147 });
168148
149 return instance.pipe(Effect.provide(servicesLayer));
150}
151
152describe("DesktopBackendManager", () => {
169153 it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () =>
170 Effect.gen(function* () {
171 let spawnedCommand: ChildProcess.Command | undefined;
172 let bootstrapJson = "";
173 let readyCount = 0;
174 const ready = yield* Deferred.make<void>();
175 const exited = yield* Queue.unbounded<void>();
176
177 const spawnerLayer = Layer.succeed(
178 ChildProcessSpawner.ChildProcessSpawner,
179 ChildProcessSpawner.make((command) =>
180 Effect.gen(function* () {
181 spawnedCommand = command;
182 if (command._tag === "StandardCommand") {
183 const fd3 = command.options.additionalFds?.fd3;
184 if (fd3?.type === "input" && fd3.stream) {
185 bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString);
154 Effect.scoped(
155 Effect.gen(function* () {
156 let spawnedCommand: ChildProcess.Command | undefined;
157 let bootstrapJson = "";
158 let readyCount = 0;
159 const ready = yield* Deferred.make<void>();
160 const exited = yield* Queue.unbounded<void>();
161
162 const spawnerLayer = Layer.succeed(
163 ChildProcessSpawner.ChildProcessSpawner,
164 ChildProcessSpawner.make((command) =>
165 Effect.gen(function* () {
166 spawnedCommand = command;
167 if (command._tag === "StandardCommand") {
168 const fd3 = command.options.additionalFds?.fd3;
169 if (fd3?.type === "input" && fd3.stream) {
170 bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString);
171 }
186172 }
187 }
188173
189 return makeProcess({
190 exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
191 });
192 }),
193 ),
194 );
195
196 const managerLayer = makeManagerLayer({
197 config: {
198 ...baseConfig,
199 bootstrap: configWithObservability,
200 },
201 spawnerLayer,
202 desktopWindow: {
203 handleBackendReady: Effect.sync(() => {
174 return makeProcess({
175 exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
176 });
177 }),
178 ),
179 );
180
181 const instance = yield* makeTestInstance({
182 config: {
183 ...baseConfig,
184 bootstrap: configWithObservability,
185 },
186 spawnerLayer,
187 onReady: Effect.sync(() => {
204188 readyCount += 1;
205 }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))),
206 },
207 backendOutputLog: {
208 writeSessionBoundary: ({ phase }) =>
209 phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void,
210 },
211 });
212
213 yield* Effect.gen(function* () {
214 const manager = yield* DesktopBackendManager.DesktopBackendManager;
215 yield* manager.start;
189 }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid),
190 backendOutputLog: {
191 writeSessionBoundary: ({ phase }) =>
192 phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void,
193 },
194 });
195
196 yield* instance.start;
216197 yield* Queue.take(exited);
217198
218199 assert.equal(readyCount, 1);
......@@ -235,292 +216,52 @@ describe("DesktopBackendManager", () => {
235216 );
236217
237218 assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability);
238 }).pipe(Effect.provide(managerLayer));
239 }),
240 );
241
242 it.effect("preserves the readiness timeout cause and process context", () =>
243 Effect.gen(function* () {
244 const requested = yield* Deferred.make<HttpClientRequest.HttpClientRequest>();
245 const layer = Layer.merge(
246 TestClock.layer(),
247 httpClientLayer((request) =>
248 Deferred.succeed(requested, request).pipe(Effect.andThen(Effect.never)),
249 ),
250 );
251
252 yield* Effect.gen(function* () {
253 const readiness = yield* DesktopBackendManager.waitForHttpReady({
254 executablePath: baseConfig.executablePath,
255 entryPath: baseConfig.entryPath,
256 cwd: baseConfig.cwd,
257 httpBaseUrl: baseConfig.httpBaseUrl,
258 timeout: Duration.millis(50),
259 }).pipe(Effect.flip, Effect.forkChild);
260
261 const request = yield* Deferred.await(requested);
262 assert.equal(request.url, "http://127.0.0.1:3773/.well-known/t3/environment");
263
264 yield* TestClock.adjust(Duration.millis(50));
265 const error = yield* Fiber.join(readiness);
266
267 assert.instanceOf(error, DesktopBackendManager.BackendReadinessTimeoutError);
268 assert.equal(error.executablePath, "/electron");
269 assert.equal(error.entryPath, "/server/bin.mjs");
270 assert.equal(error.cwd, "/server");
271 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
272 assert.equal(error.readinessUrl.href, "http://127.0.0.1:3773/.well-known/t3/environment");
273 assert.equal(error.timeoutMs, 50);
274 assert.isTrue(Cause.isTimeoutError(error.cause));
275 assert.equal(
276 error.message,
277 "Timed out after 50ms waiting for desktop backend readiness at http://127.0.0.1:3773/.well-known/t3/environment.",
278 );
279 }).pipe(Effect.provide(layer));
280 }),
281 );
282
283 it.effect("reports bootstrap encoding failures with stable process context", () =>
284 Effect.gen(function* () {
285 const spawnerLayer = Layer.succeed(
286 ChildProcessSpawner.ChildProcessSpawner,
287 ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")),
288 );
289 const error = yield* DesktopBackendManager.runBackendProcess({
290 ...baseConfig,
291 bootstrap: {
292 ...baseConfig.bootstrap,
293 port: 0,
294 },
295 }).pipe(
296 Effect.flip,
297 Effect.scoped,
298 Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)),
299 );
300
301 if (error._tag !== "BackendProcessBootstrapEncodeError") {
302 return assert.fail(`Expected bootstrap encode error, received ${error._tag}`);
303 }
304 assert.equal(error.executablePath, "/electron");
305 assert.equal(error.entryPath, "/server/bin.mjs");
306 assert.equal(error.cwd, "/server");
307 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
308 assert.isDefined(error.cause);
309 assert.equal(
310 error.message,
311 "Failed to encode the desktop backend bootstrap payload for /server/bin.mjs.",
312 );
313 assert.isTrue(isBackendProcessError(error));
314 }),
315 );
316
317 it.effect("preserves spawn failures without deriving their message from the cause", () =>
318 Effect.gen(function* () {
319 const spawnCause = PlatformError.systemError({
320 _tag: "PermissionDenied",
321 module: "ChildProcessSpawner",
322 method: "spawn",
323 pathOrDescriptor: baseConfig.executablePath,
324 description: "low-level detail that must not become the public message",
325 });
326 const spawnerLayer = Layer.succeed(
327 ChildProcessSpawner.ChildProcessSpawner,
328 ChildProcessSpawner.make(() => Effect.fail(spawnCause)),
329 );
330 const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe(
331 Effect.flip,
332 Effect.scoped,
333 Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)),
334 );
335
336 if (error._tag !== "BackendProcessSpawnError") {
337 return assert.fail(`Expected backend spawn error, received ${error._tag}`);
338 }
339 assert.equal(error.executablePath, "/electron");
340 assert.equal(error.entryPath, "/server/bin.mjs");
341 assert.equal(error.cwd, "/server");
342 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
343 assert.strictEqual(error.cause, spawnCause);
344 assert.equal(
345 error.message,
346 "Failed to spawn desktop backend entry /server/bin.mjs with /electron.",
347 );
348 assert.notInclude(error.message, spawnCause.message);
349 assert.isTrue(isBackendProcessError(error));
350 }),
351 );
352
353 it.effect("preserves exit-status failures without copying their detail into the message", () =>
354 Effect.gen(function* () {
355 const exitCause = PlatformError.systemError({
356 _tag: "PermissionDenied",
357 module: "ChildProcess",
358 method: "exitCode",
359 description: "exit-status-secret-sentinel",
360 });
361 const spawnerLayer = Layer.succeed(
362 ChildProcessSpawner.ChildProcessSpawner,
363 ChildProcessSpawner.make(() =>
364 Effect.succeed(
365 makeProcess({
366 exitCode: Effect.fail(exitCause),
367 }),
368 ),
369 ),
370 );
371 const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe(
372 Effect.flip,
373 Effect.scoped,
374 Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)),
375 );
376
377 if (error._tag !== "BackendProcessExitStatusError") {
378 return assert.fail(`Expected backend exit-status error, received ${error._tag}`);
379 }
380 assert.equal(error.pid, 123);
381 assert.equal(error.executablePath, "/electron");
382 assert.equal(error.entryPath, "/server/bin.mjs");
383 assert.equal(error.cwd, "/server");
384 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
385 assert.strictEqual(error.cause, exitCause);
386 assert.equal(error.message, "Failed to read the exit status of desktop backend process 123.");
387 assert.notInclude(error.message, "exit-status-secret-sentinel");
388 assert.isTrue(isBackendProcessError(error));
389 }),
390 );
391
392 it.effect("reports output stream failures with process and stream context", () =>
393 Effect.gen(function* () {
394 const outputCause = PlatformError.systemError({
395 _tag: "BadResource",
396 module: "ChildProcess",
397 method: "stdout",
398 description: "output-stream-secret-sentinel",
399 });
400 const reported = yield* Deferred.make<DesktopBackendManager.BackendProcessOutputError>();
401 const spawnerLayer = Layer.succeed(
402 ChildProcessSpawner.ChildProcessSpawner,
403 ChildProcessSpawner.make(() =>
404 Effect.succeed(
405 makeProcess({
406 stdout: Stream.fail(outputCause),
407 exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
408 }),
409 ),
410 ),
411 );
412
413 const exit = yield* DesktopBackendManager.runBackendProcess({
414 ...baseConfig,
415 onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid),
416 }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)));
417 const error = yield* Deferred.await(reported);
418
419 assert.equal(exit.code.pipe(Option.getOrUndefined), 0);
420 if (error._tag !== "BackendProcessOutputReadError") {
421 return assert.fail(`Expected output read error, received ${error._tag}`);
422 }
423 assert.equal(error.executablePath, "/electron");
424 assert.equal(error.entryPath, "/server/bin.mjs");
425 assert.equal(error.cwd, "/server");
426 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
427 assert.equal(error.pid, 123);
428 assert.equal(error.streamName, "stdout");
429 assert.strictEqual(error.cause, outputCause);
430 assert.equal(error.message, "Failed to read stdout from desktop backend process 123.");
431 assert.notInclude(error.message, "output-stream-secret-sentinel");
432 }),
219 }),
220 ),
433221 );
434222
435 it.effect("reports output handler failures separately from stream read failures", () =>
436 Effect.gen(function* () {
437 const chunk = new TextEncoder().encode("backend output");
438 const outputCause = new Error("output-handler-secret-sentinel");
439 const reported = yield* Deferred.make<DesktopBackendManager.BackendProcessOutputError>();
440 const spawnerLayer = Layer.succeed(
441 ChildProcessSpawner.ChildProcessSpawner,
442 ChildProcessSpawner.make(() =>
443 Effect.succeed(
444 makeProcess({
445 stdout: Stream.make(chunk),
446 exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
447 }),
223 it.effect("retries HTTP readiness before reporting the backend ready", () =>
224 Effect.scoped(
225 Effect.gen(function* () {
226 const requestUrls: Array<string> = [];
227 const statuses = [503, 200];
228 let readyCount = 0;
229 const firstRequest = yield* Deferred.make<void>();
230 const ready = yield* Deferred.make<void>();
231 const exited = yield* Queue.unbounded<void>();
232
233 const spawnerLayer = Layer.succeed(
234 ChildProcessSpawner.ChildProcessSpawner,
235 ChildProcessSpawner.make(() =>
236 Effect.succeed(
237 makeProcess({
238 exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
239 }),
240 ),
448241 ),
449 ),
450 );
451
452 const exit = yield* DesktopBackendManager.runBackendProcess({
453 ...baseConfig,
454 onOutput: () => Effect.fail(outputCause),
455 onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid),
456 }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)));
457 const error = yield* Deferred.await(reported);
458
459 assert.equal(exit.code.pipe(Option.getOrUndefined), 0);
460 if (error._tag !== "BackendProcessOutputHandlingError") {
461 return assert.fail(`Expected output handling error, received ${error._tag}`);
462 }
463 assert.equal(error.executablePath, "/electron");
464 assert.equal(error.entryPath, "/server/bin.mjs");
465 assert.equal(error.cwd, "/server");
466 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
467 assert.equal(error.pid, 123);
468 assert.equal(error.streamName, "stdout");
469 assert.equal(error.chunkByteLength, chunk.byteLength);
470 assert.strictEqual(error.cause, outputCause);
471 assert.equal(
472 error.message,
473 `Failed to handle ${chunk.byteLength} bytes from stdout of desktop backend process 123.`,
474 );
475 assert.notInclude(error.message, "output-handler-secret-sentinel");
476 }),
477 );
242 );
478243
479 it.effect("retries HTTP readiness before reporting the backend ready", () =>
480 Effect.gen(function* () {
481 const requestUrls: Array<string> = [];
482 const statuses = [503, 200];
483 let readyCount = 0;
484 const firstRequest = yield* Deferred.make<void>();
485 const ready = yield* Deferred.make<void>();
486 const exited = yield* Queue.unbounded<void>();
487
488 const spawnerLayer = Layer.succeed(
489 ChildProcessSpawner.ChildProcessSpawner,
490 ChildProcessSpawner.make(() =>
491 Effect.succeed(
492 makeProcess({
493 exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
244 const instance = yield* makeTestInstance({
245 spawnerLayer,
246 httpClientLayer: httpClientLayer((request) =>
247 Effect.gen(function* () {
248 const status = statuses.shift();
249 assert.isDefined(status);
250 requestUrls.push(request.url);
251 yield* Deferred.succeed(firstRequest, void 0);
252 return responseForRequest(request, status);
494253 }),
495254 ),
496 ),
497 );
498
499 const managerLayer = makeManagerLayer({
500 spawnerLayer,
501 httpClientLayer: httpClientLayer((request) =>
502 Effect.gen(function* () {
503 const status = statuses.shift();
504 assert.isDefined(status);
505 requestUrls.push(request.url);
506 yield* Deferred.succeed(firstRequest, void 0);
507 return responseForRequest(request, status);
508 }),
509 ),
510 desktopWindow: {
511 handleBackendReady: Effect.sync(() => {
255 onReady: Effect.sync(() => {
512256 readyCount += 1;
513 }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))),
514 },
515 backendOutputLog: {
516 writeSessionBoundary: ({ phase }) =>
517 phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void,
518 },
519 });
520
521 yield* Effect.gen(function* () {
522 const manager = yield* DesktopBackendManager.DesktopBackendManager;
523 yield* manager.start;
257 }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid),
258 backendOutputLog: {
259 writeSessionBoundary: ({ phase }) =>
260 phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void,
261 },
262 });
263
264 yield* instance.start;
524265 yield* Deferred.await(firstRequest);
525266
526267 assert.equal(readyCount, 0);
......@@ -534,106 +275,136 @@ describe("DesktopBackendManager", () => {
534275 "http://127.0.0.1:3773/.well-known/t3/environment",
535276 "http://127.0.0.1:3773/.well-known/t3/environment",
536277 ]);
537 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer)));
538 }),
278 }).pipe(Effect.provide(TestClock.layer())),
279 ),
539280 );
540281
541282 it.effect("starts the configured backend and closes the scoped process on stop", () =>
542 Effect.gen(function* () {
543 let startCount = 0;
544 let closedCount = 0;
545 const closed = yield* Deferred.make<void>();
546 const startedPids = yield* Queue.unbounded<number>();
547 const ready = yield* Deferred.make<void>();
548 const backendReady = yield* Ref.make(false);
549 const quitting = yield* Ref.make(false);
550
551 const spawnerLayer = Layer.succeed(
552 ChildProcessSpawner.ChildProcessSpawner,
553 ChildProcessSpawner.make(() =>
554 Effect.gen(function* () {
555 const scope = yield* Scope.Scope;
556 startCount += 1;
557 yield* Queue.offer(startedPids, 123);
558 const close = Effect.sync(() => {
559 closedCount += 1;
560 }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid);
561
562 yield* Scope.addFinalizer(scope, close);
563
564 return makeProcess({
565 exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
566 kill: () => close,
567 });
568 }),
569 ),
570 );
571
572 const managerLayer = makeManagerLayer({
573 spawnerLayer,
574 desktopState: {
575 backendReady,
576 quitting,
577 },
578 desktopWindow: {
579 handleBackendReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid),
580 },
581 });
582
583 yield* Effect.gen(function* () {
584 const manager = yield* DesktopBackendManager.DesktopBackendManager;
585 assert.isTrue(Option.isNone(yield* manager.currentConfig));
586
587 yield* manager.start;
283 Effect.scoped(
284 Effect.gen(function* () {
285 let startCount = 0;
286 let closedCount = 0;
287 const closed = yield* Deferred.make<void>();
288 const startedPids = yield* Queue.unbounded<number>();
289 const ready = yield* Deferred.make<void>();
290 const backendReadyFlag = yield* Ref.make(false);
291
292 const spawnerLayer = Layer.succeed(
293 ChildProcessSpawner.ChildProcessSpawner,
294 ChildProcessSpawner.make(() =>
295 Effect.gen(function* () {
296 const scope = yield* Scope.Scope;
297 startCount += 1;
298 yield* Queue.offer(startedPids, 123);
299 const close = Effect.sync(() => {
300 closedCount += 1;
301 }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid);
302
303 yield* Scope.addFinalizer(scope, close);
304
305 return makeProcess({
306 exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
307 kill: () => close,
308 });
309 }),
310 ),
311 );
312
313 const instance = yield* makeTestInstance({
314 spawnerLayer,
315 onReady: Ref.set(backendReadyFlag, true).pipe(
316 Effect.andThen(Deferred.succeed(ready, void 0)),
317 Effect.asVoid,
318 ),
319 onShutdown: Ref.set(backendReadyFlag, false),
320 });
321 assert.isTrue(Option.isNone(yield* instance.currentConfig));
322
323 yield* instance.start;
588324 assert.equal(yield* Queue.take(startedPids), 123);
589325 yield* Deferred.await(ready);
590 assert.isTrue(yield* Ref.get(backendReady));
591 assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig));
326 assert.isTrue(yield* Ref.get(backendReadyFlag));
327 assert.deepEqual(yield* instance.currentConfig, Option.some(baseConfig));
592328
593 const runningSnapshot = yield* manager.snapshot;
329 const runningSnapshot = yield* instance.snapshot;
594330 assert.equal(runningSnapshot.ready, true);
595331 assert.deepEqual(runningSnapshot.activePid, Option.some(123));
596332
597 yield* manager.stop();
333 yield* instance.stop();
598334 assert.equal(startCount, 1);
599335 assert.equal(closedCount, 1);
600336
601 const stoppedSnapshot = yield* manager.snapshot;
602 assert.isFalse(yield* Ref.get(backendReady));
337 const stoppedSnapshot = yield* instance.snapshot;
338 assert.isFalse(yield* Ref.get(backendReadyFlag));
603339 assert.equal(stoppedSnapshot.desiredRunning, false);
604340 assert.equal(stoppedSnapshot.ready, false);
605341 assert.equal(Option.isNone(stoppedSnapshot.activePid), true);
606 }).pipe(Effect.provide(managerLayer));
607 }),
342 }),
343 ),
608344 );
609345
610 it.effect("restarts an unexpectedly exited backend with the Effect clock", () =>
611 Effect.gen(function* () {
612 const starts = yield* Queue.unbounded<number>();
613 let startCount = 0;
614
615 const spawnerLayer = Layer.succeed(
616 ChildProcessSpawner.ChildProcessSpawner,
617 ChildProcessSpawner.make(() =>
618 Effect.sync(() => {
619 startCount += 1;
620 return makeProcess({
621 exitCode: Queue.offer(starts, startCount).pipe(
622 Effect.as(ChildProcessSpawner.ExitCode(1)),
623 ),
624 });
346 it.effect("does not notify shutdown before the first start has prior state", () =>
347 Effect.scoped(
348 Effect.gen(function* () {
349 let shutdownCount = 0;
350 const closed = yield* Deferred.make<void>();
351 const startedPids = yield* Queue.unbounded<number>();
352
353 const spawnerLayer = Layer.succeed(
354 ChildProcessSpawner.ChildProcessSpawner,
355 ChildProcessSpawner.make(() =>
356 Effect.gen(function* () {
357 yield* Queue.offer(startedPids, 123);
358 const close = Deferred.succeed(closed, void 0).pipe(Effect.asVoid);
359 return makeProcess({
360 exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
361 kill: () => close,
362 });
363 }),
364 ),
365 );
366
367 const instance = yield* makeTestInstance({
368 spawnerLayer,
369 httpClientLayer: httpClientLayer(() => Effect.never),
370 onShutdown: Effect.sync(() => {
371 shutdownCount += 1;
625372 }),
626 ),
627 );
373 });
628374
629 const managerLayer = makeManagerLayer({
630 spawnerLayer,
631 httpClientLayer: httpClientLayer(() => Effect.never),
632 });
375 yield* instance.start;
376 assert.equal(yield* Queue.take(startedPids), 123);
377 assert.equal(shutdownCount, 0);
378 }),
379 ),
380 );
633381
634 yield* Effect.gen(function* () {
635 const manager = yield* DesktopBackendManager.DesktopBackendManager;
636 yield* manager.start;
382 it.effect("restarts an unexpectedly exited backend with the Effect clock", () =>
383 Effect.scoped(
384 Effect.gen(function* () {
385 const starts = yield* Queue.unbounded<number>();
386 let startCount = 0;
387
388 const spawnerLayer = Layer.succeed(
389 ChildProcessSpawner.ChildProcessSpawner,
390 ChildProcessSpawner.make(() =>
391 Effect.sync(() => {
392 startCount += 1;
393 return makeProcess({
394 exitCode: Queue.offer(starts, startCount).pipe(
395 Effect.as(ChildProcessSpawner.ExitCode(1)),
396 ),
397 });
398 }),
399 ),
400 );
401
402 const instance = yield* makeTestInstance({
403 spawnerLayer,
404 httpClientLayer: httpClientLayer(() => Effect.never),
405 });
406
407 yield* instance.start;
637408
638409 assert.equal(yield* Queue.take(starts), 1);
639410
......@@ -646,107 +417,300 @@ describe("DesktopBackendManager", () => {
646417 assert.equal(yield* Queue.size(starts), 0);
647418 yield* TestClock.adjust(Duration.millis(1));
648419 assert.equal(yield* Queue.take(starts), 3);
649 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer)));
650 }),
420 }).pipe(Effect.provide(TestClock.layer())),
421 ),
422 );
423
424 it.effect("does not notify shutdown when a scheduled restart starts from non-ready state", () =>
425 Effect.scoped(
426 Effect.gen(function* () {
427 let shutdownCount = 0;
428
429 const spawnerLayer = Layer.succeed(
430 ChildProcessSpawner.ChildProcessSpawner,
431 ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")),
432 );
433
434 const instance = yield* makeTestInstance({
435 spawnerLayer,
436 config: {
437 ...baseConfig,
438 preflightFailure: Option.some({ reason: "preflight failed", fatal: false }),
439 },
440 onShutdown: Effect.sync(() => {
441 shutdownCount += 1;
442 }),
443 });
444
445 yield* instance.start;
446 assert.equal(shutdownCount, 0);
447
448 yield* TestClock.adjust(Duration.millis(500));
449 assert.equal(shutdownCount, 0);
450 }).pipe(Effect.provide(TestClock.layer())),
451 ),
452 );
453
454 it.effect("surfaces a fatal preflight failure once and stops looping after the cap", () =>
455 Effect.scoped(
456 Effect.gen(function* () {
457 const failures: string[] = [];
458 const spawnerLayer = Layer.succeed(
459 ChildProcessSpawner.ChildProcessSpawner,
460 ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")),
461 );
462
463 const instance = yield* makeTestInstance({
464 spawnerLayer,
465 config: {
466 ...baseConfig,
467 preflightFailure: Option.some({ reason: "Node.js not found", fatal: true }),
468 },
469 onPreflightFailed: (failure) =>
470 Effect.sync(() => {
471 failures.push(failure.reason);
472 }).pipe(Effect.as(false)),
473 });
474
475 yield* instance.start;
476 assert.deepEqual(failures, []);
477
478 // Five fatal attempts with exponential backoff (500ms, 1s, 2s, 4s) reach
479 // the cap, at which point the failure is surfaced exactly once.
480 yield* TestClock.adjust(Duration.millis(500));
481 yield* TestClock.adjust(Duration.seconds(1));
482 yield* TestClock.adjust(Duration.seconds(2));
483 yield* TestClock.adjust(Duration.seconds(4));
484 assert.deepEqual(failures, ["Node.js not found"]);
485
486 // Past the cap the loop stops and nothing else is surfaced.
487 yield* TestClock.adjust(Duration.seconds(8));
488 yield* TestClock.adjust(Duration.seconds(30));
489 assert.deepEqual(failures, ["Node.js not found"]);
490 }).pipe(Effect.provide(TestClock.layer())),
491 ),
492 );
493
494 it.effect("can be started again after a fatal preflight cap once config recovers", () =>
495 Effect.scoped(
496 Effect.gen(function* () {
497 const failing = yield* Ref.make(true);
498 const starts = yield* Queue.unbounded<number>();
499 const spawnerLayer = Layer.succeed(
500 ChildProcessSpawner.ChildProcessSpawner,
501 ChildProcessSpawner.make(() =>
502 Queue.offer(starts, 123).pipe(
503 Effect.as(
504 makeProcess({
505 exitCode: Effect.never,
506 }),
507 ),
508 ),
509 ),
510 );
511
512 const instance = yield* makeTestInstance({
513 spawnerLayer,
514 configResolve: Ref.get(failing).pipe(
515 Effect.map((isFailing) =>
516 isFailing
517 ? {
518 ...baseConfig,
519 preflightFailure: Option.some({
520 reason: "Node.js not found",
521 fatal: true,
522 }),
523 }
524 : baseConfig,
525 ),
526 ),
527 });
528
529 yield* instance.start;
530 yield* TestClock.adjust(Duration.millis(500));
531 yield* TestClock.adjust(Duration.seconds(1));
532 yield* TestClock.adjust(Duration.seconds(2));
533 yield* TestClock.adjust(Duration.seconds(4));
534 yield* TestClock.adjust(Duration.seconds(8));
535
536 const parked = yield* instance.snapshot;
537 assert.equal(parked.desiredRunning, false);
538 assert.equal(parked.ready, false);
539 assert.isTrue(Option.isNone(parked.activePid));
540 assert.equal(parked.restartScheduled, false);
541 assert.equal(yield* Queue.size(starts), 0);
542
543 yield* Ref.set(failing, false);
544 yield* instance.start;
545
546 assert.equal(yield* Queue.take(starts), 123);
547 const running = yield* instance.snapshot;
548 assert.equal(running.desiredRunning, true);
549 assert.deepEqual(running.activePid, Option.some(123));
550 }).pipe(Effect.provide(TestClock.layer())),
551 ),
552 );
553
554 it.effect("keeps retrying a transient (non-fatal) preflight failure without surfacing", () =>
555 Effect.scoped(
556 Effect.gen(function* () {
557 const failures: string[] = [];
558 const spawnerLayer = Layer.succeed(
559 ChildProcessSpawner.ChildProcessSpawner,
560 ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")),
561 );
562
563 const instance = yield* makeTestInstance({
564 spawnerLayer,
565 config: {
566 ...baseConfig,
567 preflightFailure: Option.some({ reason: "wslpath conversion failed", fatal: false }),
568 },
569 onPreflightFailed: (failure) =>
570 Effect.sync(() => {
571 failures.push(failure.reason);
572 }).pipe(Effect.as(false)),
573 });
574
575 yield* instance.start;
576 // Well beyond the fatal cap's worth of time: a transient failure must
577 // keep retrying (self-heal) and never surface.
578 yield* TestClock.adjust(Duration.minutes(2));
579 assert.deepEqual(failures, []);
580 }).pipe(Effect.provide(TestClock.layer())),
581 ),
582 );
583
584 it.effect("surfaces a bounded transient preflight failure after its retry limit", () =>
585 Effect.scoped(
586 Effect.gen(function* () {
587 const failures: string[] = [];
588 const spawnerLayer = Layer.succeed(
589 ChildProcessSpawner.ChildProcessSpawner,
590 ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")),
591 );
592
593 const instance = yield* makeTestInstance({
594 spawnerLayer,
595 config: {
596 ...baseConfig,
597 preflightFailure: Option.some({
598 reason: "WSL toolchain probe timed out",
599 fatal: false,
600 retryLimit: 3,
601 }),
602 },
603 onPreflightFailed: (failure) =>
604 Effect.sync(() => {
605 failures.push(failure.reason);
606 }).pipe(Effect.as(false)),
607 });
608
609 yield* instance.start;
610 yield* TestClock.adjust(Duration.millis(500));
611 assert.deepEqual(failures, []);
612
613 yield* TestClock.adjust(Duration.seconds(1));
614 assert.deepEqual(failures, ["WSL toolchain probe timed out"]);
615 }).pipe(Effect.provide(TestClock.layer())),
616 ),
651617 );
652618
653619 it.effect("cancels a scheduled restart when start is requested manually", () =>
654 Effect.gen(function* () {
655 const starts = yield* Queue.unbounded<number>();
656 const secondClosed = yield* Deferred.make<void>();
657 let startCount = 0;
658
659 const spawnerLayer = Layer.succeed(
660 ChildProcessSpawner.ChildProcessSpawner,
661 ChildProcessSpawner.make(() =>
662 Effect.gen(function* () {
663 startCount += 1;
664 yield* Queue.offer(starts, startCount);
665
666 if (startCount === 1) {
620 Effect.scoped(
621 Effect.gen(function* () {
622 const starts = yield* Queue.unbounded<number>();
623 const secondClosed = yield* Deferred.make<void>();
624 let startCount = 0;
625
626 const spawnerLayer = Layer.succeed(
627 ChildProcessSpawner.ChildProcessSpawner,
628 ChildProcessSpawner.make(() =>
629 Effect.gen(function* () {
630 startCount += 1;
631 yield* Queue.offer(starts, startCount);
632
633 if (startCount === 1) {
634 return makeProcess({
635 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
636 });
637 }
638
639 const scope = yield* Scope.Scope;
640 const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid);
641 yield* Scope.addFinalizer(scope, close);
667642 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,
669647 });
670 }
671
672 const scope = yield* Scope.Scope;
673 const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid);
674 yield* Scope.addFinalizer(scope, close);
675 return makeProcess({
676 exitCode: Deferred.await(secondClosed).pipe(
677 Effect.as(ChildProcessSpawner.ExitCode(0)),
678 ),
679 kill: () => close,
680 });
681 }),
682 ),
683 );
648 }),
649 ),
650 );
684651
685 const managerLayer = makeManagerLayer({
686 spawnerLayer,
687 httpClientLayer: httpClientLayer(() => Effect.never),
688 });
652 const instance = yield* makeTestInstance({
653 spawnerLayer,
654 httpClientLayer: httpClientLayer(() => Effect.never),
655 });
689656
690 yield* Effect.gen(function* () {
691 const manager = yield* DesktopBackendManager.DesktopBackendManager;
692 yield* manager.start;
657 yield* instance.start;
693658
694659 assert.equal(yield* Queue.take(starts), 1);
695660
696 yield* manager.start;
661 yield* instance.start;
697662 assert.equal(yield* Queue.take(starts), 2);
698663
699 yield* manager.stop();
664 yield* instance.stop();
700665 yield* TestClock.adjust(Duration.millis(500));
701666
702667 assert.equal(yield* Queue.size(starts), 0);
703 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer)));
704 }),
668 }).pipe(Effect.provide(TestClock.layer())),
669 ),
705670 );
706671
707672 it.effect("does not restart after stop cancels a scheduled restart", () =>
708 Effect.gen(function* () {
709 const starts = yield* Queue.unbounded<number>();
710 let startCount = 0;
711
712 const spawnerLayer = Layer.succeed(
713 ChildProcessSpawner.ChildProcessSpawner,
714 ChildProcessSpawner.make(() =>
715 Effect.sync(() => {
716 startCount += 1;
717 return makeProcess({
718 exitCode: Queue.offer(starts, startCount).pipe(
719 Effect.as(ChildProcessSpawner.ExitCode(1)),
720 ),
721 });
722 }),
723 ),
724 );
673 Effect.scoped(
674 Effect.gen(function* () {
675 const starts = yield* Queue.unbounded<number>();
676 let startCount = 0;
677
678 const spawnerLayer = Layer.succeed(
679 ChildProcessSpawner.ChildProcessSpawner,
680 ChildProcessSpawner.make(() =>
681 Effect.sync(() => {
682 startCount += 1;
683 return makeProcess({
684 exitCode: Queue.offer(starts, startCount).pipe(
685 Effect.as(ChildProcessSpawner.ExitCode(1)),
686 ),
687 });
688 }),
689 ),
690 );
725691
726 const managerLayer = makeManagerLayer({
727 spawnerLayer,
728 httpClientLayer: httpClientLayer(() => Effect.never),
729 });
692 const instance = yield* makeTestInstance({
693 spawnerLayer,
694 httpClientLayer: httpClientLayer(() => Effect.never),
695 });
730696
731 yield* Effect.gen(function* () {
732 const manager = yield* DesktopBackendManager.DesktopBackendManager;
733 yield* manager.start;
697 yield* instance.start;
734698 assert.equal(yield* Queue.take(starts), 1);
735699
736700 let restartScheduled = false;
737701 while (!restartScheduled) {
738 restartScheduled = (yield* manager.snapshot).restartScheduled;
702 restartScheduled = (yield* instance.snapshot).restartScheduled;
739703 if (!restartScheduled) {
740704 yield* Effect.yieldNow;
741705 }
742706 }
743707
744 yield* manager.stop();
708 yield* instance.stop();
745709 yield* TestClock.adjust(Duration.millis(500));
746710
747711 assert.equal(yield* Queue.size(starts), 0);
748 assert.equal((yield* manager.snapshot).desiredRunning, false);
749 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer)));
750 }),
712 assert.equal((yield* instance.snapshot).desiredRunning, false);
713 }).pipe(Effect.provide(TestClock.layer())),
714 ),
751715 );
752716});
apps/desktop/src/backend/DesktopBackendManager.ts+353-312
......@@ -1,35 +1,63 @@
1// Per-instance backend factory. Replaces the legacy singleton
2// `DesktopBackendManager` Context.Service: each call to
3// `makeBackendInstance(spec)` constructs an isolated backend lifecycle —
4// its own state Ref, mutex, restart loop, and active child process. The
5// returned `DesktopBackendInstance` exposes start/stop/snapshot/wait
6// methods that operate on that single backend.
7//
8// The pool layer (`DesktopBackendPool.ts`) calls this factory once per
9// backend it wants to run. Today that's the Windows primary; follow-up
10// commits add a second call for the WSL instance.
11//
12// Singleton couplings that the legacy service held inline are now
13// parameterized via the spec:
14// - configResolve replaces the legacy `DesktopBackendConfiguration.resolve`
15// so each instance can resolve its own start config — the primary wires
16// `configuration.resolvePrimary`, the WSL orchestrator wires a
17// `configuration.resolveWsl({ port, distro })` closure.
18// - onReady / onShutdown drive UI side effects (window auto-open,
19// readiness latch) only for instances that want them — the primary's
20// spec passes the window's handleBackendReady/handleBackendNotReady,
21// other pool instances pass nothing.
22// - log writes go through a per-instance writer that the factory
23// pulls from `DesktopBackendOutputLogFactory.forInstance(spec.id)`,
24// so each instance lands in its own rotating file.
25
26import * as Brand from "effect/Brand";
127import * as Cause from "effect/Cause";
2import * as Context from "effect/Context";
328import * as Duration from "effect/Duration";
429import * as Effect from "effect/Effect";
530import * as Exit from "effect/Exit";
631import * as Fiber from "effect/Fiber";
732import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
933import * as Option from "effect/Option";
1034import * as PlatformError from "effect/PlatformError";
1135import * as Ref from "effect/Ref";
36import * as Result from "effect/Result";
1237import * as Schedule from "effect/Schedule";
1338import * as Schema from "effect/Schema";
1439import * as Semaphore from "effect/Semaphore";
1540import * as Scope from "effect/Scope";
1641import * as Stream from "effect/Stream";
17import * as HttpClient from "effect/unstable/http/HttpClient";
18import * as ChildProcess from "effect/unstable/process/ChildProcess";
19import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
42import { HttpClient } from "effect/unstable/http";
43import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2044
2145import {
2246 DesktopBackendBootstrap,
2347 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
48 PRIMARY_LOCAL_ENVIRONMENT_ID,
2449} from "@t3tools/contracts";
50import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness";
2551
26import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
2752import * as DesktopObservability from "../app/DesktopObservability.ts";
28import * as DesktopState from "../app/DesktopState.ts";
29import * as DesktopWindow from "../window/DesktopWindow.ts";
3053
3154const INITIAL_RESTART_DELAY = Duration.millis(500);
3255const 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;
3361const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1);
3462const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100);
3563const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1);
......@@ -42,145 +70,91 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope;
4270
4371export type BackendProcessOutputStream = "stdout" | "stderr";
4472
45export interface BackendProcessContext {
73export type DesktopBackendBootstrapDelivery = "fd3" | "stdin";
74
75export interface DesktopBackendStartConfig {
4676 readonly executablePath: string;
77 readonly args: ReadonlyArray<string>;
4778 readonly entryPath: string;
4879 readonly cwd: string;
49 readonly httpBaseUrl: URL;
50}
51
52export interface DesktopBackendStartConfig extends BackendProcessContext {
5380 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;
5485 readonly bootstrap: DesktopBackendBootstrapValue;
86 readonly bootstrapDelivery: DesktopBackendBootstrapDelivery;
87 readonly httpBaseUrl: URL;
5588 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;
56104}
57105
58106interface BackendProcessExit {
59107 readonly code: Option.Option<number>;
60108 readonly reason: string;
109 readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;
61110}
62111
63const backendProcessContextSchema = {
64 executablePath: Schema.String,
65 entryPath: Schema.String,
66 cwd: Schema.String,
67 httpBaseUrl: Schema.URL,
68};
69
70export class BackendReadinessTimeoutError extends Schema.TaggedErrorClass<BackendReadinessTimeoutError>()(
71 "BackendReadinessTimeoutError",
112export class BackendTimeoutError extends Schema.TaggedErrorClass<BackendTimeoutError>()(
113 "BackendTimeoutError",
72114 {
73 ...backendProcessContextSchema,
74 readinessUrl: Schema.URL,
75 timeoutMs: Schema.Number,
76 cause: Schema.Defect(),
115 url: Schema.instanceOf(URL),
77116 },
78117) {
79 override get message(): string {
80 return `Timed out after ${this.timeoutMs}ms waiting for desktop backend readiness at ${this.readinessUrl.href}.`;
118 override get message() {
119 return `Timed out waiting for backend readiness at ${this.url.href}.`;
81120 }
82121}
83122
84export class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()(
123class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()(
85124 "BackendProcessBootstrapEncodeError",
86125 {
87 ...backendProcessContextSchema,
126 entryPath: Schema.String,
88127 cause: Schema.Defect(),
89128 },
90129) {
91 override get message(): string {
130 override get message() {
92131 return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`;
93132 }
94133}
95134
96export class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()(
135class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()(
97136 "BackendProcessSpawnError",
98137 {
99 ...backendProcessContextSchema,
100 cause: Schema.Defect(),
101 },
102) {
103 override get message(): string {
104 return `Failed to spawn desktop backend entry ${this.entryPath} with ${this.executablePath}.`;
105 }
106}
107
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,
138 executablePath: Schema.String,
159139 cause: Schema.Defect(),
160140 },
161141) {
162 override get message(): string {
163 return `Desktop backend restart failed after a scheduled ${this.delayMs}ms delay.`;
142 override get message() {
143 return `Failed to spawn the desktop backend process at ${this.executablePath}.`;
164144 }
165145}
166146
167export const BackendProcessError = Schema.Union([
168 BackendProcessBootstrapEncodeError,
169 BackendProcessSpawnError,
170 BackendProcessExitStatusError,
171]);
172export type BackendProcessError = typeof BackendProcessError.Type;
147type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError;
173148
174149interface RunBackendProcessOptions extends DesktopBackendStartConfig {
175150 readonly readinessTimeout?: Duration.Duration;
176151 readonly onStarted?: (pid: number) => Effect.Effect<void>;
177152 readonly onReady?: () => Effect.Effect<void>;
178 readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect<void>;
153 readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect<void>;
179154 readonly onOutput?: (
180155 streamName: BackendProcessOutputStream,
181156 chunk: Uint8Array,
182 ) => Effect.Effect<void, Error>;
183 readonly onOutputFailure?: (error: BackendProcessOutputError) => Effect.Effect<void>;
157 ) => Effect.Effect<void>;
184158}
185159
186160export interface DesktopBackendSnapshot {
......@@ -191,18 +165,60 @@ export interface DesktopBackendSnapshot {
191165 readonly restartScheduled: boolean;
192166}
193167
194export class DesktopBackendManager extends Context.Service<
195 DesktopBackendManager,
196 {
197 readonly start: Effect.Effect<void>;
198 readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect<void>;
199 readonly currentConfig: Effect.Effect<Option.Option<DesktopBackendStartConfig>>;
200 readonly snapshot: Effect.Effect<DesktopBackendSnapshot>;
201 }
202>()("@t3tools/desktop/backend/DesktopBackendManager") {}
168// Opaque identifier for one backend process inside the pool. Today only
169// PRIMARY_INSTANCE_ID is registered. Follow-up commits add WSL distros
170// under ids derived from the distro name (e.g. "wsl:ubuntu"). Eventually
171// these map 1:1 with environment ids on the frontend; keeping them
172// desktop-local for now avoids leaking the contracts dependency.
173export type BackendInstanceId = string & Brand.Brand<"BackendInstanceId">;
174export const BackendInstanceId = Brand.nominal<BackendInstanceId>();
175
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 } =
205 DesktopObservability.makeComponentLogger("desktop-backend-manager");
197// Spec describing one backend instance to spawn. The configResolve
198// effect is awaited each time the instance is (re)started so live
199// settings changes are picked up on the next start cycle. onReady and
200// onShutdown let the primary instance trigger UI side effects (window
201// open, global readiness flag) without coupling the factory to those
202// concerns; other instances pass them as undefined.
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
207223interface ActiveBackendRun {
208224 readonly id: number;
......@@ -217,6 +233,9 @@ interface BackendManagerState {
217233 readonly config: Option.Option<DesktopBackendStartConfig>;
218234 readonly active: Option.Option<ActiveBackendRun>;
219235 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;
220239 readonly restartFiber: Option.Option<Fiber.Fiber<void, never>>;
221240 readonly nextRunId: number;
222241}
......@@ -227,6 +246,7 @@ const initialState: BackendManagerState = {
227246 config: Option.none(),
228247 active: Option.none(),
229248 restartAttempt: 0,
249 preflightFailureAttempt: 0,
230250 restartFiber: Option.none(),
231251 nextRunId: 1,
232252};
......@@ -259,198 +279,135 @@ const closeRun = (
259279 ).pipe(Effect.ignore);
260280};
261281
262export const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* (
263 options: BackendProcessContext & { readonly timeout: Duration.Duration },
264): Effect.fn.Return<void, BackendReadinessTimeoutError, HttpClient.HttpClient> {
265 const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl);
266 const client = (yield* HttpClient.HttpClient).pipe(
267 HttpClient.filterStatusOk,
268 HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)),
269 HttpClient.retry(Schedule.spaced(DEFAULT_BACKEND_READINESS_INTERVAL)),
270 );
282const waitForHttpReady = (
283 baseUrl: URL,
284 timeout: Duration.Duration,
285): Effect.Effect<void, BackendTimeoutError, HttpClient.HttpClient> => {
286 const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl);
287 return waitForHttpReadyShared({
288 baseUrl: baseUrl.href,
289 path: BACKEND_READINESS_PATH,
290 timeoutMs: Duration.toMillis(timeout),
291 intervalMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_INTERVAL),
292 probeTimeoutMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT),
293 makeError: () => new BackendTimeoutError({ url: readinessUrl }),
294 });
295};
271296
272 yield* client.get(readinessUrl).pipe(
273 Effect.asVoid,
274 Effect.timeout(options.timeout),
275 Effect.mapError(
276 (cause) =>
277 new BackendReadinessTimeoutError({
278 executablePath: options.executablePath,
279 entryPath: options.entryPath,
280 cwd: options.cwd,
281 httpBaseUrl: options.httpBaseUrl,
282 readinessUrl,
283 timeoutMs: Duration.toMillis(options.timeout),
284 cause,
285 }),
286 ),
287 );
288});
297function describeProcessExit(
298 result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>,
299): BackendProcessExit {
300 if (Result.isSuccess(result)) {
301 return {
302 code: Option.some(result.success),
303 reason: `code=${result.success}`,
304 result,
305 };
306 }
307
308 return {
309 code: Option.none(),
310 reason: result.failure.message,
311 result,
312 };
313}
289314
290315function drainBackendOutput(
291 context: BackendProcessContext & { readonly pid: number },
292316 streamName: BackendProcessOutputStream,
293317 stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>,
294 onOutput: (
295 streamName: BackendProcessOutputStream,
296 chunk: Uint8Array,
297 ) => Effect.Effect<void, Error>,
298 onOutputFailure: (error: BackendProcessOutputError) => Effect.Effect<void>,
318 onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect<void>,
299319): Effect.Effect<void> {
300320 return stream.pipe(
301 Stream.mapError(
302 (cause) =>
303 new BackendProcessOutputReadError({
304 ...context,
305 streamName,
306 cause,
307 }),
308 ),
309 Stream.runForEach((chunk) =>
310 onOutput(streamName, chunk).pipe(
311 Effect.mapError(
312 (cause) =>
313 new BackendProcessOutputHandlingError({
314 ...context,
315 streamName,
316 chunkByteLength: chunk.byteLength,
317 cause,
318 }),
319 ),
320 ),
321 ),
322 Effect.catchTags({
323 BackendProcessOutputReadError: onOutputFailure,
324 BackendProcessOutputHandlingError: onOutputFailure,
325 }),
321 Stream.runForEach((chunk) => onOutput(streamName, chunk)),
322 Effect.ignore,
326323 );
327324}
328325
329326const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
330327
331export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
328const runBackendProcess = Effect.fn("runBackendProcess")(function* (
332329 options: RunBackendProcessOptions,
333330): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> {
334331 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
335332 const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe(
336333 Effect.mapError(
337 (cause) =>
338 new BackendProcessBootstrapEncodeError({
339 executablePath: options.executablePath,
340 entryPath: options.entryPath,
341 cwd: options.cwd,
342 httpBaseUrl: options.httpBaseUrl,
343 cause,
344 }),
334 (cause) => new BackendProcessBootstrapEncodeError({ entryPath: options.entryPath, cause }),
345335 ),
346336 );
347337 const onOutput = options.onOutput ?? (() => Effect.void);
348 const command = ChildProcess.make(
349 options.executablePath,
350 [options.entryPath, "--bootstrap-fd", "3"],
351 {
352 cwd: options.cwd,
353 env: options.env,
354 extendEnv: true,
355 // In Electron main, process.execPath points to the Electron binary.
356 // Run the child in Node mode so this backend process does not become a GUI app instance.
357 stdin: "ignore",
358 stdout: options.captureOutput ? "pipe" : "inherit",
359 stderr: options.captureOutput ? "pipe" : "inherit",
360 killSignal: "SIGTERM",
361 forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE,
362 additionalFds: {
363 fd3: {
364 type: "input",
365 stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)),
366 },
367 },
368 },
369 );
338 const bootstrapStream = Stream.encodeText(Stream.make(`${bootstrapJson}\n`));
339 const command = ChildProcess.make(options.executablePath, options.args, {
340 cwd: options.cwd,
341 env: options.env,
342 extendEnv: options.extendEnv,
343 // In Electron main, process.execPath points to the Electron binary.
344 // Run the child in Node mode so this backend process does not become a GUI app instance.
345 stdin: options.bootstrapDelivery === "stdin" ? bootstrapStream : "ignore",
346 stdout: options.captureOutput ? "pipe" : "inherit",
347 stderr: options.captureOutput ? "pipe" : "inherit",
348 killSignal: "SIGTERM",
349 forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE,
350 // wsl.exe drops additional file descriptors when forwarding to the Linux
351 // side, so the WSL spawn path delivers the bootstrap envelope via stdin
352 // (`--bootstrap-fd 0`) instead.
353 ...(options.bootstrapDelivery === "fd3"
354 ? { additionalFds: { fd3: { type: "input" as const, stream: bootstrapStream } } }
355 : {}),
356 });
370357
371 const handle = yield* spawner.spawn(command).pipe(
372 Effect.mapError(
373 (cause) =>
374 new BackendProcessSpawnError({
375 executablePath: options.executablePath,
376 entryPath: options.entryPath,
377 cwd: options.cwd,
378 httpBaseUrl: options.httpBaseUrl,
379 cause,
380 }),
381 ),
382 );
358 const handle = yield* spawner
359 .spawn(command)
360 .pipe(
361 Effect.mapError(
362 (cause) => new BackendProcessSpawnError({ executablePath: options.executablePath, cause }),
363 ),
364 );
383365
384366 yield* options.onStarted?.(handle.pid) ?? Effect.void;
385367 if (options.captureOutput) {
386 const outputContext = {
387 executablePath: options.executablePath,
388 entryPath: options.entryPath,
389 cwd: options.cwd,
390 httpBaseUrl: options.httpBaseUrl,
391 pid: Number(handle.pid),
392 };
393 const onOutputFailure = options.onOutputFailure ?? (() => Effect.void);
394 yield* drainBackendOutput(
395 outputContext,
396 "stdout",
397 handle.stdout,
398 onOutput,
399 onOutputFailure,
400 ).pipe(Effect.forkScoped);
401 yield* drainBackendOutput(
402 outputContext,
403 "stderr",
404 handle.stderr,
405 onOutput,
406 onOutputFailure,
407 ).pipe(Effect.forkScoped);
368 yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped);
369 yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped);
408370 }
409 yield* waitForHttpReady({
410 executablePath: options.executablePath,
411 entryPath: options.entryPath,
412 cwd: options.cwd,
413 httpBaseUrl: options.httpBaseUrl,
414 timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
415 }).pipe(
371 yield* waitForHttpReady(
372 options.httpBaseUrl,
373 options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
374 ).pipe(
416375 Effect.tap(() => options.onReady?.() ?? Effect.void),
417 Effect.catchTags({
418 BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void,
419 }),
376 Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void),
420377 Effect.forkScoped,
421378 );
422379
423 const exitCode = yield* handle.exitCode.pipe(
424 Effect.mapError(
425 (cause) =>
426 new BackendProcessExitStatusError({
427 executablePath: options.executablePath,
428 entryPath: options.entryPath,
429 cwd: options.cwd,
430 httpBaseUrl: options.httpBaseUrl,
431 pid: Number(handle.pid),
432 cause,
433 }),
434 ),
435 );
436 return {
437 code: Option.some(exitCode),
438 reason: `code=${exitCode}`,
439 } satisfies BackendProcessExit;
380 return describeProcessExit(yield* Effect.result(handle.exitCode));
440381});
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> {
443399 const parentScope = yield* Scope.Scope;
444400 const fileSystem = yield* FileSystem.FileSystem;
445 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
446 const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog;
447 const desktopState = yield* DesktopState.DesktopState;
448 const desktopWindow = yield* DesktopWindow.DesktopWindow;
401 const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
402 const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id);
449403 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
450404 const httpClient = yield* HttpClient.HttpClient;
451405 const state = yield* Ref.make(initialState);
452406 const mutex = yield* Semaphore.make(1);
453407
408 const { logWarning: logInstanceWarning, logError: logInstanceError } =
409 DesktopObservability.makeComponentLogger(`desktop-backend-instance:${spec.id}`);
410
454411 const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) =>
455412 Ref.update(state, withActiveRun(runId, f));
456413
......@@ -490,11 +447,16 @@ export const make = Effect.gen(function* () {
490447 return;
491448 }
492449
493 yield* Ref.set(desktopState.backendReady, false);
494 const config = yield* configuration.resolve.pipe(
450 if (current.ready) {
451 yield* spec.onShutdown?.() ?? Effect.void;
452 yield* Ref.update(state, (latest) =>
453 latest.ready ? { ...latest, ready: false } : latest,
454 );
455 }
456 const config = yield* spec.configResolve.pipe(
495457 Effect.tapError((error) =>
496 logBackendManagerError("failed to generate desktop backend configuration", {
497 cause: error,
458 logInstanceError("failed to generate desktop backend configuration", {
459 cause: error.message,
498460 }),
499461 ),
500462 Effect.option,
......@@ -506,14 +468,84 @@ export const make = Effect.gen(function* () {
506468 .exists(config.value.entryPath)
507469 .pipe(Effect.orElseSucceed(() => false));
508470
471 const resetFatalPreflightCounter =
472 !current.desiredRunning && current.preflightFailureAttempt > 0;
509473 yield* cancelRestart;
510474 yield* Ref.update(state, (latest) => ({
511475 ...latest,
512476 desiredRunning: true,
513477 ready: false,
514478 config: Option.some(config.value),
479 preflightFailureAttempt: resetFatalPreflightCounter ? 0 : latest.preflightFailureAttempt,
515480 }));
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
517549 if (!entryExists) {
518550 yield* scheduleRestart(`missing server entry at ${config.value.entryPath}`);
519551 return;
......@@ -534,7 +566,7 @@ export const make = Effect.gen(function* () {
534566 },
535567 ]);
536568
537 const finalizeRun = Effect.fn("desktop.backendManager.finalizeRun")(function* (
569 const finalizeRun = Effect.fn("desktop.backendInstance.finalizeRun")(function* (
538570 reason: string,
539571 ) {
540572 yield* mutex.withPermits(1)(
......@@ -586,7 +618,7 @@ export const make = Effect.gen(function* () {
586618 details: `pid=${pid.value} ${reason}`,
587619 });
588620 }
589 yield* Ref.set(desktopState.backendReady, false);
621 yield* spec.onShutdown?.() ?? Effect.void;
590622 }
591623
592624 if (isCurrentRun && nextState.desiredRunning) {
......@@ -598,7 +630,7 @@ export const make = Effect.gen(function* () {
598630
599631 const program = runBackendProcess({
600632 ...config.value,
601 onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) {
633 onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) {
602634 yield* updateActiveRun(runId, (run) => ({
603635 ...run,
604636 pid: Option.some(pid),
......@@ -608,7 +640,7 @@ export const make = Effect.gen(function* () {
608640 details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`,
609641 });
610642 }),
611 onReady: Effect.fn("desktop.backendManager.onReady")(function* () {
643 onReady: Effect.fn("desktop.backendInstance.onReady")(function* () {
612644 const isCurrentRun = yield* Ref.modify(state, (latest) => {
613645 const activeRun = Option.getOrUndefined(latest.active);
614646 if (activeRun?.id !== runId) {
......@@ -628,30 +660,19 @@ export const make = Effect.gen(function* () {
628660 return;
629661 }
630662
631 yield* Ref.set(desktopState.backendReady, true);
632 yield* desktopWindow.handleBackendReady.pipe(
633 Effect.catch((error) =>
634 logBackendManagerError("failed to open main window after backend readiness", {
635 cause: error,
636 }),
637 ),
638 );
663 yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void;
639664 }),
640665 onReadinessFailure: (error) =>
641 logBackendManagerWarning("backend readiness check failed during bootstrap", {
642 error,
666 logInstanceWarning("backend readiness check failed during bootstrap", {
667 error: error.message,
643668 }),
644669 onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk),
645 onOutputFailure: (error) => logBackendManagerError(error.message, { error }),
646670 }).pipe(
647671 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
648672 Effect.provideService(HttpClient.HttpClient, httpClient),
649673 Scope.provide(runScope),
650674 Effect.matchEffect({
651 onFailure: (error) =>
652 logBackendManagerError(error.message, { error }).pipe(
653 Effect.andThen(finalizeRun(error.message)),
654 ),
675 onFailure: (error) => finalizeRun(error.message),
655676 onSuccess: (exit) => finalizeRun(exit.reason),
656677 }),
657678 Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)),
......@@ -664,9 +685,9 @@ export const make = Effect.gen(function* () {
664685 }));
665686 }),
666687 ),
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* (
670691 reason: string,
671692 ) {
672693 const scheduled = yield* Ref.modify(state, (latest) => {
......@@ -686,8 +707,8 @@ export const make = Effect.gen(function* () {
686707
687708 yield* Option.match(scheduled, {
688709 onNone: () => Effect.void,
689 onSome: Effect.fn("desktop.backendManager.scheduleRestartFiber")(function* (delay) {
690 yield* logBackendManagerError("backend exited unexpectedly; restart scheduled", {
710 onSome: Effect.fn("desktop.backendInstance.scheduleRestartFiber")(function* (delay) {
711 yield* logInstanceError("backend exited unexpectedly; restart scheduled", {
691712 reason,
692713 delayMs: Duration.toMillis(delay),
693714 });
......@@ -706,17 +727,11 @@ export const make = Effect.gen(function* () {
706727 }),
707728 ),
708729 Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)),
709 Effect.catchCause((cause) => {
710 if (Cause.hasInterruptsOnly(cause)) {
711 return Effect.void;
712 }
713 const error = new DesktopBackendRestartError({
714 reason,
715 delayMs: Duration.toMillis(delay),
716 cause,
717 });
718 return logBackendManagerError(error.message, { error });
719 }),
730 Effect.catchCause((cause) =>
731 logInstanceError("desktop backend restart fiber failed", {
732 cause: Cause.pretty(cause),
733 }),
734 ),
720735 ),
721736 parentScope,
722737 );
......@@ -732,7 +747,7 @@ export const make = Effect.gen(function* () {
732747 });
733748 });
734749
735 const stop = Effect.fn("desktop.backendManager.stop")(function* (options?: {
750 const stop = Effect.fn("desktop.backendInstance.stop")(function* (options?: {
736751 readonly timeout?: Duration.Duration;
737752 }) {
738753 const { active, restartFiber } = yield* mutex.withPermits(1)(
......@@ -750,7 +765,15 @@ export const make = Effect.gen(function* () {
750765 restartFiber: Option.none<Fiber.Fiber<void, never>>(),
751766 },
752767 ]);
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);
754777 return result;
755778 }),
756779 );
......@@ -765,14 +788,32 @@ export const make = Effect.gen(function* () {
765788 });
766789 });
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
768808 yield* Effect.addFinalizer(() => stop());
769809
770 return DesktopBackendManager.of({
810 return {
811 id: spec.id,
812 label: spec.label,
771813 start,
772814 stop,
773815 currentConfig,
774816 snapshot,
775 });
817 waitForReady,
818 } satisfies DesktopBackendInstance;
776819});
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";
55import * as Ref from "effect/Ref";
66import * as HttpClient from "effect/unstable/http/HttpClient";
77import * 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";
1011import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts";
1112
12const config: DesktopBackendManager.DesktopBackendStartConfig = {
13const config = {
1314 executablePath: "/electron",
1415 entryPath: "/server/bin.mjs",
1516 cwd: "/server",
......@@ -54,20 +55,17 @@ describe("DesktopLocalEnvironmentAuth", () => {
5455 ),
5556 ),
5657 );
57 const managerLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, {
58 start: Effect.void,
59 stop: () => Effect.void,
60 currentConfig: Effect.succeed(Option.some(config)),
61 snapshot: Effect.succeed({
62 desiredRunning: true,
63 ready: true,
64 activePid: Option.none(),
65 restartAttempt: 0,
66 restartScheduled: false,
67 }),
68 });
58 const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, {
59 list: Effect.succeed([
60 {
61 id: PRIMARY_LOCAL_ENVIRONMENT_ID,
62 label: Effect.succeed("Windows"),
63 currentConfig: Effect.succeed(Option.some(config)),
64 },
65 ]),
66 } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]);
6967 const testLayer = DesktopLocalEnvironmentAuth.layer.pipe(
70 Layer.provide(Layer.mergeAll(managerLayer, httpClientLayer)),
68 Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer)),
7169 );
7270
7371 const [first, second] = yield* Effect.gen(function* () {
apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts+11-4
......@@ -1,4 +1,5 @@
11import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization";
2import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
23import * as Context from "effect/Context";
34import * as Effect from "effect/Effect";
45import * as Layer from "effect/Layer";
......@@ -8,7 +9,7 @@ import * as Schema from "effect/Schema";
89import * as Semaphore from "effect/Semaphore";
910import * as HttpClient from "effect/unstable/http/HttpClient";
1011
11import * as DesktopBackendManager from "./DesktopBackendManager.ts";
12import * as DesktopBackendPool from "./DesktopBackendPool.ts";
1213
1314export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedErrorClass<DesktopLocalEnvironmentAuthBackendNotConfiguredError>()(
1415 "DesktopLocalEnvironmentAuthBackendNotConfiguredError",
......@@ -42,7 +43,7 @@ export class DesktopLocalEnvironmentAuth extends Context.Service<
4243>()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {}
4344
4445export const make = Effect.gen(function* () {
45 const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
46 const pool = yield* DesktopBackendPool.DesktopBackendPool;
4647 const httpClient = yield* HttpClient.HttpClient;
4748 const tokenRef = yield* Ref.make(Option.none<string>());
4849 const mutex = yield* Semaphore.make(1);
......@@ -55,14 +56,20 @@ export const make = Effect.gen(function* () {
5556 return cached.value;
5657 }
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;
5962 if (Option.isNone(configOption)) {
6063 return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError();
6164 }
6265 const config = configOption.value;
66 const credential = config.bootstrap.desktopBootstrapToken;
67 if (!credential) {
68 return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError();
69 }
6370 const session = yield* bootstrapRemoteBearerSession({
6471 httpBaseUrl: config.httpBaseUrl.href,
65 credential: config.bootstrap.desktopBootstrapToken,
72 credential,
6673 clientMetadata: {
6774 label: "T3 Code Desktop",
6875 deviceType: "desktop",
apps/desktop/src/backend/DesktopServerExposure.test.ts+5
......@@ -253,6 +253,11 @@ describe("DesktopServerExposure", () => {
253253 setServerExposureMode: () => Effect.fail(settingsFailure),
254254 setTailscaleServe: () => Effect.fail(settingsFailure),
255255 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"),
256261 } satisfies DesktopAppSettings.DesktopAppSettings["Service"]);
257262
258263 return withHarness(
apps/desktop/src/ipc/DesktopIpcHandlers.ts+8-2
......@@ -33,21 +33,22 @@ import {
3333import {
3434 confirm,
3535 getAppBranding,
36 getLocalEnvironmentBootstraps,
3637 getLocalEnvironmentBearerToken,
37 getLocalEnvironmentBootstrap,
3838 openExternal,
3939 pickFolder,
4040 setTheme,
4141 showContextMenu,
4242} from "./methods/window.ts";
4343import * as PreviewIpc from "./methods/preview.ts";
44import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts";
4445
4546export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {
4647 const ipc = yield* DesktopIpc.DesktopIpc;
4748 yield* PreviewIpc.installPreviewEventForwarding();
4849
4950 yield* ipc.handleSync(getAppBranding);
50 yield* ipc.handleSync(getLocalEnvironmentBootstrap);
51 yield* ipc.handleSync(getLocalEnvironmentBootstraps);
5152 yield* ipc.handle(getLocalEnvironmentBearerToken);
5253
5354 yield* ipc.handle(getClientSettings);
......@@ -70,6 +71,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
7071 yield* ipc.handle(setTailscaleServeEnabled);
7172 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
7379 yield* ipc.handle(pickFolder);
7480 yield* ipc.handle(confirm);
7581 yield* ipc.handle(setTheme);
apps/desktop/src/ipc/channels.ts+5-1
......@@ -11,7 +11,7 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
1111export const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
1212export const UPDATE_CHECK_CHANNEL = "desktop:update-check";
1313export 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";
1515export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL =
1616 "desktop:get-local-environment-bearer-token";
1717export 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
3232export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
3333export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";
3434export 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";
3539export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
3640export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab";
3741export 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 {
44 DesktopEnvironmentBootstrapSchema,
55 DesktopThemeSchema,
66 PickFolderOptionsSchema,
7 PRIMARY_LOCAL_ENVIRONMENT_ID,
8 type DesktopEnvironmentBootstrap,
79} from "@t3tools/contracts";
810import * as Effect from "effect/Effect";
911import * as Option from "effect/Option";
1012import * as Schema from "effect/Schema";
1113
12import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
14import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
1315import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts";
1416import * 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";
1520import * as ElectronDialog from "../../electron/ElectronDialog.ts";
1621import * as ElectronMenu from "../../electron/ElectronMenu.ts";
1722import * as ElectronShell from "../../electron/ElectronShell.ts";
......@@ -19,6 +24,11 @@ import * as ElectronTheme from "../../electron/ElectronTheme.ts";
1924import * as ElectronWindow from "../../electron/ElectronWindow.ts";
2025import * as IpcChannels from "../channels.ts";
2126import * as DesktopIpc from "../DesktopIpc.ts";
27import {
28 extractDistroFromUncPath,
29 resolveWslPickFolderDefaultPath,
30 wslUncPathToLinuxPath,
31} from "../../wsl/wslPathParsing.ts";
2232
2333const ContextMenuPosition = Schema.Struct({
2434 x: Schema.Number,
......@@ -45,26 +55,79 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({
4555 }),
4656});
4757
48export const getLocalEnvironmentBootstrap = DesktopIpc.makeSyncIpcMethod({
49 channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL,
50 result: Schema.NullOr(DesktopEnvironmentBootstrapSchema),
51 handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstrap")(function* () {
52 const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
53 const config = yield* backendManager.currentConfig;
54 return Option.match(config, {
55 onNone: () => null,
56 onSome: ({ bootstrap, httpBaseUrl }) => ({
57 label: "Local environment",
58export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({
59 channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL,
60 result: Schema.Array(DesktopEnvironmentBootstrapSchema),
61 handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () {
62 const pool = yield* DesktopBackendPool.DesktopBackendPool;
63 const instances = yield* pool.list;
64 const bootstraps: DesktopEnvironmentBootstrap[] = [];
65 for (const instance of instances) {
66 const isPrimary = instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID;
67 const config = yield* instance.currentConfig;
68 const snapshot = yield* instance.snapshot;
69 // A secondary backend (e.g. a parallel WSL backend) that hasn't produced
70 // a config yet (mid-registration, before its first start cycle) or that
71 // is retrying a *transient* preflight failure (WSL VM still booting, a
72 // not-yet-built linux server entry) is not listening on a port. We
73 // surface it as a *pending* bootstrap (null endpoints, no token) so the
74 // renderer can show a "Connecting…" indicator while it retries — null
75 // endpoints keep the renderer from dialing the dead port, avoiding the
76 // needless /api/auth/bootstrap/bearer error cycles a real endpoint would
77 // trigger.
78 if (Option.isNone(config) || Option.isSome(config.value.preflightFailure)) {
79 // Skip the primary (same-origin, no "connecting" affordance) and skip a
80 // secondary whose preflight failed *fatally* (no node, wrong version,
81 // missing build tools): it has stopped retrying, so an indefinite
82 // "Connecting…" would be misleading — its error is surfaced by the
83 // WSL-state UI instead.
84 const fatalPreflight =
85 Option.isSome(config) &&
86 Option.isSome(config.value.preflightFailure) &&
87 config.value.preflightFailure.value.fatal;
88 const stoppedPreflight =
89 Option.isSome(config) &&
90 Option.isSome(config.value.preflightFailure) &&
91 (!snapshot.desiredRunning || !snapshot.restartScheduled);
92 if (isPrimary || fatalPreflight || stoppedPreflight) continue;
93 bootstraps.push({
94 id: instance.id,
95 label: yield* instance.label,
96 runningDistro: null,
97 httpBaseUrl: null,
98 wsBaseUrl: null,
99 });
100 continue;
101 }
102 const { bootstrap, httpBaseUrl } = config.value;
103 const runningDistro = config.value.runningDistro ?? null;
104 bootstraps.push({
105 id: instance.id,
106 label: runningDistro === null ? yield* instance.label : `WSL (${runningDistro})`,
107 runningDistro,
58108 httpBaseUrl: httpBaseUrl.href,
59109 wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl),
60110 ...(bootstrap.desktopBootstrapToken
61111 ? { bootstrapToken: bootstrap.desktopBootstrapToken }
62112 : {}),
63 }),
64 });
113 });
114 }
115 return bootstraps;
65116 }),
66117});
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
68131export const getLocalEnvironmentBearerToken = DesktopIpc.makeIpcMethod({
69132 channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL,
70133 payload: Schema.Void,
......@@ -83,11 +146,62 @@ export const pickFolder = DesktopIpc.makeIpcMethod({
83146 const dialog = yield* ElectronDialog.ElectronDialog;
84147 const electronWindow = yield* ElectronWindow.ElectronWindow;
85148 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);
86184 const selectedPath = yield* dialog.pickFolder({
87185 owner: yield* electronWindow.focusedMainOrFirst,
88 defaultPath: environment.resolvePickFolderDefaultPath(options),
186 defaultPath,
89187 });
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);
91205 }),
92206});
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";
3131import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";
3232import * as DesktopAssets from "./app/DesktopAssets.ts";
3333import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";
34import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts";
34import * as DesktopBackendPool from "./backend/DesktopBackendPool.ts";
3535import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts";
3636import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts";
3737import * as DesktopEnvironment from "./app/DesktopEnvironment.ts";
......@@ -50,6 +50,8 @@ import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
5050import * as BrowserSession from "./preview/BrowserSession.ts";
5151import * as PreviewManager from "./preview/Manager.ts";
5252import * as DesktopWindow from "./window/DesktopWindow.ts";
53import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";
54import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts";
5355
5456const desktopEnvironmentLayer = Layer.unwrap(
5557 Effect.gen(function* () {
......@@ -143,12 +145,25 @@ const desktopWindowLayer = DesktopWindow.layer.pipe(
143145 Layer.provideMerge(desktopPreviewLayer),
144146);
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(
147154 Layer.provideMerge(DesktopAppIdentity.layer),
148155 Layer.provideMerge(DesktopBackendConfiguration.layer),
156 Layer.provideMerge(DesktopWslEnvironment.layer),
149157 Layer.provideMerge(desktopWindowLayer),
150158);
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
152167const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe(
153168 Layer.provideMerge(desktopBackendLayer),
154169);
......@@ -160,6 +175,7 @@ const desktopApplicationLayer = Layer.mergeAll(
160175 desktopSshLayer,
161176).pipe(
162177 Layer.provideMerge(DesktopUpdates.layer),
178 Layer.provideMerge(desktopWslBackendLayer),
163179 Layer.provideMerge(desktopLocalEnvironmentAuthLayer),
164180);
165181
apps/desktop/src/preload.ts+10-5
......@@ -35,12 +35,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
3535 }
3636 return result as ReturnType<DesktopBridge["getAppBranding"]>;
3737 },
38 getLocalEnvironmentBootstrap: () => {
39 const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
40 if (typeof result !== "object" || result === null) {
41 return null;
38 getLocalEnvironmentBootstraps: () => {
39 const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL);
40 if (!Array.isArray(result)) {
41 return [];
4242 }
43 return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>;
43 return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstraps"]>;
4444 },
4545 getLocalEnvironmentBearerToken: () =>
4646 ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL),
......@@ -91,6 +91,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {
9191 setTailscaleServeEnabled: (input) =>
9292 ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input),
9393 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),
9499 pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),
95100 confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message),
96101 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({
1616 tailscaleServePort: Schema.optionalKey(Schema.Number),
1717 updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])),
1818 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),
1923});
2024
2125const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch));
......@@ -92,6 +96,9 @@ describe("DesktopSettings", () => {
9296 tailscaleServePort: 443,
9397 updateChannel: "nightly",
9498 updateChannelConfiguredByUser: false,
99 wslBackendEnabled: false,
100 wslOnly: false,
101 wslDistro: null,
95102 } satisfies DesktopAppSettings.DesktopSettings,
96103 );
97104 });
......@@ -114,6 +121,9 @@ describe("DesktopSettings", () => {
114121 tailscaleServePort: 8443,
115122 updateChannel: "latest",
116123 updateChannelConfiguredByUser: true,
124 wslBackendEnabled: false,
125 wslOnly: false,
126 wslDistro: null,
117127 } satisfies DesktopAppSettings.DesktopSettings);
118128
119129 const exposure = yield* settings.setServerExposureMode("local-only");
......@@ -214,6 +224,9 @@ describe("DesktopSettings", () => {
214224 tailscaleServePort: 8443,
215225 updateChannel: "latest",
216226 updateChannelConfiguredByUser: false,
227 wslBackendEnabled: false,
228 wslOnly: false,
229 wslDistro: null,
217230 } satisfies DesktopAppSettings.DesktopSettings);
218231 }),
219232 ),
......@@ -253,6 +266,9 @@ describe("DesktopSettings", () => {
253266 tailscaleServePort: 443,
254267 updateChannel: "nightly",
255268 updateChannelConfiguredByUser: false,
269 wslBackendEnabled: false,
270 wslOnly: false,
271 wslDistro: null,
256272 } satisfies DesktopAppSettings.DesktopSettings);
257273 }),
258274 { appVersion: "0.0.17-nightly.20260415.1" },
......@@ -275,6 +291,9 @@ describe("DesktopSettings", () => {
275291 tailscaleServePort: 443,
276292 updateChannel: "latest",
277293 updateChannelConfiguredByUser: true,
294 wslBackendEnabled: false,
295 wslOnly: false,
296 wslDistro: null,
278297 } satisfies DesktopAppSettings.DesktopSettings);
279298 }),
280299 { appVersion: "0.0.17-nightly.20260415.1" },
......@@ -296,8 +315,101 @@ describe("DesktopSettings", () => {
296315 tailscaleServePort: 443,
297316 updateChannel: "latest",
298317 updateChannelConfiguredByUser: false,
318 wslBackendEnabled: false,
319 wslOnly: false,
320 wslDistro: null,
299321 } satisfies DesktopAppSettings.DesktopSettings);
300322 }),
301323 ),
302324 );
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 );
303415});
apps/desktop/src/settings/DesktopAppSettings.ts+127
......@@ -17,6 +17,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
1717
1818import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1919import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts";
20import { isValidDistroName } from "../wsl/wslPathParsing.ts";
2021
2122export interface DesktopSettings {
2223 readonly serverExposureMode: DesktopServerExposureMode;
......@@ -24,6 +25,21 @@ export interface DesktopSettings {
2425 readonly tailscaleServePort: number;
2526 readonly updateChannel: DesktopUpdateChannel;
2627 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;
2743}
2844
2945export interface DesktopSettingsChange {
......@@ -39,6 +55,9 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
3955 tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
4056 updateChannel: "latest",
4157 updateChannelConfiguredByUser: false,
58 wslBackendEnabled: false,
59 wslDistro: null,
60 wslOnly: false,
4261};
4362
4463const DesktopSettingsDocument = Schema.Struct({
......@@ -47,6 +66,13 @@ const DesktopSettingsDocument = Schema.Struct({
4766 tailscaleServePort: Schema.optionalKey(Schema.Number),
4867 updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema),
4968 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),
5076});
5177
5278type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type;
......@@ -98,6 +124,20 @@ export class DesktopAppSettings extends Context.Service<
98124 readonly setUpdateChannel: (
99125 channel: DesktopUpdateChannel,
100126 ) => 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>;
101141 }
102142>()("@t3tools/desktop/settings/DesktopAppSettings") {}
103143
......@@ -114,6 +154,10 @@ function normalizeTailscaleServePort(value: unknown): number {
114154 : DEFAULT_TAILSCALE_SERVE_PORT;
115155}
116156
157function normalizeWslDistro(value: unknown): string | null {
158 return typeof value === "string" && isValidDistroName(value) ? value : null;
159}
160
117161function normalizeDesktopSettingsDocument(
118162 parsed: DesktopSettingsDocument,
119163 appVersion: string,
......@@ -125,6 +169,13 @@ function normalizeDesktopSettingsDocument(
125169 parsed.updateChannelConfiguredByUser === true ||
126170 (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
128179 return {
129180 serverExposureMode:
130181 parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
......@@ -134,6 +185,9 @@ function normalizeDesktopSettingsDocument(
134185 ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel)
135186 : defaultSettings.updateChannel,
136187 updateChannelConfiguredByUser,
188 wslBackendEnabled,
189 wslDistro: normalizeWslDistro(parsed.wslDistro),
190 wslOnly: parsed.wslOnly === true,
137191 };
138192}
139193
......@@ -158,6 +212,15 @@ function toDesktopSettingsDocument(
158212 if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) {
159213 document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser;
160214 }
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
162225 return document;
163226}
......@@ -204,6 +267,38 @@ function setUpdateChannel(
204267 };
205268}
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
207302function readSettings(
208303 fileSystem: FileSystem.FileSystem,
209304 settingsPath: string,
......@@ -287,6 +382,12 @@ export const make = Effect.gen(function* () {
287382 const crypto = yield* Crypto.Crypto;
288383 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
290391 const persist = (
291392 update: (settings: DesktopSettings) => DesktopSettings,
292393 ): Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError> =>
......@@ -342,6 +443,26 @@ export const make = Effect.gen(function* () {
342443 persist((settings) => setUpdateChannel(settings, channel)).pipe(
343444 Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }),
344445 ),
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 ),
345466 });
346467});
347468
......@@ -371,6 +492,12 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET
371492 update((settings) => setServerExposureMode(settings, mode)),
372493 setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)),
373494 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),
374501 });
375502 }),
376503 );
apps/desktop/src/updates/DesktopUpdates.test.ts+12-3
......@@ -13,7 +13,7 @@ import * as References from "effect/References";
1313import * as Ref from "effect/Ref";
1414import * as TestClock from "effect/testing/TestClock";
1515
16import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
16import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
1717import * as DesktopConfig from "../app/DesktopConfig.ts";
1818import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1919import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
......@@ -107,7 +107,9 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
107107 syncAllAppearance: () => Effect.void,
108108 } 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"),
111113 start: Effect.void,
112114 stop: () => options.stopBackend ?? Effect.void,
113115 currentConfig: Effect.succeed(Option.none()),
......@@ -118,7 +120,9 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
118120 restartAttempt: 0,
119121 restartScheduled: false,
120122 }),
121 });
123 waitForReady: () => Effect.succeed(true),
124 };
125 const backendLayer = DesktopBackendPool.layerTest([stubBackendInstance]);
122126
123127 const environmentLayer = DesktopEnvironment.layer({
124128 dirname: "/repo/apps/desktop/src",
......@@ -152,6 +156,11 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
152156 setServerExposureMode: () => Effect.die("unexpected server exposure update"),
153157 setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"),
154158 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"),
155164 } satisfies DesktopAppSettings.DesktopAppSettings["Service"])
156165 : DesktopAppSettings.layer;
157166
apps/desktop/src/updates/DesktopUpdates.ts+15-3
......@@ -18,7 +18,7 @@ import * as Ref from "effect/Ref";
1818import * as Schema from "effect/Schema";
1919import * as Scope from "effect/Scope";
2020
21import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
21import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
2222import * as DesktopConfig from "../app/DesktopConfig.ts";
2323import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
2424import * as DesktopObservability from "../app/DesktopObservability.ts";
......@@ -241,7 +241,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean
241241
242242export const make = Effect.gen(function* () {
243243 const config = yield* DesktopConfig.DesktopConfig;
244 const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
244 const pool = yield* DesktopBackendPool.DesktopBackendPool;
245245 const desktopState = yield* DesktopState.DesktopState;
246246 const electronUpdater = yield* ElectronUpdater.ElectronUpdater;
247247 const electronWindow = yield* ElectronWindow.ElectronWindow;
......@@ -458,7 +458,19 @@ export const make = Effect.gen(function* () {
458458 yield* Ref.set(updateInstallInFlightRef, true);
459459
460460 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 );
462474 yield* electronWindow.destroyAll;
463475 yield* electronUpdater.quitAndInstall({
464476 isSilent: true,
apps/desktop/src/window/DesktopApplicationMenu.test.ts+3-1
......@@ -73,7 +73,9 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) =>
7373 revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"),
7474 activate: Effect.void,
7575 createMainIfBackendReady: Effect.void,
76 handleBackendReady: Effect.void,
76 showConnectingSplash: Effect.void,
77 handleBackendReady: () => Effect.void,
78 handleBackendNotReady: Effect.void,
7779 dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid),
7880 syncAppearance: Effect.void,
7981 } satisfies DesktopWindow.DesktopWindow["Service"]);
apps/desktop/src/window/DesktopWindow.test.ts+204-3
......@@ -28,6 +28,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts";
2828import * as ElectronShell from "../electron/ElectronShell.ts";
2929import * as ElectronTheme from "../electron/ElectronTheme.ts";
3030import * as ElectronWindow from "../electron/ElectronWindow.ts";
31import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts";
3132import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
3233import * as DesktopWindow from "./DesktopWindow.ts";
3334import * as PreviewManager from "../preview/Manager.ts";
......@@ -62,6 +63,7 @@ function makeFakeBrowserWindow() {
6263 };
6364
6465 const window = {
66 close: vi.fn(),
6567 focus: vi.fn(),
6668 isDestroyed: vi.fn(() => false),
6769 isMinimized: vi.fn(() => false),
......@@ -83,6 +85,7 @@ function makeFakeBrowserWindow() {
8385 loadURL: window.loadURL,
8486 openDevTools: webContents.openDevTools,
8587 reload: webContents.reload,
88 send: webContents.send,
8689 setAutoHideCursor: window.setAutoHideCursor,
8790 webContentsListeners,
8891 };
......@@ -191,6 +194,100 @@ function makeTestLayer(input: {
191194 );
192195}
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
194291describe("DesktopWindow", () => {
195292 it("recognizes only same-origin renderer navigations", () => {
196293 assert.isTrue(
......@@ -231,7 +328,7 @@ describe("DesktopWindow", () => {
231328 yield* desktopWindow.activate;
232329 assert.equal(yield* Ref.get(createCount), 0);
233330
234 yield* desktopWindow.handleBackendReady;
331 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
235332 assert.equal(yield* Ref.get(createCount), 1);
236333 assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor);
237334 assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]);
......@@ -254,7 +351,7 @@ describe("DesktopWindow", () => {
254351
255352 yield* Effect.gen(function* () {
256353 const desktopWindow = yield* DesktopWindow.DesktopWindow;
257 yield* desktopWindow.handleBackendReady;
354 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
258355
259356 const didFailLoad = fakeWindow.webContentsListeners.get("did-fail-load");
260357 const didFinishLoad = fakeWindow.webContentsListeners.get("did-finish-load");
......@@ -323,7 +420,7 @@ describe("DesktopWindow", () => {
323420
324421 yield* Effect.gen(function* () {
325422 const desktopWindow = yield* DesktopWindow.DesktopWindow;
326 yield* desktopWindow.handleBackendReady;
423 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
327424
328425 const willNavigate = fakeWindow.webContentsListeners.get("will-navigate");
329426 if (!willNavigate) {
......@@ -345,4 +442,108 @@ describe("DesktopWindow", () => {
345442 }).pipe(Effect.provide(layer));
346443 }),
347444 );
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 );
348549});
apps/desktop/src/window/DesktopWindow.ts+144-17
......@@ -10,7 +10,6 @@ import type * as Electron from "electron";
1010import * as DesktopAssets from "../app/DesktopAssets.ts";
1111import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1212import { makeComponentLogger } from "../app/DesktopObservability.ts";
13import * as DesktopState from "../app/DesktopState.ts";
1413import * as ElectronMenu from "../electron/ElectronMenu.ts";
1514import { getDesktopUrl } from "../electron/ElectronProtocol.ts";
1615import * as ElectronShell from "../electron/ElectronShell.ts";
......@@ -42,7 +41,6 @@ type WindowTitleBarOptions = Pick<
4241type DesktopWindowRuntimeServices =
4342 | DesktopEnvironment.DesktopEnvironment
4443 | DesktopAssets.DesktopAssets
45 | DesktopState.DesktopState
4644 | ElectronMenu.ElectronMenu
4745 | ElectronShell.ElectronShell
4846 | ElectronTheme.ElectronTheme
......@@ -61,7 +59,22 @@ export class DesktopWindow extends Context.Service<
6159 readonly revealOrCreateMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>;
6260 readonly activate: Effect.Effect<void, DesktopWindowError>;
6361 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>;
6578 readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>;
6679 readonly syncAppearance: Effect.Effect<void>;
6780 }
......@@ -86,6 +99,18 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string {
8699 return shouldUseDarkColors ? "#0a0a0a" : "#ffffff";
87100}
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
89114export function isSameOriginRendererNavigation(input: {
90115 readonly applicationUrl: string;
91116 readonly navigationUrl: string;
......@@ -177,11 +202,45 @@ export const make = Effect.gen(function* () {
177202 const electronTheme = yield* ElectronTheme.ElectronTheme;
178203 const electronWindow = yield* ElectronWindow.ElectronWindow;
179204 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());
181214 const context = yield* Effect.context<DesktopWindowRuntimeServices>();
182215 const runFork = Effect.runForkWith(context);
183216 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
185244 const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return<
186245 Electron.BrowserWindow,
187246 DesktopWindowError
......@@ -402,7 +461,9 @@ export const make = Effect.gen(function* () {
402461 revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire));
403462 }
404463 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));
406467 });
407468
408469 loadApplication();
......@@ -426,7 +487,7 @@ export const make = Effect.gen(function* () {
426487 }).pipe(Effect.withSpan("desktop.window.createMain"));
427488
428489 const ensureMain = Effect.gen(function* () {
429 const existingWindow = yield* electronWindow.currentMainOrFirst;
490 const existingWindow = yield* currentMainWindow;
430491 if (Option.isSome(existingWindow)) {
431492 return existingWindow.value;
432493 }
......@@ -440,35 +501,101 @@ export const make = Effect.gen(function* () {
440501 }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain"));
441502
442503 const createMainIfBackendReady = Effect.gen(function* () {
443 const backendReady = yield* Ref.get(state.backendReady);
504 const backendReady = yield* Ref.get(backendReadyRef);
444505 if (!backendReady) return;
445 const existingWindow = yield* electronWindow.currentMainOrFirst;
506 const existingWindow = yield* currentMainWindow;
446507 if (Option.isSome(existingWindow)) return;
447508 yield* createMain;
448509 }).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
450557 return DesktopWindow.of({
451558 createMain,
452559 ensureMain,
453560 revealOrCreateMain,
454561 activate: Effect.gen(function* () {
455 const existingWindow = yield* electronWindow.currentMainOrFirst;
562 const existingWindow = yield* currentMainWindow;
456563 if (Option.isSome(existingWindow)) {
457564 yield* electronWindow.reveal(existingWindow.value);
458 } else {
459 yield* createMainIfBackendReady;
565 return;
566 }
567 // No real main window yet. While the backend is still cold-booting,
568 // re-reveal the connecting splash so taskbar/dock activation brings it
569 // back instead of doing nothing. Once the backend is ready we fall
570 // through to (re)create the real main -- including retrying a previously
571 // failed open the pool swallowed -- rather than latching onto the splash.
572 const backendReady = yield* Ref.get(backendReadyRef);
573 if (!backendReady) {
574 const splash = yield* Ref.get(splashWindowRef);
575 if (Option.isSome(splash)) {
576 yield* electronWindow.reveal(splash.value);
577 return;
578 }
460579 }
580 yield* createMainIfBackendReady;
461581 }).pipe(Effect.withSpan("desktop.window.activate")),
462582 createMainIfBackendReady,
463 handleBackendReady: Effect.gen(function* () {
464 yield* Ref.set(state.backendReady, true);
465 yield* logWindowInfo("backend ready", { source: "http" });
583 showConnectingSplash,
584 handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) {
585 yield* Ref.set(backendReadyRef, true);
586 yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href });
466587 yield* createMainIfBackendReady;
467 }).pipe(Effect.withSpan("desktop.window.handleBackendReady")),
588 }),
589 handleBackendNotReady: Ref.set(backendReadyRef, false).pipe(
590 Effect.withSpan("desktop.window.handleBackendNotReady"),
591 ),
468592 dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) {
469593 yield* Effect.annotateCurrentSpan({ action });
470 const existingWindow = yield* electronWindow.focusedMainOrFirst;
471 const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* createMain;
594 const existingWindow = yield* focusedMainWindow;
595 if (Option.isNone(existingWindow) && !(yield* Ref.get(backendReadyRef))) {
596 return;
597 }
598 const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain;
472599
473600 const send = () => {
474601 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) => {
137137 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
138138 );
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", () =>
141141 Effect.gen(function* () {
142142 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
143143 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
146147 expect(first.method).toBe("desktop-bootstrap");
147148 expect(first.scopes).toEqual([
......@@ -155,7 +156,8 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
155156 "relay:write",
156157 ]);
157158 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");
159161 }).pipe(
160162 Effect.provide(
161163 makePairingGrantStoreLayer({
......@@ -169,7 +171,13 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
169171 Effect.gen(function* () {
170172 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));
173181 const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
174182
175183 expect(expired._tag).toBe("ExpiredBootstrapCredentialError");
apps/server/src/auth/PairingGrantStore.ts+16-2
......@@ -234,6 +234,15 @@ type ConsumeResult =
234234 };
235235
236236const 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);
237246const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
238247const PAIRING_TOKEN_LENGTH = 12;
239248const PAIRING_TOKEN_REJECTION_LIMIT =
......@@ -295,9 +304,14 @@ export const make = Effect.gen(function* () {
295304 scopes: AuthAdministrativeScopes,
296305 subject: "desktop-bootstrap",
297306 expiresAt: DateTime.add(now, {
298 milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES),
307 milliseconds: Duration.toMillis(DESKTOP_BOOTSTRAP_TTL_HOURS),
299308 }),
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",
301315 });
302316 }
303317
apps/server/src/bootstrap.ts+4-1
......@@ -222,9 +222,12 @@ const makeDirectBootstrapStream = (fd: number): NodeStream.Readable => {
222222 }
223223};
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.
225228const isBootstrapFdPathDuplicationError = Predicate.compose(
226229 Predicate.hasProperty("code"),
227 (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM",
230 (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM" || _.code === "EACCES",
228231);
229232
230233function 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";
1717import * as Stream from "effect/Stream";
1818import { HttpClient, HttpClientResponse } from "effect/unstable/http";
1919import { ChildProcessSpawner } from "effect/unstable/process";
20import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
21import { SpawnExecutableResolution } from "@t3tools/shared/shell";
2022
2123import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts";
2224import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts";
......@@ -35,6 +37,12 @@ const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor");
3537const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode");
3638const 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
3846function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities {
3947 if (provider === CURSOR_DRIVER) {
4048 return makeProviderMaintenanceCapabilities({
......@@ -230,6 +238,7 @@ describe("providerMaintenanceRunner", () => {
230238 }).pipe(
231239 Effect.provide(
232240 Layer.mergeAll(
241 NonWindowsPlatform,
233242 latestVersionHttpClient("0.0.0"),
234243 mockSpawnerLayer((command, args) => {
235244 calls.push({ command, args });
......@@ -279,6 +288,7 @@ describe("providerMaintenanceRunner", () => {
279288 }).pipe(
280289 Effect.provide(
281290 Layer.mergeAll(
291 NonWindowsPlatform,
282292 latestVersionHttpClient("0.0.0"),
283293 mockSpawnerLayer((command, args) => {
284294 calls.push({ command, args });
......@@ -309,6 +319,7 @@ describe("providerMaintenanceRunner", () => {
309319 }).pipe(
310320 Effect.provide(
311321 Layer.mergeAll(
322 NonWindowsPlatform,
312323 latestVersionHttpClient("0.0.0"),
313324 mockSpawnerLayer((command, args) => {
314325 calls.push({ command, args });
......@@ -381,6 +392,7 @@ describe("providerMaintenanceRunner", () => {
381392 }).pipe(
382393 Effect.provide(
383394 Layer.mergeAll(
395 NonWindowsPlatform,
384396 latestVersionHttpClient("0.124.0-alpha.3"),
385397 mockSpawnerLayer((command, args) => {
386398 calls.push({ command, args });
......@@ -405,6 +417,7 @@ describe("providerMaintenanceRunner", () => {
405417 }).pipe(
406418 Effect.provide(
407419 Layer.mergeAll(
420 NonWindowsPlatform,
408421 latestVersionHttpClient("0.0.0"),
409422 mockSpawnerLayer(() => ({ stderr: "permission denied", code: 1 })),
410423 ),
......@@ -430,6 +443,7 @@ describe("providerMaintenanceRunner", () => {
430443 }).pipe(
431444 Effect.provide(
432445 Layer.mergeAll(
446 NonWindowsPlatform,
433447 latestVersionHttpClient("9.9.9"),
434448 mockSpawnerLayer(() => ({ stdout: "updated" })),
435449 ),
......@@ -468,6 +482,7 @@ describe("providerMaintenanceRunner", () => {
468482 }).pipe(
469483 Effect.provide(
470484 Layer.mergeAll(
485 NonWindowsPlatform,
471486 latestVersionHttpClient("0.0.0"),
472487 mockSpawnerLayer(() => {
473488 startedLatch.resolve();
......@@ -544,6 +559,7 @@ describe("providerMaintenanceRunner", () => {
544559 }).pipe(
545560 Effect.provide(
546561 Layer.mergeAll(
562 NonWindowsPlatform,
547563 latestVersionHttpClient("0.0.0"),
548564 mockSpawnerLayer((_command, args) => {
549565 calls.push(args.join(" "));
......@@ -587,6 +603,7 @@ describe("providerMaintenanceRunner", () => {
587603 }).pipe(
588604 Effect.provide(
589605 Layer.mergeAll(
606 NonWindowsPlatform,
590607 latestVersionHttpClient("0.0.0"),
591608 mockSpawnerLayer((_command, args) => {
592609 calls.push(args.join(" "));
......@@ -641,10 +658,73 @@ describe("providerMaintenanceRunner", () => {
641658 }).pipe(
642659 Effect.provide(
643660 Layer.mergeAll(
661 NonWindowsPlatform,
644662 latestVersionHttpClient("0.0.0"),
645663 mockSpawnerLayer(() => ({ stdout: "updated" })),
646664 ),
647665 ),
648666 ),
649667 );
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 });
650730});
apps/server/src/provider/providerMaintenanceRunner.ts+8-1
......@@ -7,6 +7,7 @@ import {
77 type ServerProviderUpdatedPayload,
88 type ServerProviderUpdateState,
99} from "@t3tools/contracts";
10import { resolveSpawnCommand } from "@t3tools/shared/shell";
1011import * as Cause from "effect/Cause";
1112import * as Context from "effect/Context";
1213import * as Data from "effect/Data";
......@@ -75,8 +76,14 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
7576 }) {
7677 const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")(
7778 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);
7885 const child = yield* input.spawner
79 .spawn(ChildProcess.make(input.command, [...input.args]))
86 .spawn(ChildProcess.make(resolved.command, resolved.args, { shell: resolved.shell }))
8087 .pipe(
8188 Effect.mapError(
8289 (cause) =>
apps/server/src/server.test.ts+6-5
......@@ -3680,18 +3680,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
36803680 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
36813681 );
36823682
3683 it.effect("rejects reusing the same bootstrap credential after it has been exchanged", () =>
3683 it.effect("allows reusing the desktop bootstrap credential", () =>
36843684 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).
36853689 yield* buildAppUnderTest();
36863690
36873691 const first = yield* bootstrapBrowserSession();
36883692 const second = yield* bootstrapBrowserSession();
36893693
36903694 assert.equal(first.response.status, 200);
3691 assert.equal(second.response.status, 401);
3692 assert.equal((second.body as { readonly _tag?: string })._tag, "EnvironmentAuthInvalidError");
3693 assert.equal((second.body as { readonly code?: string }).code, "auth_invalid");
3694 assert.equal((second.body as { readonly reason?: string }).reason, "invalid_credential");
3695 assert.equal(second.response.status, 200);
36953696 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
36963697 );
36973698
apps/web/src/authBootstrap.test.ts+43-15
......@@ -74,13 +74,16 @@ function installTestBrowser(url: string) {
7474function installDesktopBootstrap() {
7575 const testWindow = installTestBrowser("http://localhost/");
7676 testWindow.desktopBridge = {
77 getLocalEnvironmentBootstrap: () => ({
78 label: "Local environment",
79 httpBaseUrl: "http://localhost:3773",
80 wsBaseUrl: "ws://localhost:3773",
81 bootstrapToken: "desktop-bootstrap-token",
82 }),
83 } as DesktopBridge;
77 getLocalEnvironmentBootstraps: () => [
78 {
79 id: "primary",
80 label: "Local environment",
81 httpBaseUrl: "http://localhost:3773",
82 wsBaseUrl: "ws://localhost:3773",
83 bootstrapToken: "desktop-bootstrap-token",
84 },
85 ],
86 } as unknown as DesktopBridge;
8487}
8588
8689function sequence<A>(...values: ReadonlyArray<A>) {
......@@ -143,7 +146,18 @@ describe("resolveInitialServerAuthGateState", () => {
143146 browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
144147 });
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
148162 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
149163
......@@ -192,12 +206,15 @@ describe("resolveInitialServerAuthGateState", () => {
192206
193207 const testWindow = installTestBrowser("http://127.0.0.1:5733/");
194208 testWindow.desktopBridge = {
195 getLocalEnvironmentBootstrap: () => ({
196 label: "Local environment",
197 httpBaseUrl: "http://127.0.0.1:3773",
198 wsBaseUrl: "ws://127.0.0.1:3773",
199 }),
200 } as DesktopBridge;
209 getLocalEnvironmentBootstraps: () => [
210 {
211 id: "primary",
212 label: "Windows",
213 httpBaseUrl: "http://127.0.0.1:3773",
214 wsBaseUrl: "ws://127.0.0.1:3773",
215 },
216 ],
217 } as unknown as DesktopBridge;
201218
202219 const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } =
203220 await import("./environments/primary");
......@@ -373,7 +390,18 @@ describe("resolveInitialServerAuthGateState", () => {
373390 browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
374391 });
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
378406 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
379407
apps/web/src/components/CommandPalette.tsx+180-15
......@@ -8,6 +8,7 @@ import {
88} from "@t3tools/client-runtime/state/runtime";
99import {
1010 DEFAULT_MODEL,
11 type DesktopWslState,
1112 type EnvironmentId,
1213 type FilesystemBrowseResult,
1314 type ProjectId,
......@@ -15,6 +16,7 @@ import {
1516 type SourceControlDiscoveryResult,
1617 type SourceControlProviderKind,
1718 type SourceControlRepositoryInfo,
19 PRIMARY_LOCAL_ENVIRONMENT_ID,
1820} from "@t3tools/contracts";
1921import { useNavigate, useParams } from "@tanstack/react-router";
2022import * as Option from "effect/Option";
......@@ -44,9 +46,12 @@ import {
4446} from "react";
4547import { useAtomValue } from "@effect/atom-react";
4648import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext";
49import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
50import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
4751import { useHandleNewThread } from "../hooks/useHandleNewThread";
4852import { useClientSettings } from "../hooks/useSettings";
4953import { readLocalApi } from "../localApi";
54import { desktopLocalBackendId } from "../connection/desktopLocal";
5055import { filesystemEnvironment } from "../state/filesystem";
5156import { projectEnvironment } from "../state/projects";
5257import { useEnvironmentQuery } from "../state/query";
......@@ -79,6 +84,12 @@ import { getLatestThreadForProject } from "../lib/threadSort";
7984import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils";
8085import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
8186import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
87import {
88 applyWslEnvironmentConfiguration,
89 parseWslUncPath,
90 resolveProjectPickerTarget,
91 resolveWslProjectSelection,
92} from "../wslPaths";
8293import {
8394 ADDON_ICON_CLASS,
8495 buildBrowseGroups,
......@@ -458,6 +469,7 @@ function OpenCommandPaletteDialog(props: {
458469 reportFailure: false,
459470 });
460471 const { environments } = useEnvironments();
472 const desktopLocalBootstraps = useDesktopLocalBootstraps();
461473 const primaryEnvironment = usePrimaryEnvironment();
462474 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
463475 useHandleNewThread();
......@@ -500,9 +512,43 @@ function OpenCommandPaletteDialog(props: {
500512 return options;
501513 }, [environments]);
502514 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 );
503527 const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId;
504528 const browseEnvironment =
505529 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]);
506552 const sourceControlDiscovery = useEnvironmentQuery(
507553 browseEnvironmentId === null
508554 ? null
......@@ -984,6 +1030,21 @@ function OpenCommandPaletteDialog(props: {
9841030 },
9851031 });
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
9871048 actionItems.push({
9881049 kind: "action",
9891050 value: "action:settings",
......@@ -1016,11 +1077,16 @@ function OpenCommandPaletteDialog(props: {
10161077 threadSearchItems: allThreadItems,
10171078 });
10181079
1019 const handleAddProject = useCallback(
1020 async (rawCwd: string) => {
1021 if (!browseEnvironmentId) return;
1080 const handleAddProjectForEnvironment = useCallback(
1081 async (input: {
1082 readonly environmentId: EnvironmentId;
1083 readonly rawCwd: string;
1084 readonly platform: string;
1085 readonly currentProjectCwd: string | null;
1086 }) => {
1087 const rawCwd = input.rawCwd;
10221088
1023 if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) {
1089 if (isUnsupportedWindowsProjectPath(rawCwd.trim(), input.platform)) {
10241090 toastManager.add(
10251091 stackedThreadToast({
10261092 type: "error",
......@@ -1031,7 +1097,7 @@ function OpenCommandPaletteDialog(props: {
10311097 return;
10321098 }
10331099
1034 if (isExplicitRelativeProjectPath(rawCwd.trim()) && !currentProjectCwdForBrowse) {
1100 if (isExplicitRelativeProjectPath(rawCwd.trim()) && !input.currentProjectCwd) {
10351101 toastManager.add(
10361102 stackedThreadToast({
10371103 type: "error",
......@@ -1042,11 +1108,11 @@ function OpenCommandPaletteDialog(props: {
10421108 return;
10431109 }
10441110
1045 const cwd = resolveProjectPathForDispatch(rawCwd, currentProjectCwdForBrowse);
1111 const cwd = resolveProjectPathForDispatch(rawCwd, input.currentProjectCwd);
10461112 if (cwd.length === 0) return;
10471113
10481114 const existing = findProjectByPath(
1049 projects.filter((project) => project.environmentId === browseEnvironmentId),
1115 projects.filter((project) => project.environmentId === input.environmentId),
10501116 cwd,
10511117 );
10521118 if (existing) {
......@@ -1084,7 +1150,7 @@ function OpenCommandPaletteDialog(props: {
10841150
10851151 const projectId = newProjectId();
10861152 const createResult = await createProject({
1087 environmentId: browseEnvironmentId,
1153 environmentId: input.environmentId,
10881154 input: {
10891155 projectId,
10901156 title: inferProjectTitleFromPath(cwd),
......@@ -1111,7 +1177,7 @@ function OpenCommandPaletteDialog(props: {
11111177 }
11121178
11131179 const navigationResult = await settlePromise(() =>
1114 handleNewThread(scopeProjectRef(browseEnvironmentId, projectId)),
1180 handleNewThread(scopeProjectRef(input.environmentId, projectId)),
11151181 );
11161182 if (navigationResult._tag === "Failure") {
11171183 const error = squashAtomCommandFailure(navigationResult);
......@@ -1127,9 +1193,6 @@ function OpenCommandPaletteDialog(props: {
11271193 setOpen(false);
11281194 },
11291195 [
1130 browseEnvironmentId,
1131 browseEnvironmentPlatform,
1132 currentProjectCwdForBrowse,
11331196 handleNewThread,
11341197 createProject,
11351198 navigate,
......@@ -1140,6 +1203,24 @@ function OpenCommandPaletteDialog(props: {
11401203 ],
11411204 );
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
11431224 function getDefaultCloneParentPath(environmentId: EnvironmentId): string {
11441225 return getAddProjectInitialQueryForEnvironment(environmentId);
11451226 }
......@@ -1372,8 +1453,13 @@ function OpenCommandPaletteDialog(props: {
13721453 const canOpenProjectFromFileManager =
13731454 isBrowsing &&
13741455 browseEnvironmentId !== null &&
1375 primaryEnvironmentId !== null &&
1376 browseEnvironmentId === primaryEnvironmentId &&
1456 // For a desktop-local (WSL) env, only offer the picker once we have resolved
1457 // its desktop pool instance id. Without it pickFolder can't be routed to the
1458 // WSL filesystem and would open the primary (Windows) picker, then add the
1459 // chosen Windows path against the WSL env -- a wrong-path footgun. Stay
1460 // hidden until the bootstrap mapping is available rather than mis-routing.
1461 (browseEnvironmentId === primaryEnvironmentId ||
1462 (browseEnvironmentIsDesktopLocal && browseDesktopInstanceId !== null)) &&
13771463 typeof window !== "undefined" &&
13781464 window.desktopBridge !== undefined;
13791465 const fileManagerInitialPath = useMemo(() => {
......@@ -1468,9 +1554,31 @@ function OpenCommandPaletteDialog(props: {
14681554
14691555 setIsPickingProjectFolder(true);
14701556 let pickedPath: string | null = null;
1557 let desktopWslState: DesktopWslState | null = null;
14711558 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 };
14721580 pickedPath = await api.dialogs.pickFolder(
1473 fileManagerInitialPath ? { initialPath: fileManagerInitialPath } : undefined,
1581 Object.keys(pickerOptions).length > 0 ? pickerOptions : undefined,
14741582 );
14751583 } catch {
14761584 // Ignore picker failures and leave the palette open.
......@@ -1481,12 +1589,69 @@ function OpenCommandPaletteDialog(props: {
14811589 if (!pickedPath) {
14821590 return;
14831591 }
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 }
14841642 await handleAddProject(pickedPath);
14851643 }, [
1644 browseDesktopInstanceId,
1645 browseEnvironmentId,
1646 browseEnvironmentPlatform,
14861647 canOpenProjectFromFileManager,
1648 desktopLocalBootstraps,
1649 environments,
14871650 fileManagerInitialPath,
14881651 handleAddProject,
1652 handleAddProjectForEnvironment,
14891653 isPickingProjectFolder,
1654 primaryEnvironmentId,
14901655 ]);
14911656
14921657 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 @@
11import { 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";
38import * as Cause from "effect/Cause";
49import { AsyncResult } from "effect/unstable/reactivity";
510
611import {
12 buildLocalEnvironmentUpdateGroups,
713 canOneClickUpdateProviderCandidate,
814 collectProviderUpdateCandidates,
15 collectProviderUpdateOutcomeSnapshots,
916 collectUpdatedProviderSnapshots,
17 deriveEnvironmentDisplayLabel,
18 environmentGroupsWithUpdates,
1019 firstFailedProviderUpdateMessage,
20 firstRejectedProviderUpdateMessage,
21 firstUnsuccessfulSecondaryProviderOutcome,
1122 getProviderUpdateInitialToastView,
1223 getProviderUpdateProgressToastView,
1324 getProviderUpdateRejectedToastView,
......@@ -15,8 +26,17 @@ import {
1526 getSingleProviderUpdateProgressToastView,
1627 hasOneClickUpdateProviderCandidate,
1728 isProviderUpdateCandidate,
29 isTerminalProviderUpdatePhase,
30 localEnvironmentUpdateNotificationKey,
31 parseWslDistroFromInstanceId,
1832 providerUpdateNotificationKey,
33 resolveEnvironmentUpdateRowStatus,
34 type LocalEnvironmentProvidersInput,
35 type LocalEnvironmentUpdateGroup,
36 type LocalProviderUpdateOutcome,
1937 type ProviderUpdateCandidate,
38 type ProviderUpdateSidebarPillView,
39 type ProviderUpdateToastView,
2040} from "./ProviderUpdateLaunchNotification.logic";
2141
2242const checkedAt = "2026-04-23T10:00:00.000Z";
......@@ -665,4 +685,431 @@ describe("provider update launch notification logic", () => {
665685 ]),
666686 ).toBeNull();
667687 });
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 });
6681115});
apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts+293
......@@ -1,6 +1,8 @@
11import {
22 defaultInstanceIdForDriver,
33 PROVIDER_DISPLAY_NAMES,
4 type EnvironmentId,
5 type ExecutionEnvironmentPlatformOs,
46 type ProviderDriverKind,
57 type ProviderInstanceId,
68 type ServerProvider,
......@@ -28,6 +30,17 @@ export interface ProviderUpdateToastView {
2830 readonly dismissAfterVisibleMs?: number;
2931}
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
3144export type ProviderUpdateSidebarPillTone = "loading" | "warning" | "error" | "success";
3245
3346export interface ProviderUpdateSidebarPillView {
......@@ -540,3 +553,283 @@ function getFailedProviderUpdateDescription(providers: ReadonlyArray<ServerProvi
540553 }
541554 return `${formatProviderList(providers)} failed to update. Check provider settings for details.`;
542555}
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 @@
11import { useNavigate } from "@tanstack/react-router";
2import { useAtomValue } from "@effect/atom-react";
32import { DownloadIcon } from "lucide-react";
4import { useCallback, useEffect, useMemo, useRef } from "react";
5import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
64
7import { primaryServerProvidersAtom, serverEnvironment } from "../state/server";
8import { usePrimaryEnvironment } from "../state/environments";
5import { useEnvironments } from "~/state/environments";
6import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
97import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal";
10import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils";
8import { ProviderUpdateEnvironmentRows } from "./ProviderUpdateEnvironmentRows";
9import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments";
1110import {
12 canOneClickUpdateProviderCandidate,
1311 collectProviderUpdateCandidates,
14 collectUpdatedProviderSnapshots,
15 firstFailedProviderUpdateMessage,
12 environmentGroupsWithUpdates,
1613 getProviderUpdateInitialToastView,
17 getProviderUpdateProgressToastView,
18 getProviderUpdateRejectedToastView,
19 getProviderUpdateRunningToastView,
20 providerUpdateNotificationKey,
21 type ProviderUpdateToastView,
14 localEnvironmentUpdateNotificationKey,
2215} from "./ProviderUpdateLaunchNotification.logic";
16import { ProviderUpdatePrimaryNotification } from "./ProviderUpdatePrimaryNotification";
2317import { stackedThreadToast, toastManager } from "./ui/toast";
24import { useAtomCommand } from "../state/use-atom-command";
2518
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>
19/**
20 * True when a desktop-local secondary backend (the parallel WSL backend) is
21 * present alongside the primary. Local secondaries connect over loopback with a
22 * `local:<backendInstanceId>` bearer connection id; everything else (SSH, relay,
23 * remote) is ignored. Gating on this keeps non-WSL users on the unchanged
24 * single-prompt flow.
25 */
26function useHasLocalSecondaryEnvironment(): boolean {
27 const { environments } = useEnvironments();
28 return useMemo(
29 () =>
30 environments.some((environment) => isDesktopLocalConnectionTarget(environment.entry.target)),
31 [environments],
5732 );
5833}
5934
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 }
35/**
36 * The provider update popover. With a WSL backend present it splits the update
37 * trigger per environment; without one (the common case) it falls back to the
38 * single-prompt flow so non-WSL users see no change.
39 */
40export function ProviderUpdateLaunchNotification() {
41 const hasLocalSecondary = useHasLocalSecondaryEnvironment();
8042
81 toastManager.update(
82 input.toastId,
83 stackedThreadToast({
84 type: input.view.type,
85 title: input.view.title,
86 description: input.view.description,
87 timeout: 0,
88 actionProps: {
89 children: "Settings",
90 onClick: input.openSettings,
91 },
92 actionVariant: "outline",
93 data: {
94 hideCopyButton: true,
95 },
96 }),
43 return hasLocalSecondary ? (
44 <ProviderUpdateEnvironmentsNotification />
45 ) : (
46 <ProviderUpdatePrimaryNotification />
9747 );
9848}
9949
100function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) {
101 return view.phase === "failed" || view.phase === "unchanged" || view.phase === "succeeded";
102}
50const seenProviderUpdateNotificationKeys = new Set<string>();
51type ProviderUpdateToastId = ReturnType<typeof toastManager.add>;
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() {
10559 const navigate = useNavigate();
106 const providers = useAtomValue(primaryServerProvidersAtom);
107 const primaryEnvironment = usePrimaryEnvironment();
108 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {
109 reportFailure: false,
110 });
111 const activeToastRef = useRef<ActiveProviderUpdateToast | null>(null);
60 const { groups, isAnySettling } = useLocalEnvironmentUpdateGroups();
11261 const { dismissedNotificationKeys, dismissNotificationKey } =
11362 useDismissedProviderUpdateNotificationKeys();
11463
115 const updateProviders = useMemo(() => collectProviderUpdateCandidates(providers), [providers]);
116 const notificationKey = useMemo(
117 () => providerUpdateNotificationKey(updateProviders),
118 [updateProviders],
119 );
120 const oneClickProviders = useMemo(
121 () =>
122 updateProviders.filter((provider) => canOneClickUpdateProviderCandidate(provider, providers)),
123 [providers, updateProviders],
124 );
125
126 const openProviderSettings = useCallback(
127 (toastId?: ProviderUpdateToastId) => {
128 const activeToast = activeToastRef.current;
129 if (toastId !== undefined) {
130 toastManager.close(toastId);
131 } else if (activeToast) {
132 toastManager.close(activeToast.toastId);
133 }
134 if (activeToast && (toastId === undefined || activeToast.toastId === toastId)) {
64 const activeToastRef = useRef<{
65 readonly toastId: ProviderUpdateToastId;
66 readonly key: string;
67 } | null>(null);
68 const notificationKeyRef = useRef<string | null>(null);
69 // Whether the user has triggered an update from the current toast. Until they
70 // do, the prompt is replaced when the available updates change; afterward it
71 // is kept so in-progress rows are not torn down.
72 const hasInteractedRef = useRef(false);
73
74 // Close our prompt if this flow unmounts (e.g. the WSL backend is disabled
75 // and we fall back to the single-prompt flow).
76 useEffect(() => {
77 return () => {
78 if (activeToastRef.current !== null) {
79 toastManager.close(activeToastRef.current.toastId);
13580 activeToastRef.current = null;
13681 }
137 void navigate({ to: "/settings/providers" });
138 },
139 [navigate],
82 };
83 }, []);
84
85 const updateGroups = useMemo(() => environmentGroupsWithUpdates(groups), [groups]);
86 const notificationKey = useMemo(() => localEnvironmentUpdateNotificationKey(groups), [groups]);
87 useEffect(() => {
88 notificationKeyRef.current = notificationKey;
89 }, [notificationKey]);
90
91 // Title summarizes the distinct providers on offer across all environments;
92 // the per-environment detail lives in the popover body.
93 const candidateUnion = useMemo(
94 () => collectProviderUpdateCandidates(updateGroups.flatMap((group) => group.candidates)),
95 [updateGroups],
14096 );
14197
98 // Defer while any local backend is still connecting, up to the grace period.
99 const [settleGraceElapsed, setSettleGraceElapsed] = useState(false);
142100 useEffect(() => {
143 const activeToast = activeToastRef.current;
144 if (activeToast?.kind !== "update") {
101 if (!isAnySettling) {
102 setSettleGraceElapsed(false);
145103 return;
146104 }
147
148 const activeProviders = providers.filter((provider) =>
149 activeToast.providerInstanceIds.has(provider.instanceId),
150 );
151 const view = getProviderUpdateProgressToastView({
152 providers: activeProviders,
153 providerCount: activeToast.providerCount,
154 });
155 updateProviderUpdateToast({
156 toastId: activeToast.toastId,
157 view,
158 openSettings: () => openProviderSettings(activeToast.toastId),
159 });
160
161 if (isTerminalProviderUpdateToastView(view)) {
105 const timer = setTimeout(() => setSettleGraceElapsed(true), SETTLING_GRACE_MS);
106 return () => clearTimeout(timer);
107 }, [isAnySettling]);
108 const isGated = isAnySettling && !settleGraceElapsed;
109
110 const openProviderSettings = useCallback(() => {
111 const active = activeToastRef.current;
112 if (active !== null) {
113 toastManager.close(active.toastId);
162114 activeToastRef.current = null;
163115 }
164 }, [providers, openProviderSettings]);
116 void navigate({ to: "/settings/providers" });
117 }, [navigate]);
165118
166119 useEffect(() => {
167 const activeToast = activeToastRef.current;
168 if (activeToast?.kind === "prompt" && activeToast.key !== notificationKey) {
169 toastManager.close(activeToast.toastId);
120 // Whether a fresh prompt can actually be shown for the current update set.
121 const canShowPrompt =
122 notificationKey !== null &&
123 !isGated &&
124 !dismissedNotificationKeys.has(notificationKey) &&
125 !seenProviderUpdateNotificationKeys.has(notificationKey);
126
127 // Close a prompt the user hasn't acted on yet when the available updates
128 // change: when they clear entirely (key null) so the toast doesn't linger,
129 // and when a fresh set is ready to replace it. Keep it only while a backend
130 // is re-settling (updates still exist, just gated) — and once an update is
131 // in progress, so its rows survive.
132 const active = activeToastRef.current;
133 if (
134 active &&
135 active.key !== notificationKey &&
136 !hasInteractedRef.current &&
137 (notificationKey === null || !isGated)
138 ) {
139 toastManager.close(active.toastId);
170140 activeToastRef.current = null;
171141 }
172142
173 if (
174 !notificationKey ||
175 dismissedNotificationKeys.has(notificationKey) ||
176 seenProviderUpdateNotificationKeys.has(notificationKey) ||
177 activeToastRef.current
178 ) {
143 if (!notificationKey || !canShowPrompt || activeToastRef.current !== null) {
179144 return;
180145 }
181146
182147 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);
189150 const dismissPrompt = () => {
190 dismissNotificationKey(notificationKey);
191 };
192
193 const runUpdates = () => {
194 if (updateStarted || oneClickProviders.length === 0 || !primaryEnvironment) {
195 return;
151 // Dismiss whatever set is still on offer at close time, so the popover
152 // does not re-pop for updates the user just declined.
153 const liveKey = notificationKeyRef.current;
154 if (liveKey) {
155 dismissNotificationKey(liveKey);
196156 }
197 updateStarted = true;
198
199 const providerCount = oneClickProviders.length;
200 const providerInstanceIds = new Set(oneClickProviders.map((provider) => provider.instanceId));
201 activeToastRef.current = {
202 kind: "update",
203 key: notificationKey,
204 toastId,
205 providerInstanceIds,
206 providerCount,
207 };
208
209 updateProviderUpdateToast({
210 toastId,
211 view: getProviderUpdateRunningToastView(providerCount),
212 openSettings,
213 });
214
215 void (async () => {
216 const results = [];
217 for (const provider of oneClickProviders) {
218 results.push(
219 await updateProvider({
220 environmentId: primaryEnvironment.environmentId,
221 input: {
222 provider: provider.driver,
223 instanceId: provider.instanceId,
224 },
225 }),
226 );
227 }
228
229 const activeUpdateToast = activeToastRef.current;
230 if (activeUpdateToast?.kind !== "update" || activeUpdateToast.toastId !== toastId) {
231 return;
232 }
233
234 const failedMessage = firstFailedProviderUpdateMessage(results);
235 if (failedMessage) {
236 updateProviderUpdateToast({
237 toastId,
238 view: getProviderUpdateRejectedToastView(providerCount, failedMessage),
239 openSettings,
240 });
241 activeToastRef.current = null;
242 return;
243 }
244
245 const updatedProviderSnapshots = collectUpdatedProviderSnapshots({
246 results,
247 providerInstanceIds,
248 });
249 const view = getProviderUpdateProgressToastView({
250 providers: updatedProviderSnapshots,
251 providerCount,
252 });
253 updateProviderUpdateToast({
254 toastId,
255 view,
256 openSettings,
257 });
258
259 if (isTerminalProviderUpdateToastView(view)) {
260 activeToastRef.current = null;
261 }
262 })();
157 activeToastRef.current = null;
263158 };
264159
265 toastId = toastManager.add(
160 const toastId = toastManager.add(
266161 stackedThreadToast({
267 type: initialView.type,
268 title: initialView.title,
269 description: initialView.description,
162 type: "warning",
163 title: getProviderUpdateInitialToastView({
164 updateProviders: candidateUnion,
165 oneClickProviders: candidateUnion,
166 }).title,
167 description: (
168 <ProviderUpdateEnvironmentRows
169 onInteract={() => {
170 hasInteractedRef.current = true;
171 }}
172 />
173 ),
270174 timeout: 0,
271 actionProps:
272 oneClickProviders.length > 0
273 ? {
274 children: "Update",
275 onClick: runUpdates,
276 }
277 : {
278 children: "Settings",
279 onClick: openSettings,
280 },
281 actionVariant: oneClickProviders.length > 0 ? "default" : "outline",
175 actionProps: {
176 children: "Settings",
177 onClick: openProviderSettings,
178 },
179 actionVariant: "outline",
282180 data: {
283 leadingIcon:
284 updateProviders.length === 1 ? (
285 <ProviderUpdateToastIcon provider={updateProviders[0]!.driver} />
286 ) : undefined,
287181 hideCopyButton: true,
182 leadingIcon: <DownloadIcon aria-hidden="true" className="size-4 text-success" />,
288183 onClose: dismissPrompt,
289 ...(oneClickProviders.length > 0
290 ? {
291 secondaryActionProps: {
292 children: "Settings",
293 onClick: openSettings,
294 },
295 secondaryActionVariant: "outline" as const,
296 }
297 : {}),
298184 },
299185 }),
300186 );
301 activeToastRef.current = { kind: "prompt", key: notificationKey, toastId };
187 activeToastRef.current = { toastId, key: notificationKey };
302188 }, [
303 updateProvider,
304 dismissNotificationKey,
305 dismissedNotificationKeys,
306189 notificationKey,
307 oneClickProviders,
190 isGated,
191 candidateUnion,
192 dismissedNotificationKeys,
193 dismissNotificationKey,
308194 openProviderSettings,
309 primaryEnvironment,
310 updateProviders,
311195 ]);
312196
313197 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 {
33 ArrowUpDownIcon,
44 ChevronRightIcon,
55 CloudIcon,
6 ContainerIcon,
67 FolderPlusIcon,
78 Globe2Icon,
9 LoaderIcon,
810 SearchIcon,
911 SettingsIcon,
1012 SquarePenIcon,
......@@ -69,6 +71,8 @@ import {
6971 type SidebarThreadPreviewCount,
7072 type SidebarThreadSortOrder,
7173} from "@t3tools/contracts/settings";
74import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
75import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
7276import { isElectron } from "../env";
7377import { APP_STAGE_LABEL } from "../branding";
7478import { useOpenPrLink } from "../lib/openPullRequestLink";
......@@ -398,7 +402,16 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
398402 const isRemoteThread =
399403 primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;
400404 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;
402415 // For grouped projects, the thread may belong to a different environment
403416 // than the representative project. Look up the thread's own project cwd
404417 // so git status (and thus PR detection) queries the correct path.
......@@ -821,7 +834,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
821834 ) : null}
822835 <span className={threadMetaClassName}>
823836 <span className="inline-flex items-center gap-1">
824 {isRemoteThread && (
837 {isRemoteThread && !isDesktopLocalThread && (
825838 <Tooltip>
826839 <TooltipTrigger
827840 render={
......@@ -2249,18 +2262,24 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
22492262 render={
22502263 <span
22512264 aria-label={
2252 project.environmentPresence === "remote-only"
2253 ? "Remote project"
2254 : "Available in multiple environments"
2265 project.allRemoteMembersAreDesktopLocal
2266 ? "Local sandbox project"
2267 : "Remote project"
22552268 }
22562269 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"
22572270 />
22582271 }
22592272 >
2260 <CloudIcon className="size-3" />
2273 {project.allRemoteMembersAreDesktopLocal ? (
2274 <ContainerIcon className="size-3" />
2275 ) : (
2276 <CloudIcon className="size-3" />
2277 )}
22612278 </TooltipTrigger>
22622279 <TooltipPopup side="top">
2263 Remote environment: {project.remoteEnvironmentLabels.join(", ")}
2280 {project.allRemoteMembersAreDesktopLocal
2281 ? `Local sandbox: ${project.remoteEnvironmentLabels.join(", ")}`
2282 : `Remote environment: ${project.remoteEnvironmentLabels.join(", ")}`}
22642283 </TooltipPopup>
22652284 </Tooltip>
22662285 )}
......@@ -2452,6 +2471,81 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar
24522471 );
24532472});
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
24552549type SortableProjectHandleProps = Pick<
24562550 ReturnType<typeof useSortable>,
24572551 "attributes" | "listeners" | "setActivatorNodeRef"
......@@ -2889,6 +2983,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
28892983 </Alert>
28902984 </SidebarGroup>
28912985 ) : null}
2986 <LocalSecondaryStatus />
28922987 <SidebarGroup className="px-2 py-2">
28932988 <div className="mb-1 flex items-center justify-between pl-2 pr-1.5">
28942989 <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
......@@ -3058,6 +3153,15 @@ export default function Sidebar() {
30583153 ),
30593154 [environments],
30603155 );
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 );
30613165 const orderedProjects = useMemo(() => {
30623166 return orderItemsByPreferredIds({
30633167 items: projects,
......@@ -3096,8 +3200,15 @@ export default function Sidebar() {
30963200 settings: projectGroupingSettings,
30973201 primaryEnvironmentId,
30983202 resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
3203 isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId),
30993204 });
3100 }, [environmentLabelById, orderedProjects, projectGroupingSettings, primaryEnvironmentId]);
3205 }, [
3206 environmentLabelById,
3207 desktopLocalEnvironmentIds,
3208 orderedProjects,
3209 projectGroupingSettings,
3210 primaryEnvironmentId,
3211 ]);
31013212
31023213 const sidebarProjectByKey = useMemo(
31033214 () => 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 {
2727 type DesktopDiscoveredSshHost,
2828 type DesktopSshEnvironmentTarget,
2929 type DesktopServerExposureState,
30 type DesktopWslState,
3031 type EnvironmentId,
3132} from "@t3tools/contracts";
3233import {
......@@ -48,6 +49,7 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
4849import { cn } from "../../lib/utils";
4950import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
5051import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
52import { applyWslEnableSelection } from "./ConnectionsSettings.logic";
5153import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig";
5254import {
5355 SettingsPageContainer,
......@@ -82,6 +84,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
8284import { QRCodeSvg } from "../ui/qr-code";
8385import { Skeleton } from "../ui/skeleton";
8486import { Spinner } from "../ui/spinner";
87import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
8588import { Switch } from "../ui/switch";
8689import { stackedThreadToast, toastManager } from "../ui/toast";
8790import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -111,6 +114,7 @@ import {
111114 type ServerClientSessionRecord,
112115 type ServerPairingLinkRecord,
113116} from "~/environments/primary";
117import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
114118import { useUiStateStore } from "~/uiStateStore";
115119import { resolveServerConfigVersionMismatch } from "~/versionSkew";
116120import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState";
......@@ -132,6 +136,7 @@ import {
132136 refreshDesktopNetworkAccessState,
133137} from "~/state/desktopNetworkAccess";
134138import { desktopSshHostsStateAtom } from "~/state/desktopSshHosts";
139import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState";
135140import {
136141 type EnvironmentPresentation,
137142 useEnvironments,
......@@ -145,6 +150,12 @@ const DEFAULT_TAILSCALE_SERVE_PORT = 443;
145150const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = [];
146151const 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
148159const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, {
149160 dateStyle: "medium",
150161 timeStyle: "short",
......@@ -1461,6 +1472,12 @@ function SavedBackendListRow({
14611472 environment.relayManaged ? "T3 Connect" : null,
14621473 ].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
14641481 return (
14651482 <div className={ITEM_ROW_CLASSNAME}>
14661483 <div className={ITEM_ROW_INNER_CLASSNAME}>
......@@ -1503,20 +1520,37 @@ function SavedBackendListRow({
15031520 ) : null}
15041521 </div>
15051522 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
1506 <Button
1507 size="xs"
1508 variant="outline"
1509 disabled={isConnecting || removingEnvironmentId === environmentId}
1510 onClick={() => void (isConnected ? onRemove(environmentId) : onConnect(environmentId))}
1511 >
1512 {isConnected
1513 ? removingEnvironmentId === environmentId
1514 ? "Disconnecting…"
1515 : "Disconnect"
1516 : isConnecting
1517 ? "Connecting…"
1518 : "Connect"}
1519 </Button>
1523 {isWslEnvironment ? (
1524 <Tooltip>
1525 <TooltipTrigger
1526 render={
1527 <Button size="xs" variant="outline" disabled>
1528 Managed above
1529 </Button>
1530 }
1531 />
1532 <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight">
1533 The WSL backend is managed by the WSL setting above — turn it on or off there.
1534 </TooltipPopup>
1535 </Tooltip>
1536 ) : (
1537 <Button
1538 size="xs"
1539 variant="outline"
1540 disabled={isConnecting || removingEnvironmentId === environmentId}
1541 onClick={() =>
1542 void (isConnected ? onRemove(environmentId) : onConnect(environmentId))
1543 }
1544 >
1545 {isConnected
1546 ? removingEnvironmentId === environmentId
1547 ? "Disconnecting…"
1548 : "Disconnect"
1549 : isConnecting
1550 ? "Connecting…"
1551 : "Connect"}
1552 </Button>
1553 )}
15201554 </div>
15211555 </div>
15221556 </div>
......@@ -2066,6 +2100,30 @@ export function ConnectionsSettings() {
20662100 const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false);
20672101 const [isDesktopServerExposureDialogOpen, setIsDesktopServerExposureDialogOpen] = useState(false);
20682102 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;
20692127 const [pendingTailscaleServeEndpoint, setPendingTailscaleServeEndpoint] =
20702128 useState<AdvertisedEndpoint | null>(null);
20712129 const [disableTailscaleServeDialogOpen, setDisableTailscaleServeDialogOpen] = useState(false);
......@@ -2102,6 +2160,12 @@ export function ConnectionsSettings() {
21022160 ? desktopSshHostsStateAtom
21032161 : null,
21042162 );
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;
21052169 const discoveredSshHosts = desktopSshHosts.data ?? EMPTY_DISCOVERED_SSH_HOSTS;
21062170 const unsavedDiscoveredSshHosts = useMemo(
21072171 () =>
......@@ -2793,6 +2857,313 @@ export function ConnectionsSettings() {
27932857 );
27942858 })
27952859 : 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
27963167 const renderTailscaleRow = () => (
27973168 <SettingsRow
27983169 title="Tailscale HTTPS"
......@@ -2928,6 +3299,7 @@ export function ConnectionsSettings() {
29283299 {renderNetworkAccessRow()}
29293300 {renderEndpointRows("endpoint-rail")}
29303301 {renderTailscaleRow()}
3302 {renderWslRow()}
29313303 <CloudLinkRow canManageRelay={canManageRelay} />
29323304 </>
29333305 ) : (
......@@ -3011,6 +3383,114 @@ export function ConnectionsSettings() {
30113383 </AlertDialogFooter>
30123384 </AlertDialogPopup>
30133385 </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>
30143494 <AlertDialog
30153495 open={disableTailscaleServeDialogOpen}
30163496 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 @@
11import {
22 AuthStandardClientScopes,
33 EnvironmentId,
4 PRIMARY_LOCAL_ENVIRONMENT_ID,
45 type DesktopBridge,
56 type DesktopSshEnvironmentTarget,
67} from "@t3tools/contracts";
78import { describe, expect, it } from "@effect/vitest";
89import * 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
1222const TARGET: DesktopSshEnvironmentTarget = {
1323 alias: "devbox",
......@@ -86,3 +96,130 @@ describe("desktop SSH pairing", () => {
8696 }),
8797 );
8898});
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 {
88 SshEnvironmentGateway,
99} from "@t3tools/client-runtime/platform";
1010import {
11 BearerConnectionCredential,
12 BearerConnectionProfile,
13 BearerConnectionRegistration,
14 BearerConnectionTarget,
1115 ConnectionBlockedError,
1216 ConnectionTransientError,
1317 Connectivity,
1418 mapRemoteEnvironmentError,
19 type PlatformConnectionRegistration,
1520 PrimaryConnectionRegistration,
1621 PrimaryConnectionTarget,
1722 Wakeups,
1823} from "@t3tools/client-runtime/connection";
24import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization";
1925import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
2026import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
2127import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc";
2228import {
2329 AuthStandardClientScopes,
2430 type DesktopBridge,
31 type DesktopEnvironmentBootstrap,
2532 type DesktopSshEnvironmentTarget,
33 PRIMARY_LOCAL_ENVIRONMENT_ID,
2634} from "@t3tools/contracts";
35import * as Clock from "effect/Clock";
2736import * as Context from "effect/Context";
2837import * as Effect from "effect/Effect";
2938import * as Layer from "effect/Layer";
3039import * as Option from "effect/Option";
3140import * as Queue from "effect/Queue";
32import * as Schedule from "effect/Schedule";
41import * as Ref from "effect/Ref";
3342import * as Stream from "effect/Stream";
43import { FetchHttpClient } from "effect/unstable/http";
3444
3545import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth";
3646import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer";
37import { readPrimaryEnvironmentTarget } from "../environments/primary/target";
47import {
48 readPrimaryEnvironmentTarget,
49 type PrimaryEnvironmentTarget,
50} from "../environments/primary/target";
3851import { clearComposerDraftsEnvironment } from "../composerDraftStore";
3952import { isHostedStaticApp } from "../hostedPairing";
4053import { appAtomRegistry } from "../rpc/atomRegistry";
4154import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState";
55import {
56 desktopLocalConnectionId,
57 readDesktopSecondaryBootstrapsResult,
58 type DesktopSecondaryBootstrapsRead,
59} from "./desktopLocal";
4260import { connectionStorageLayer } from "./storage";
4361
4462let nextObservedRpcRequestId = 0;
......@@ -267,14 +285,7 @@ const capabilitiesLayer = Layer.effectContext(
267285
268286const loadPrimaryConnectionRegistration = Effect.fn(
269287 "web.connectionPlatform.loadPrimaryConnectionRegistration",
270)(function* () {
271 const resolved = readPrimaryEnvironmentTarget();
272 if (resolved === null) {
273 return yield* new ConnectionBlockedError({
274 reason: "configuration",
275 detail: "Unable to resolve the primary environment endpoint.",
276 });
277 }
288)(function* (resolved: PrimaryEnvironmentTarget) {
278289 const descriptor = yield* fetchRemoteEnvironmentDescriptor({
279290 httpBaseUrl: resolved.target.httpBaseUrl,
280291 }).pipe(Effect.provide(primaryEnvironmentHttpLayer), Effect.mapError(mapRemoteEnvironmentError));
......@@ -288,29 +299,280 @@ const loadPrimaryConnectionRegistration = Effect.fn(
288299 });
289300});
290301
291const primaryRegistrationRetrySchedule = Schedule.exponential("1 second").pipe(
292 Schedule.either(Schedule.spaced("16 seconds")),
293);
294
295const platformConnectionSourceLayer = Layer.sync(PlatformConnectionSource, () => {
296 if (isHostedStaticApp()) {
297 return PlatformConnectionSource.of({
298 registrations: Stream.empty,
302// A desktop-local secondary backend (e.g. a parallel WSL backend) lives on its
303// own loopback origin, so — unlike the same-origin primary — it authenticates
304// with a bearer token minted from the bootstrap credential the desktop issues.
305const loadSecondaryConnectionRegistration = Effect.fn(
306 "web.connectionPlatform.loadSecondaryConnectionRegistration",
307)(function* (entry: DesktopEnvironmentBootstrap) {
308 if (
309 entry.httpBaseUrl === null ||
310 entry.wsBaseUrl === null ||
311 entry.bootstrapToken === undefined
312 ) {
313 return yield* new ConnectionTransientError({
314 reason: "endpoint-unavailable",
315 detail: `Desktop-local backend ${entry.id} is not ready yet.`,
299316 });
300317 }
301 return PlatformConnectionSource.of({
302 registrations: Stream.fromEffect(loadPrimaryConnectionRegistration()).pipe(
303 Stream.tapError((error) =>
304 Effect.logWarning("Could not discover the primary environment.", {
305 error,
306 }),
307 ),
308 Stream.retry(primaryRegistrationRetrySchedule),
309 Stream.catchCause(() => Stream.empty),
310 ),
311 });
318 const httpBaseUrl = entry.httpBaseUrl;
319 const wsBaseUrl = entry.wsBaseUrl;
320 const descriptor = yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe(
321 Effect.mapError(mapRemoteEnvironmentError),
322 );
323 const issuedAtEpochMs = yield* Clock.currentTimeMillis;
324 const access = yield* bootstrapRemoteBearerSession({
325 httpBaseUrl,
326 credential: entry.bootstrapToken,
327 scopes: AuthStandardClientScopes,
328 clientMetadata: clientMetadata(),
329 }).pipe(Effect.mapError(mapRemoteEnvironmentError));
330 // Keep the desktop pool's stable backend id in the connection id. The
331 // descriptor environment id still scopes projects and RPC state, while the
332 // backend id lets desktop-only operations (notably the WSL folder picker)
333 // route back to the instance that owns the environment.
334 const connectionId = desktopLocalConnectionId(entry.id);
335 // Prefer the desktop's bootstrap label (it identifies the backend and distro,
336 // e.g. "WSL: Ubuntu") over the generic descriptor label, so consumers can show
337 // a meaningful name without recovering it from the bootstrap list later.
338 const label = entry.label || descriptor.label;
339 return {
340 registration: new BearerConnectionRegistration({
341 target: new BearerConnectionTarget({
342 environmentId: descriptor.environmentId,
343 label,
344 connectionId,
345 }),
346 profile: new BearerConnectionProfile({
347 connectionId,
348 environmentId: descriptor.environmentId,
349 label,
350 httpBaseUrl,
351 wsBaseUrl,
352 }),
353 credential: new BearerConnectionCredential({ token: access.access_token }),
354 }),
355 expiresAtEpochMs: secondaryBearerExpiresAtEpochMs(issuedAtEpochMs, access.expires_in),
356 refreshAtEpochMs: secondaryBearerRefreshAtEpochMs(issuedAtEpochMs, access.expires_in),
357 };
312358});
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
314576const environmentOwnedDataCleanupLayer = Layer.succeed(
315577 EnvironmentOwnedDataCleanup,
316578 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 {
77 AuthSessionId,
88 AuthSessionState,
99} from "@t3tools/contracts";
10import { EnvironmentHttpCommonError } from "@t3tools/contracts";
10import { EnvironmentHttpCommonError, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
1111import type { EnvironmentHttpCommonError as EnvironmentHttpCommonErrorType } from "@t3tools/contracts";
1212import * as DateTime from "effect/DateTime";
1313import * as Effect from "effect/Effect";
......@@ -176,9 +176,13 @@ export function takePairingTokenFromUrl(): string | null {
176176}
177177
178178function getDesktopBootstrapCredential(): string | null {
179 const bootstrap = window.desktopBridge?.getLocalEnvironmentBootstrap();
180 return typeof bootstrap?.bootstrapToken === "string" && bootstrap.bootstrapToken.length > 0
181 ? bootstrap.bootstrapToken
179 // Both backends share the same bootstrap token (DesktopBackendConfiguration
180 // mints one tokenRef and feeds it to both resolvers), so picking the
181 // primary entry is fine even when the WSL backend is also registered.
182 const bootstraps = window.desktopBridge?.getLocalEnvironmentBootstraps() ?? [];
183 const primary = bootstraps.find((entry) => entry.id === PRIMARY_LOCAL_ENVIRONMENT_ID);
184 return typeof primary?.bootstrapToken === "string" && primary.bootstrapToken.length > 0
185 ? primary.bootstrapToken
182186 : null;
183187}
184188
......@@ -526,6 +530,16 @@ export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGat
526530 });
527531}
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
529543export function __resetServerAuthBootstrapForTests() {
530544 bootstrapPromise = null;
531545 resolvedAuthenticatedGateState = null;
apps/web/src/environments/primary/bootstrap.test.ts+17-11
......@@ -173,12 +173,15 @@ describe("environmentBootstrap", () => {
173173 replaceState: vi.fn(),
174174 },
175175 desktopBridge: {
176 getLocalEnvironmentBootstrap: () => ({
177 label: "Local environment",
178 httpBaseUrl: "http://127.0.0.1:3773",
179 wsBaseUrl: "ws://127.0.0.1:3773",
180 bootstrapToken: "desktop-bootstrap-token",
181 }),
176 getLocalEnvironmentBootstraps: () => [
177 {
178 id: "primary",
179 label: "Windows",
180 httpBaseUrl: "http://127.0.0.1:3773",
181 wsBaseUrl: "ws://127.0.0.1:3773",
182 bootstrapToken: "desktop-bootstrap-token",
183 },
184 ],
182185 },
183186 });
184187 await installDescriptorApi();
......@@ -212,11 +215,14 @@ describe("environmentBootstrap", () => {
212215 location: new URL("http://127.0.0.1:5733/"),
213216 history: { replaceState: vi.fn() },
214217 desktopBridge: {
215 getLocalEnvironmentBootstrap: () => ({
216 label: "Local environment",
217 httpBaseUrl: "http://127.0.0.1:3773",
218 bootstrapToken: "desktop-bootstrap-token",
219 }),
218 getLocalEnvironmentBootstraps: () => [
219 {
220 id: "primary",
221 label: "Local environment",
222 httpBaseUrl: "http://127.0.0.1:3773",
223 bootstrapToken: "desktop-bootstrap-token",
224 },
225 ],
220226 },
221227 });
222228
apps/web/src/environments/primary/index.ts+1
......@@ -23,6 +23,7 @@ export {
2323 peekPairingTokenFromUrl,
2424 PrimaryEnvironmentPairingCredentialRejectedError,
2525 PrimaryEnvironmentRequestError,
26 reauthenticatePrimaryEnvironment,
2627 resolveInitialServerAuthGateState,
2728 revokeOtherServerClientSessions,
2829 revokeServerClientSession,
apps/web/src/environments/primary/target.ts+6-2
......@@ -1,4 +1,4 @@
1import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts";
1import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts";
22import * as Schema from "effect/Schema";
33
44const PrimaryEnvironmentTargetSource = Schema.Literals([
......@@ -76,7 +76,11 @@ export interface PrimaryEnvironmentTarget {
7676const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
7777
7878function 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;
8084}
8185
8286function parseTargetUrl(input: {
apps/web/src/sidebarProjectGrouping.ts+21-6
......@@ -20,6 +20,12 @@ export interface SidebarProjectSnapshot extends Project {
2020 displayName: string;
2121 groupedProjectCount: number;
2222 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;
2329 memberProjects: readonly SidebarProjectGroupMember[];
2430 memberProjectRefs: readonly ScopedProjectRef[];
2531 remoteEnvironmentLabels: readonly string[];
......@@ -44,6 +50,11 @@ export function buildSidebarProjectSnapshots(input: {
4450 settings: ProjectGroupingSettings;
4551 primaryEnvironmentId: EnvironmentId | null;
4652 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;
4758}): SidebarProjectSnapshot[] {
4859 const groupedMembers = new Map<string, SidebarProjectGroupMember[]>();
4960 for (const project of input.projects) {
......@@ -86,14 +97,17 @@ export function buildSidebarProjectSnapshots(input: {
8697 input.primaryEnvironmentId !== null
8798 ? members.some((member) => member.environmentId !== input.primaryEnvironmentId)
8899 : false;
89 const remoteEnvironmentLabels = members
90 .filter(
91 (member) =>
92 input.primaryEnvironmentId !== null &&
93 member.environmentId !== input.primaryEnvironmentId,
94 )
100 const remoteMembers = members.filter(
101 (member) =>
102 input.primaryEnvironmentId !== null && member.environmentId !== input.primaryEnvironmentId,
103 );
104 const remoteEnvironmentLabels = remoteMembers
95105 .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : []))
96106 .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
98112 result.push({
99113 ...representative,
......@@ -108,6 +122,7 @@ export function buildSidebarProjectSnapshots(input: {
108122 groupedProjectCount: members.length,
109123 environmentPresence:
110124 hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only",
125 allRemoteMembersAreDesktopLocal,
111126 memberProjects: members,
112127 memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)),
113128 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([
8989]);
9090export 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
92107export function connectionRegistrationTarget(
93108 registration: ConnectionRegistration | PrimaryConnectionRegistration,
94109): ConnectionTarget {
packages/client-runtime/src/connection/layer.ts+1-1
......@@ -35,7 +35,7 @@ const connectionStartupLayer = Layer.effectDiscard(
3535 const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource;
3636 yield* registry.start;
3737 yield* platformSource.registrations.pipe(
38 Stream.runForEach(registry.registerPlatform),
38 Stream.runForEach(registry.reconcilePlatform),
3939 Effect.forkScoped,
4040 );
4141 }).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";
1616import {
1717 type ConnectionCatalogEntry,
1818 type ConnectionRegistration,
19 type PlatformConnectionRegistration,
1920 type PrimaryConnectionRegistration,
2021 SshConnectionProfile,
2122 connectionRegistrationCatalogEntry,
2223} from "./catalog.ts";
24import * as ConnectionCredentialStore from "./credentialStore.ts";
2325import * as ConnectionProfileStore from "./profileStore.ts";
2426import * as Connectivity from "./connectivity.ts";
2527import type {
......@@ -69,6 +71,9 @@ export class EnvironmentRegistry extends Context.Service<
6971 registration: ConnectionRegistration,
7072 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;
7173 readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect<void>;
74 readonly reconcilePlatform: (
75 registrations: ReadonlyArray<PlatformConnectionRegistration>,
76 ) => Effect.Effect<void>;
7277 readonly remove: (
7378 environmentId: EnvironmentId,
7479 ) => Effect.Effect<
......@@ -126,6 +131,7 @@ export const make = Effect.gen(function* () {
126131 const cache = yield* Persistence.EnvironmentCacheStore;
127132 const ownedDataCleanup = yield* Persistence.EnvironmentOwnedDataCleanup;
128133 const profiles = yield* ConnectionProfileStore.ConnectionProfileStore;
134 const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore;
129135 const connectivity = yield* Connectivity.Connectivity;
130136 const driver = yield* ConnectionDriver.ConnectionDriver;
131137 const wakeups = yield* ConnectionWakeups.ConnectionWakeups;
......@@ -402,47 +408,137 @@ export const make = Effect.gen(function* () {
402408 );
403409 });
404410
405 const registerPlatform = Effect.fn("EnvironmentRegistry.registerPlatform")(function* (
406 registration: PrimaryConnectionRegistration,
407 ) {
408 const entry = connectionRegistrationCatalogEntry(registration);
409 const target = entry.target;
410 yield* withLeaseLock(
411 target.environmentId,
412 Effect.gen(function* () {
413 yield* Ref.update(platformEnvironmentIds, (current) => {
414 const next = new Set(current);
415 next.add(target.environmentId);
416 return next;
417 });
411 const installPlatformRegistration = Effect.fn("EnvironmentRegistry.installPlatformRegistration")(
412 function* (registration: PlatformConnectionRegistration) {
413 const entry = connectionRegistrationCatalogEntry(registration);
414 const target = entry.target;
415 yield* withLeaseLock(
416 target.environmentId,
417 Effect.gen(function* () {
418 yield* Ref.update(platformEnvironmentIds, (current) => {
419 const next = new Set(current);
420 next.add(target.environmentId);
421 return next;
422 });
418423
419 const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get(
420 target.environmentId,
421 );
422 if (persistedTarget !== undefined) {
423 yield* registrations.remove(persistedTarget).pipe(
424 Effect.tap(() =>
425 Ref.update(persistedTargetsByEnvironment, (current) => {
426 const next = new Map(current);
427 next.delete(target.environmentId);
428 return next;
429 }),
430 ),
431 Effect.catch((error) =>
432 Effect.logWarning(
433 "Could not remove a persisted registration shadowed by the primary environment.",
434 {
424 // Secondary desktop-local backends (e.g. a parallel WSL backend) live
425 // on their own loopback origin, so they authenticate with a bearer
426 // token instead of the primary's same-origin cookie. Stash it where
427 // the resolver's bearer broker looks it up.
428 if (registration._tag === "BearerConnectionRegistration") {
429 yield* credentials.put(registration.target.connectionId, registration.credential).pipe(
430 Effect.catch((error) =>
431 Effect.logWarning("Could not store the platform bearer credential.", {
435432 environmentId: target.environmentId,
436433 error,
437 },
434 }),
438435 ),
439 ),
436 );
437 }
438
439 const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get(
440 target.environmentId,
440441 );
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 });
444 }),
463 yield* installEntryLocked(entry, { retainEquivalentRuntime: true });
464 }),
465 );
466 },
467 );
468
469 // Tear down a platform-managed environment that the host no longer reports
470 // (e.g. the user turned the parallel WSL backend off). Platform environments
471 // bypass the user-facing `remove` guard since they are reconciled from the
472 // bootstrap rather than removed by hand.
473 const removePlatformEnvironment = Effect.fn("EnvironmentRegistry.removePlatformEnvironment")(
474 function* (environmentId: EnvironmentId) {
475 yield* withLeaseLock(
476 environmentId,
477 Effect.gen(function* () {
478 const entry = (yield* SubscriptionRef.get(entries)).get(environmentId);
479 yield* Ref.update(platformEnvironmentIds, (current) => {
480 const next = new Set(current);
481 next.delete(environmentId);
482 return next;
483 });
484 yield* closeServiceScope(environmentId);
485 yield* SubscriptionRef.update(entries, (current) => {
486 const next = new Map(current);
487 next.delete(environmentId);
488 return next;
489 });
490 if (entry !== undefined && entry.target._tag === "BearerConnectionTarget") {
491 yield* credentials.remove(entry.target.connectionId).pipe(
492 Effect.catch((error) =>
493 Effect.logWarning("Could not clear the platform bearer credential.", {
494 environmentId,
495 error,
496 }),
497 ),
498 );
499 }
500 yield* Effect.all(
501 [
502 cache.clear(environmentId).pipe(
503 Effect.catch((error) =>
504 Effect.logWarning("Could not clear cached environment data after removal.", {
505 environmentId,
506 error,
507 }),
508 ),
509 ),
510 ownedDataCleanup.clear(environmentId),
511 ],
512 { concurrency: "unbounded", discard: true },
513 );
514 }),
515 );
516 },
517 );
518
519 const registerPlatform = Effect.fn("EnvironmentRegistry.registerPlatform")(function* (
520 registration: PrimaryConnectionRegistration,
521 ) {
522 yield* installPlatformRegistration(registration);
523 });
524
525 // Reconcile the full set of platform-managed environments against what the
526 // host currently reports: add/refresh the desired ones and tear down any
527 // platform environment that disappeared (WSL toggled off, distro switched).
528 const reconcilePlatform = Effect.fn("EnvironmentRegistry.reconcilePlatform")(function* (
529 platformRegistrations: ReadonlyArray<PlatformConnectionRegistration>,
530 ) {
531 const desiredIds = new Set(
532 platformRegistrations.map((registration) => registration.target.environmentId),
533 );
534 const currentPlatformIds = yield* Ref.get(platformEnvironmentIds);
535 yield* Effect.forEach(
536 currentPlatformIds,
537 (environmentId) =>
538 desiredIds.has(environmentId) ? Effect.void : removePlatformEnvironment(environmentId),
539 { discard: true },
445540 );
541 yield* Effect.forEach(platformRegistrations, installPlatformRegistration, { discard: true });
446542 });
447543
448544 const remove = Effect.fn("EnvironmentRegistry.remove")(function* (environmentId: EnvironmentId) {
......@@ -567,6 +663,7 @@ export const make = Effect.gen(function* () {
567663 start,
568664 register,
569665 registerPlatform,
666 reconcilePlatform,
570667 remove,
571668 removeRelayEnvironments,
572669 retryNow,
packages/client-runtime/src/platform/source.ts+6-2
......@@ -1,11 +1,15 @@
11import * as Context from "effect/Context";
22import type * as Stream from "effect/Stream";
33
4import type { PrimaryConnectionRegistration } from "../connection/catalog.ts";
4import type { PlatformConnectionRegistration } from "../connection/catalog.ts";
55
66export class PlatformConnectionSource extends Context.Service<
77 PlatformConnectionSource,
88 {
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>>;
1014 }
1115>()("@t3tools/client-runtime/platform/source/PlatformConnectionSource") {}
packages/contracts/src/desktopBootstrap.ts+4-1
......@@ -6,7 +6,10 @@ export const DesktopBackendBootstrap = Schema.Struct({
66 mode: Schema.Literal("desktop"),
77 noBrowser: Schema.Boolean,
88 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),
1013 host: Schema.String,
1114 desktopBootstrapToken: Schema.String,
1215 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({
259259 state: DesktopUpdateStateSchema,
260260});
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
262268export 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;
263273 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;
264278 httpBaseUrl: string | null;
265279 wsBaseUrl: string | null;
266280 bootstrapToken?: string;
267281}
268282
269283export const DesktopEnvironmentBootstrapSchema = Schema.Struct({
284 id: Schema.String,
270285 label: Schema.String,
286 runningDistro: Schema.optionalKey(Schema.NullOr(Schema.String)),
271287 httpBaseUrl: Schema.NullOr(Schema.String),
272288 wsBaseUrl: Schema.NullOr(Schema.String),
273289 bootstrapToken: Schema.optionalKey(Schema.String),
......@@ -411,10 +427,59 @@ export const DesktopServerExposureStateSchema = Schema.Struct({
411427
412428export interface PickFolderOptions {
413429 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;
414436}
415437
416438export const PickFolderOptionsSchema = Schema.Struct({
417439 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),
418483});
419484
420485/**
......@@ -881,7 +946,10 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({
881946
882947export interface DesktopBridge {
883948 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[];
885953 getLocalEnvironmentBearerToken: () => Promise<string>;
886954 getClientSettings: () => Promise<ClientSettings | null>;
887955 setClientSettings: (settings: ClientSettings) => Promise<void>;
......@@ -913,6 +981,10 @@ export interface DesktopBridge {
913981 readonly port?: number;
914982 }) => Promise<DesktopServerExposureState>;
915983 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>;
916988 pickFolder: (options?: PickFolderOptions) => Promise<string | null>;
917989 confirm: (message: string) => Promise<boolean>;
918990 setTheme: (theme: DesktopTheme) => Promise<void>;
packages/shared/package.json+4
......@@ -178,6 +178,10 @@
178178 "./hostProcess": {
179179 "types": "./src/hostProcess.ts",
180180 "import": "./src/hostProcess.ts"
181 },
182 "./httpReadiness": {
183 "types": "./src/httpReadiness.ts",
184 "import": "./src/httpReadiness.ts"
181185 }
182186 },
183187 "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 {
22 DesktopSshEnvironmentBootstrap,
33 DesktopSshEnvironmentTarget,
44} from "@t3tools/contracts";
5import {
6 describeReadinessCause,
7 waitForHttpReady as waitForHttpReadyShared,
8} from "@t3tools/shared/httpReadiness";
59import * as NetService from "@t3tools/shared/Net";
610import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson";
711import { satisfiesSemverRange } from "@t3tools/shared/semver";
812import * as Context from "effect/Context";
913import * as Deferred from "effect/Deferred";
10import * as Duration from "effect/Duration";
1114import * as Effect from "effect/Effect";
1215import * as Exit from "effect/Exit";
1316import * as FileSystem from "effect/FileSystem";
1417import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
1618import * as Path from "effect/Path";
17import * as Ref from "effect/Ref";
18import * as Schedule from "effect/Schedule";
1919import * as Schema from "effect/Schema";
2020import * as Scope from "effect/Scope";
2121import * as Stream from "effect/Stream";
22import { HttpClient, HttpClientRequest } from "effect/unstable/http";
22import { HttpClient } from "effect/unstable/http";
2323import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2424
2525import {
......@@ -232,33 +232,9 @@ function applyScriptPlaceholders(
232232 return result;
233233}
234234
235export function describeReadinessCause(cause: unknown): unknown {
236 if (cause instanceof SshReadinessError) {
237 return {
238 _tag: cause._tag,
239 message: cause.message,
240 ...(cause.cause === undefined ? {} : { cause: describeReadinessCause(cause.cause) }),
241 };
242 }
243 if (cause instanceof Error) {
244 return {
245 name: cause.name,
246 message: cause.message,
247 ...(cause.cause === undefined ? {} : { cause: describeReadinessCause(cause.cause) }),
248 };
249 }
250 if (typeof cause !== "object" || cause === null) {
251 return cause;
252 }
253
254 const record = cause as Readonly<Record<string, unknown>>;
255 return {
256 ...(typeof record._tag === "string" ? { _tag: record._tag } : {}),
257 ...(typeof record.message === "string" ? { message: record.message } : {}),
258 ...(record.reason === undefined ? {} : { reason: describeReadinessCause(record.reason) }),
259 ...(record.cause === undefined ? {} : { cause: describeReadinessCause(record.cause) }),
260 };
261}
235// Re-exported from the shared HTTP readiness module so existing importers
236// (notably tunnel.test.ts) keep resolving it from here.
237export { describeReadinessCause };
262238
263239export const REMOTE_PICK_PORT_SCRIPT = `const fs = require("node:fs");
264240const net = require("node:net");
......@@ -666,7 +642,7 @@ export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string
666642 );
667643}
668644
669function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string {
645export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string {
670646 return stripTrailingNewlines(
671647 applyScriptPlaceholders(REMOTE_NODE_ENV_SCRIPT, {
672648 T3_NODE_ENGINE_RANGE: shellSingleQuote(input?.nodeEngineRange?.trim() || ""),
......@@ -865,120 +841,46 @@ const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")(
865841 return result.stdout.trim();
866842});
867843
868export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(function* (input: {
844export const waitForHttpReady = (input: {
869845 readonly baseUrl: string;
870846 readonly timeoutMs?: number;
871847 readonly intervalMs?: number;
872848 readonly probeTimeoutMs?: number;
873849 readonly path?: string;
874}): Effect.fn.Return<void, SshReadinessError, HttpClient.HttpClient> {
875 const timeoutMs = input.timeoutMs ?? 30_000;
876 const intervalMs = input.intervalMs ?? 100;
877 const probeTimeoutMs = input.probeTimeoutMs ?? SSH_READY_PROBE_TIMEOUT_MS;
878 const retryPolicy = Schedule.spaced(Duration.millis(intervalMs)).pipe(
879 Schedule.take(Math.max(0, Math.ceil(timeoutMs / intervalMs))),
880 );
881 const requestUrl = new URL(input.path ?? "/", input.baseUrl).toString();
882 const client = yield* HttpClient.HttpClient;
883 const lastProbeFailure = yield* Ref.make<unknown>(null);
884 let attempt = 0;
885
886 yield* Effect.logDebug("ssh.tunnel.httpReady.start", {
850}): Effect.Effect<void, SshReadinessError, HttpClient.HttpClient> =>
851 waitForHttpReadyShared({
887852 baseUrl: input.baseUrl,
888 requestUrl,
889 timeoutMs,
890 intervalMs,
891 probeTimeoutMs,
892 });
893
894 const readinessClient = client.pipe(
895 HttpClient.filterStatusOk,
896 HttpClient.transform((effect) =>
897 Effect.gen(function* () {
898 attempt += 1;
899 const responseOption = yield* effect.pipe(
900 Effect.timeoutOption(Duration.millis(probeTimeoutMs)),
901 Effect.mapError(
902 (cause) =>
903 new SshReadinessError({
904 message: `Backend readiness probe failed at ${requestUrl}.`,
905 cause,
906 }),
907 ),
908 );
909 return yield* Option.match(responseOption, {
910 onSome: Effect.succeed,
911 onNone: () =>
912 Effect.fail(
913 new SshReadinessError({
914 message: `Backend readiness probe exceeded ${probeTimeoutMs}ms at ${requestUrl}.`,
915 cause: {
916 kind: "probe-timeout",
917 attempt,
918 probeTimeoutMs,
919 },
920 }),
921 ),
922 });
923 }).pipe(
924 Effect.mapError((cause) =>
925 cause instanceof SshReadinessError
926 ? cause
927 : new SshReadinessError({
928 message: `Backend readiness probe failed at ${requestUrl}.`,
929 cause,
930 }),
931 ),
932 Effect.tapError((cause) =>
933 Ref.set(lastProbeFailure, {
934 attempt,
935 cause: describeReadinessCause(cause),
936 }),
937 ),
938 ),
939 ),
940 HttpClient.tap((response) => response.text.pipe(Effect.ignore)),
941 HttpClient.retry(retryPolicy),
942 );
943
944 const result = yield* readinessClient.execute(HttpClientRequest.get(requestUrl)).pipe(
945 Effect.mapError((cause) =>
946 cause instanceof SshReadinessError
947 ? cause
948 : new SshReadinessError({
949 message: `Backend readiness probe failed at ${requestUrl}.`,
853 ...(input.path === undefined ? {} : { path: input.path }),
854 ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
855 ...(input.intervalMs === undefined ? {} : { intervalMs: input.intervalMs }),
856 probeTimeoutMs: input.probeTimeoutMs ?? SSH_READY_PROBE_TIMEOUT_MS,
857 makeError: ({ requestUrl, probeTimeoutMs, cause }) => {
858 if (typeof cause === "object" && cause !== null && "kind" in cause) {
859 const kind = (cause as { readonly kind?: unknown }).kind;
860 if (kind === "probe-timeout") {
861 return new SshReadinessError({
862 message: `Backend readiness probe exceeded ${probeTimeoutMs}ms at ${requestUrl}.`,
950863 cause,
951 }),
952 ),
953 Effect.timeoutOption(Duration.millis(timeoutMs)),
954 );
955
956 return yield* Option.match(result, {
957 onSome: () =>
958 Effect.logDebug("ssh.tunnel.httpReady.succeeded", {
959 baseUrl: input.baseUrl,
960 requestUrl,
961 attempts: attempt,
962 }),
963 onNone: () =>
964 Effect.gen(function* () {
965 const lastFailure = yield* Ref.get(lastProbeFailure);
966 yield* Effect.logWarning("ssh.tunnel.httpReady.timedOut", {
967 baseUrl: input.baseUrl,
968 requestUrl,
969 timeoutMs,
970 intervalMs,
971 probeTimeoutMs,
972 attempts: attempt,
973 lastFailure,
974 });
975 return yield* new SshReadinessError({
976 message: `Timed out waiting ${timeoutMs}ms for backend readiness at ${input.baseUrl}.`,
977 cause: lastFailure,
978 });
979 }),
864 });
865 }
866 if (kind === "overall-timeout") {
867 const overall = cause as unknown as {
868 readonly baseUrl: string;
869 readonly timeoutMs: number;
870 readonly lastFailure: unknown;
871 };
872 return new SshReadinessError({
873 message: `Timed out waiting ${overall.timeoutMs}ms for backend readiness at ${overall.baseUrl}.`,
874 cause: overall.lastFailure,
875 });
876 }
877 }
878 return new SshReadinessError({
879 message: `Backend readiness probe failed at ${requestUrl}.`,
880 cause,
881 });
882 },
980883 });
981});
982884
983885function isLoopbackHostname(hostname: string): boolean {
984886 const normalized = hostname
pnpm-lock.yaml+117-10
......@@ -911,21 +911,25 @@ packages:
911911 resolution: {integrity: sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg==}
912912 cpu: [arm64]
913913 os: [linux]
914 libc: [musl]
914915
915916 '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170':
916917 resolution: {integrity: sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ==}
917918 cpu: [arm64]
918919 os: [linux]
920 libc: [glibc]
919921
920922 '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170':
921923 resolution: {integrity: sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ==}
922924 cpu: [x64]
923925 os: [linux]
926 libc: [musl]
924927
925928 '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170':
926929 resolution: {integrity: sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg==}
927930 cpu: [x64]
928931 os: [linux]
932 libc: [glibc]
929933
930934 '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170':
931935 resolution: {integrity: sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg==}
......@@ -977,24 +981,28 @@ packages:
977981 engines: {node: ^20.19.0 || >=22.12.0}
978982 cpu: [arm64]
979983 os: [linux]
984 libc: [glibc]
980985
981986 '@astrojs/compiler-binding-linux-arm64-musl@0.2.3':
982987 resolution: {integrity: sha512-O3e2CbN4yTsRguWYNnRd0p5YQ0H3fb7KpcR0W4R319q/gq5B1pJ7eqNbiO3b8g2AuiEcRTiUz5jeGT9j69cxOQ==}
983988 engines: {node: ^20.19.0 || >=22.12.0}
984989 cpu: [arm64]
985990 os: [linux]
991 libc: [musl]
986992
987993 '@astrojs/compiler-binding-linux-x64-gnu@0.2.3':
988994 resolution: {integrity: sha512-hbLBjXVp+96psMe7/7uqyrquGiULXANrq6REVxxPK/I5VzebZ7LHmSfykmByUbLyR1u+K6CTBKgvdQsK2L+2Xw==}
989995 engines: {node: ^20.19.0 || >=22.12.0}
990996 cpu: [x64]
991997 os: [linux]
998 libc: [glibc]
992999
9931000 '@astrojs/compiler-binding-linux-x64-musl@0.2.3':
9941001 resolution: {integrity: sha512-vIiEvOwrJfHZMaTmqUCrFTIwMYL0+PD3Rvy7kFDQgERyx3zhaw8CPa01MCCqa+/sj344BGrXKZ6ti37SgNLMhw==}
9951002 engines: {node: ^20.19.0 || >=22.12.0}
9961003 cpu: [x64]
9971004 os: [linux]
1005 libc: [musl]
9981006
9991007 '@astrojs/compiler-binding-wasm32-wasi@0.2.3':
10001008 resolution: {integrity: sha512-p9S2X8z/mUR2SMzAVJRFMCt8YaalKR+pjl2DgpdjzCQc6ww4bo8kiy54tgKqxZeNF5c+/2tCDTQIxVSm9V1FsA==}
......@@ -1583,21 +1591,25 @@ packages:
15831591 resolution: {integrity: sha512-A/pWy8Jb/PhDYc2/JFuYh06gFJcsfBUBDl81YydGYBrL/Z4nItDfhNDNOibyeSN/lKKDRlycIHEIajjErk00sQ==}
15841592 cpu: [arm64]
15851593 os: [linux]
1594 libc: [glibc]
15861595
15871596 '@bruits/satteri-linux-arm64-musl@0.9.3':
15881597 resolution: {integrity: sha512-L6YxmyOSickzo4pE5WmZfNTJnjX0MtgKOsuwQfNZECTx9Ir5vl2B37EIwnxe2AybuPPHl+FqVQtthNDUdH4Vgg==}
15891598 cpu: [arm64]
15901599 os: [linux]
1600 libc: [musl]
15911601
15921602 '@bruits/satteri-linux-x64-gnu@0.9.3':
15931603 resolution: {integrity: sha512-RgH6GPihg9Lzs2yHUsMjqiLxfLyOdmBty8sg9pBY9B4CBnvdOzvg8vklqN+C4qrEEdA9TwpbDpHr1AshLKyRpw==}
15941604 cpu: [x64]
15951605 os: [linux]
1606 libc: [glibc]
15961607
15971608 '@bruits/satteri-linux-x64-musl@0.9.3':
15981609 resolution: {integrity: sha512-BeWhVORjNTIomePznUKiMbHZTqC0j7sMXZFsISmbX+po5d33KLkqBqKh6K332CHJ8KUmCWx16FfPjwsoysttQg==}
15991610 cpu: [x64]
16001611 os: [linux]
1612 libc: [musl]
16011613
16021614 '@bruits/satteri-wasm32-wasi@0.9.3':
16031615 resolution: {integrity: sha512-dFNcOHKWV2cztCPnYTn7kZ9D7kNOt8N239z5ysFkNHLxJrfK7zaKIXQbfXYN32C+JoVFqAcTIOeWH2+VnsCOHg==}
......@@ -2575,21 +2587,25 @@ packages:
25752587 resolution: {integrity: sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw==}
25762588 cpu: [arm64]
25772589 os: [linux]
2590 libc: [glibc]
25782591
25792592 '@ff-labs/fff-bin-linux-arm64-musl@0.9.4':
25802593 resolution: {integrity: sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA==}
25812594 cpu: [arm64]
25822595 os: [linux]
2596 libc: [musl]
25832597
25842598 '@ff-labs/fff-bin-linux-x64-gnu@0.9.4':
25852599 resolution: {integrity: sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q==}
25862600 cpu: [x64]
25872601 os: [linux]
2602 libc: [glibc]
25882603
25892604 '@ff-labs/fff-bin-linux-x64-musl@0.9.4':
25902605 resolution: {integrity: sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w==}
25912606 cpu: [x64]
25922607 os: [linux]
2608 libc: [musl]
25932609
25942610 '@ff-labs/fff-bin-win32-arm64@0.9.4':
25952611 resolution: {integrity: sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ==}
......@@ -2676,89 +2692,105 @@ packages:
26762692 resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
26772693 cpu: [arm64]
26782694 os: [linux]
2695 libc: [glibc]
26792696
26802697 '@img/sharp-libvips-linux-arm@1.2.4':
26812698 resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
26822699 cpu: [arm]
26832700 os: [linux]
2701 libc: [glibc]
26842702
26852703 '@img/sharp-libvips-linux-ppc64@1.2.4':
26862704 resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
26872705 cpu: [ppc64]
26882706 os: [linux]
2707 libc: [glibc]
26892708
26902709 '@img/sharp-libvips-linux-riscv64@1.2.4':
26912710 resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
26922711 cpu: [riscv64]
26932712 os: [linux]
2713 libc: [glibc]
26942714
26952715 '@img/sharp-libvips-linux-s390x@1.2.4':
26962716 resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
26972717 cpu: [s390x]
26982718 os: [linux]
2719 libc: [glibc]
26992720
27002721 '@img/sharp-libvips-linux-x64@1.2.4':
27012722 resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
27022723 cpu: [x64]
27032724 os: [linux]
2725 libc: [glibc]
27042726
27052727 '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
27062728 resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
27072729 cpu: [arm64]
27082730 os: [linux]
2731 libc: [musl]
27092732
27102733 '@img/sharp-libvips-linuxmusl-x64@1.2.4':
27112734 resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
27122735 cpu: [x64]
27132736 os: [linux]
2737 libc: [musl]
27142738
27152739 '@img/sharp-linux-arm64@0.34.5':
27162740 resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
27172741 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
27182742 cpu: [arm64]
27192743 os: [linux]
2744 libc: [glibc]
27202745
27212746 '@img/sharp-linux-arm@0.34.5':
27222747 resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
27232748 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
27242749 cpu: [arm]
27252750 os: [linux]
2751 libc: [glibc]
27262752
27272753 '@img/sharp-linux-ppc64@0.34.5':
27282754 resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
27292755 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
27302756 cpu: [ppc64]
27312757 os: [linux]
2758 libc: [glibc]
27322759
27332760 '@img/sharp-linux-riscv64@0.34.5':
27342761 resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
27352762 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
27362763 cpu: [riscv64]
27372764 os: [linux]
2765 libc: [glibc]
27382766
27392767 '@img/sharp-linux-s390x@0.34.5':
27402768 resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
27412769 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
27422770 cpu: [s390x]
27432771 os: [linux]
2772 libc: [glibc]
27442773
27452774 '@img/sharp-linux-x64@0.34.5':
27462775 resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
27472776 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
27482777 cpu: [x64]
27492778 os: [linux]
2779 libc: [glibc]
27502780
27512781 '@img/sharp-linuxmusl-arm64@0.34.5':
27522782 resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
27532783 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
27542784 cpu: [arm64]
27552785 os: [linux]
2786 libc: [musl]
27562787
27572788 '@img/sharp-linuxmusl-x64@0.34.5':
27582789 resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
27592790 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
27602791 cpu: [x64]
27612792 os: [linux]
2793 libc: [musl]
27622794
27632795 '@img/sharp-wasm32@0.34.5':
27642796 resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
......@@ -3217,48 +3249,56 @@ packages:
32173249 engines: {node: ^20.19.0 || >=22.12.0}
32183250 cpu: [arm64]
32193251 os: [linux]
3252 libc: [glibc]
32203253
32213254 '@oxfmt/binding-linux-arm64-musl@0.55.0':
32223255 resolution: {integrity: sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA==}
32233256 engines: {node: ^20.19.0 || >=22.12.0}
32243257 cpu: [arm64]
32253258 os: [linux]
3259 libc: [musl]
32263260
32273261 '@oxfmt/binding-linux-ppc64-gnu@0.55.0':
32283262 resolution: {integrity: sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ==}
32293263 engines: {node: ^20.19.0 || >=22.12.0}
32303264 cpu: [ppc64]
32313265 os: [linux]
3266 libc: [glibc]
32323267
32333268 '@oxfmt/binding-linux-riscv64-gnu@0.55.0':
32343269 resolution: {integrity: sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A==}
32353270 engines: {node: ^20.19.0 || >=22.12.0}
32363271 cpu: [riscv64]
32373272 os: [linux]
3273 libc: [glibc]
32383274
32393275 '@oxfmt/binding-linux-riscv64-musl@0.55.0':
32403276 resolution: {integrity: sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA==}
32413277 engines: {node: ^20.19.0 || >=22.12.0}
32423278 cpu: [riscv64]
32433279 os: [linux]
3280 libc: [musl]
32443281
32453282 '@oxfmt/binding-linux-s390x-gnu@0.55.0':
32463283 resolution: {integrity: sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ==}
32473284 engines: {node: ^20.19.0 || >=22.12.0}
32483285 cpu: [s390x]
32493286 os: [linux]
3287 libc: [glibc]
32503288
32513289 '@oxfmt/binding-linux-x64-gnu@0.55.0':
32523290 resolution: {integrity: sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA==}
32533291 engines: {node: ^20.19.0 || >=22.12.0}
32543292 cpu: [x64]
32553293 os: [linux]
3294 libc: [glibc]
32563295
32573296 '@oxfmt/binding-linux-x64-musl@0.55.0':
32583297 resolution: {integrity: sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g==}
32593298 engines: {node: ^20.19.0 || >=22.12.0}
32603299 cpu: [x64]
32613300 os: [linux]
3301 libc: [musl]
32623302
32633303 '@oxfmt/binding-openharmony-arm64@0.55.0':
32643304 resolution: {integrity: sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg==}
......@@ -3361,48 +3401,56 @@ packages:
33613401 engines: {node: ^20.19.0 || >=22.12.0}
33623402 cpu: [arm64]
33633403 os: [linux]
3404 libc: [glibc]
33643405
33653406 '@oxlint/binding-linux-arm64-musl@1.70.0':
33663407 resolution: {integrity: sha512-JiylyurlB0CLSedNtx1gzv3FvfWPF1h/2Y3BJszPLNt5XQFlBsH5ke0Jle3iJb3uqu5m2e7A/DwzpuCAHdiU+A==}
33673408 engines: {node: ^20.19.0 || >=22.12.0}
33683409 cpu: [arm64]
33693410 os: [linux]
3411 libc: [musl]
33703412
33713413 '@oxlint/binding-linux-ppc64-gnu@1.70.0':
33723414 resolution: {integrity: sha512-J8VPG7I3/HmgaU4u8pNU2kFx2+0U+vPLS1dXFxXOaR/2TQ0f8AC7DRz0SRGRI1bfphnX2hVYTTtLuhL4nYKL+Q==}
33733415 engines: {node: ^20.19.0 || >=22.12.0}
33743416 cpu: [ppc64]
33753417 os: [linux]
3418 libc: [glibc]
33763419
33773420 '@oxlint/binding-linux-riscv64-gnu@1.70.0':
33783421 resolution: {integrity: sha512-N2+4lV2KLN+oXTIIIwmWDhwkrnvqf5oX7Hw0zPjk+RuIVgiBQSOlJWF7uQoFx2siEYX0ZQ5cfSbEAHm+J3t7Wg==}
33793422 engines: {node: ^20.19.0 || >=22.12.0}
33803423 cpu: [riscv64]
33813424 os: [linux]
3425 libc: [glibc]
33823426
33833427 '@oxlint/binding-linux-riscv64-musl@1.70.0':
33843428 resolution: {integrity: sha512-1e2L7cFCvx9QDzq6NPP+0tABKb5z6nWHyddWTNKprEsjO9xNrAtPowuCGpjNXxkTdsMiZ4jc8YQ5SstZd4XK6g==}
33853429 engines: {node: ^20.19.0 || >=22.12.0}
33863430 cpu: [riscv64]
33873431 os: [linux]
3432 libc: [musl]
33883433
33893434 '@oxlint/binding-linux-s390x-gnu@1.70.0':
33903435 resolution: {integrity: sha512-Kwu/l/8GcYibCWA9m9N5pRXMIKVSsL/YbgpLzYkqDhWTiqdRfnNJ/+nqIKRKQiFbHWsdlHEhzMwruJK+qcEruA==}
33913436 engines: {node: ^20.19.0 || >=22.12.0}
33923437 cpu: [s390x]
33933438 os: [linux]
3439 libc: [glibc]
33943440
33953441 '@oxlint/binding-linux-x64-gnu@1.70.0':
33963442 resolution: {integrity: sha512-tap04CsHYOl0nSAQJfPNIuBxqEPB2HnhQqwaOXLg1jnp2XfRo8Fa814dA4QC4zpvTWXCjAAaCY1W5LOORkEQuQ==}
33973443 engines: {node: ^20.19.0 || >=22.12.0}
33983444 cpu: [x64]
33993445 os: [linux]
3446 libc: [glibc]
34003447
34013448 '@oxlint/binding-linux-x64-musl@1.70.0':
34023449 resolution: {integrity: sha512-hzJa/WgvtJpbBD9rgfy0qe+MjbxOXNUT0bfR1S6EQQzfTtBFA9xg5q8KSwRrQ2QfSS+TaP4j+4mVPQrfNc6UNg==}
34033450 engines: {node: ^20.19.0 || >=22.12.0}
34043451 cpu: [x64]
34053452 os: [linux]
3453 libc: [musl]
34063454
34073455 '@oxlint/binding-openharmony-arm64@1.70.0':
34083456 resolution: {integrity: sha512-xbsaNSNzVSnaJACCUYr1HQMyY/Q/Q1LkePmHG3UvZPvGCYGNxrsZp9OmtA6ick8xH47ltRRbRrPCM1YXYcyC+A==}
......@@ -3889,108 +3937,126 @@ packages:
38893937 engines: {node: ^20.19.0 || >=22.12.0}
38903938 cpu: [arm64]
38913939 os: [linux]
3940 libc: [glibc]
38923941
38933942 '@rolldown/binding-linux-arm64-gnu@1.0.1':
38943943 resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==}
38953944 engines: {node: ^20.19.0 || >=22.12.0}
38963945 cpu: [arm64]
38973946 os: [linux]
3947 libc: [glibc]
38983948
38993949 '@rolldown/binding-linux-arm64-gnu@1.1.3':
39003950 resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==}
39013951 engines: {node: ^20.19.0 || >=22.12.0}
39023952 cpu: [arm64]
39033953 os: [linux]
3954 libc: [glibc]
39043955
39053956 '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
39063957 resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
39073958 engines: {node: ^20.19.0 || >=22.12.0}
39083959 cpu: [arm64]
39093960 os: [linux]
3961 libc: [musl]
39103962
39113963 '@rolldown/binding-linux-arm64-musl@1.0.1':
39123964 resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==}
39133965 engines: {node: ^20.19.0 || >=22.12.0}
39143966 cpu: [arm64]
39153967 os: [linux]
3968 libc: [musl]
39163969
39173970 '@rolldown/binding-linux-arm64-musl@1.1.3':
39183971 resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
39193972 engines: {node: ^20.19.0 || >=22.12.0}
39203973 cpu: [arm64]
39213974 os: [linux]
3975 libc: [musl]
39223976
39233977 '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
39243978 resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
39253979 engines: {node: ^20.19.0 || >=22.12.0}
39263980 cpu: [ppc64]
39273981 os: [linux]
3982 libc: [glibc]
39283983
39293984 '@rolldown/binding-linux-ppc64-gnu@1.0.1':
39303985 resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==}
39313986 engines: {node: ^20.19.0 || >=22.12.0}
39323987 cpu: [ppc64]
39333988 os: [linux]
3989 libc: [glibc]
39343990
39353991 '@rolldown/binding-linux-ppc64-gnu@1.1.3':
39363992 resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
39373993 engines: {node: ^20.19.0 || >=22.12.0}
39383994 cpu: [ppc64]
39393995 os: [linux]
3996 libc: [glibc]
39403997
39413998 '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
39423999 resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
39434000 engines: {node: ^20.19.0 || >=22.12.0}
39444001 cpu: [s390x]
39454002 os: [linux]
4003 libc: [glibc]
39464004
39474005 '@rolldown/binding-linux-s390x-gnu@1.0.1':
39484006 resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==}
39494007 engines: {node: ^20.19.0 || >=22.12.0}
39504008 cpu: [s390x]
39514009 os: [linux]
4010 libc: [glibc]
39524011
39534012 '@rolldown/binding-linux-s390x-gnu@1.1.3':
39544013 resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
39554014 engines: {node: ^20.19.0 || >=22.12.0}
39564015 cpu: [s390x]
39574016 os: [linux]
4017 libc: [glibc]
39584018
39594019 '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
39604020 resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
39614021 engines: {node: ^20.19.0 || >=22.12.0}
39624022 cpu: [x64]
39634023 os: [linux]
4024 libc: [glibc]
39644025
39654026 '@rolldown/binding-linux-x64-gnu@1.0.1':
39664027 resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==}
39674028 engines: {node: ^20.19.0 || >=22.12.0}
39684029 cpu: [x64]
39694030 os: [linux]
4031 libc: [glibc]
39704032
39714033 '@rolldown/binding-linux-x64-gnu@1.1.3':
39724034 resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
39734035 engines: {node: ^20.19.0 || >=22.12.0}
39744036 cpu: [x64]
39754037 os: [linux]
4038 libc: [glibc]
39764039
39774040 '@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
39784041 resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
39794042 engines: {node: ^20.19.0 || >=22.12.0}
39804043 cpu: [x64]
39814044 os: [linux]
4045 libc: [musl]
39824046
39834047 '@rolldown/binding-linux-x64-musl@1.0.1':
39844048 resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==}
39854049 engines: {node: ^20.19.0 || >=22.12.0}
39864050 cpu: [x64]
39874051 os: [linux]
4052 libc: [musl]
39884053
39894054 '@rolldown/binding-linux-x64-musl@1.1.3':
39904055 resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
39914056 engines: {node: ^20.19.0 || >=22.12.0}
39924057 cpu: [x64]
39934058 os: [linux]
4059 libc: [musl]
39944060
39954061 '@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
39964062 resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
......@@ -4127,66 +4193,79 @@ packages:
41274193 resolution: {integrity: sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==}
41284194 cpu: [arm]
41294195 os: [linux]
4196 libc: [glibc]
41304197
41314198 '@rollup/rollup-linux-arm-musleabihf@4.61.0':
41324199 resolution: {integrity: sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==}
41334200 cpu: [arm]
41344201 os: [linux]
4202 libc: [musl]
41354203
41364204 '@rollup/rollup-linux-arm64-gnu@4.61.0':
41374205 resolution: {integrity: sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==}
41384206 cpu: [arm64]
41394207 os: [linux]
4208 libc: [glibc]
41404209
41414210 '@rollup/rollup-linux-arm64-musl@4.61.0':
41424211 resolution: {integrity: sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==}
41434212 cpu: [arm64]
41444213 os: [linux]
4214 libc: [musl]
41454215
41464216 '@rollup/rollup-linux-loong64-gnu@4.61.0':
41474217 resolution: {integrity: sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==}
41484218 cpu: [loong64]
41494219 os: [linux]
4220 libc: [glibc]
41504221
41514222 '@rollup/rollup-linux-loong64-musl@4.61.0':
41524223 resolution: {integrity: sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==}
41534224 cpu: [loong64]
41544225 os: [linux]
4226 libc: [musl]
41554227
41564228 '@rollup/rollup-linux-ppc64-gnu@4.61.0':
41574229 resolution: {integrity: sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==}
41584230 cpu: [ppc64]
41594231 os: [linux]
4232 libc: [glibc]
41604233
41614234 '@rollup/rollup-linux-ppc64-musl@4.61.0':
41624235 resolution: {integrity: sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==}
41634236 cpu: [ppc64]
41644237 os: [linux]
4238 libc: [musl]
41654239
41664240 '@rollup/rollup-linux-riscv64-gnu@4.61.0':
41674241 resolution: {integrity: sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==}
41684242 cpu: [riscv64]
41694243 os: [linux]
4244 libc: [glibc]
41704245
41714246 '@rollup/rollup-linux-riscv64-musl@4.61.0':
41724247 resolution: {integrity: sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==}
41734248 cpu: [riscv64]
41744249 os: [linux]
4250 libc: [musl]
41754251
41764252 '@rollup/rollup-linux-s390x-gnu@4.61.0':
41774253 resolution: {integrity: sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==}
41784254 cpu: [s390x]
41794255 os: [linux]
4256 libc: [glibc]
41804257
41814258 '@rollup/rollup-linux-x64-gnu@4.61.0':
41824259 resolution: {integrity: sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==}
41834260 cpu: [x64]
41844261 os: [linux]
4262 libc: [glibc]
41854263
41864264 '@rollup/rollup-linux-x64-musl@4.61.0':
41874265 resolution: {integrity: sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==}
41884266 cpu: [x64]
41894267 os: [linux]
4268 libc: [musl]
41904269
41914270 '@rollup/rollup-openbsd-x64@4.61.0':
41924271 resolution: {integrity: sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==}
......@@ -4421,48 +4500,56 @@ packages:
44214500 engines: {node: '>= 20'}
44224501 cpu: [arm64]
44234502 os: [linux]
4503 libc: [glibc]
44244504
44254505 '@tailwindcss/oxide-linux-arm64-gnu@4.3.0':
44264506 resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==}
44274507 engines: {node: '>= 20'}
44284508 cpu: [arm64]
44294509 os: [linux]
4510 libc: [glibc]
44304511
44314512 '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
44324513 resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
44334514 engines: {node: '>= 20'}
44344515 cpu: [arm64]
44354516 os: [linux]
4517 libc: [musl]
44364518
44374519 '@tailwindcss/oxide-linux-arm64-musl@4.3.0':
44384520 resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
44394521 engines: {node: '>= 20'}
44404522 cpu: [arm64]
44414523 os: [linux]
4524 libc: [musl]
44424525
44434526 '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
44444527 resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
44454528 engines: {node: '>= 20'}
44464529 cpu: [x64]
44474530 os: [linux]
4531 libc: [glibc]
44484532
44494533 '@tailwindcss/oxide-linux-x64-gnu@4.3.0':
44504534 resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
44514535 engines: {node: '>= 20'}
44524536 cpu: [x64]
44534537 os: [linux]
4538 libc: [glibc]
44544539
44554540 '@tailwindcss/oxide-linux-x64-musl@4.2.1':
44564541 resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
44574542 engines: {node: '>= 20'}
44584543 cpu: [x64]
44594544 os: [linux]
4545 libc: [musl]
44604546
44614547 '@tailwindcss/oxide-linux-x64-musl@4.3.0':
44624548 resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
44634549 engines: {node: '>= 20'}
44644550 cpu: [x64]
44654551 os: [linux]
4552 libc: [musl]
44664553
44674554 '@tailwindcss/oxide-wasm32-wasi@4.2.1':
44684555 resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
......@@ -4935,24 +5022,28 @@ packages:
49355022 engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0}
49365023 cpu: [arm64]
49375024 os: [linux]
5025 libc: [glibc]
49385026
49395027 '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.1':
49405028 resolution: {integrity: sha512-vUY7hYycZW0qEevpl7ImzZJFnOEKRYCaCOX4TBW0vk6MJZ+zj/xW7e0LOggzJcz2wbYAgLDqp5h+b8wV9dguDA==}
49415029 engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0}
49425030 cpu: [arm64]
49435031 os: [linux]
5032 libc: [musl]
49445033
49455034 '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.1':
49465035 resolution: {integrity: sha512-tFxpToEaykBGxMQHp8M/qmr1yruRRED+c9gA1h9kmplqot04OxuqzRCWu/IiIvMJ0v3JFdOP3gqkyjXLLJhxIA==}
49475036 engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0}
49485037 cpu: [x64]
49495038 os: [linux]
5039 libc: [glibc]
49505040
49515041 '@voidzero-dev/vite-plus-linux-x64-musl@0.2.1':
49525042 resolution: {integrity: sha512-2scSS7wEbLO2758fqr1/bAULg7nLCFa5V8LO2b5w3g1CrTYdMTDt2WX1ghPesIi+70pYGydRbXo6iaaN43zfMg==}
49535043 engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0}
49545044 cpu: [x64]
49555045 os: [linux]
5046 libc: [musl]
49565047
49575048 '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.1':
49585049 resolution: {integrity: sha512-3+5FJYhi9SqBszjngI2LBmvoiqEwxJWyQ5UsOUtNz6/d+yDrDw+tOgHLl4OKIh5aVNZeIGXzxvP6h24kcEqIyg==}
......@@ -5035,24 +5126,28 @@ packages:
50355126 engines: {node: '>= 12'}
50365127 cpu: [arm64]
50375128 os: [linux]
5129 libc: [glibc]
50385130
50395131 '@yuuang/ffi-rs-linux-arm64-musl@1.3.2':
50405132 resolution: {integrity: sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA==}
50415133 engines: {node: '>= 12'}
50425134 cpu: [arm64]
50435135 os: [linux]
5136 libc: [musl]
50445137
50455138 '@yuuang/ffi-rs-linux-x64-gnu@1.3.2':
50465139 resolution: {integrity: sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g==}
50475140 engines: {node: '>= 12'}
50485141 cpu: [x64]
50495142 os: [linux]
5143 libc: [glibc]
50505144
50515145 '@yuuang/ffi-rs-linux-x64-musl@1.3.2':
50525146 resolution: {integrity: sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w==}
50535147 engines: {node: '>= 12'}
50545148 cpu: [x64]
50555149 os: [linux]
5150 libc: [glibc]
50565151
50575152 '@yuuang/ffi-rs-win32-arm64-msvc@1.3.2':
50585153 resolution: {integrity: sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg==}
......@@ -7416,72 +7511,84 @@ packages:
74167511 engines: {node: '>= 12.0.0'}
74177512 cpu: [arm64]
74187513 os: [linux]
7514 libc: [glibc]
74197515
74207516 lightningcss-linux-arm64-gnu@1.31.1:
74217517 resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
74227518 engines: {node: '>= 12.0.0'}
74237519 cpu: [arm64]
74247520 os: [linux]
7521 libc: [glibc]
74257522
74267523 lightningcss-linux-arm64-gnu@1.32.0:
74277524 resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
74287525 engines: {node: '>= 12.0.0'}
74297526 cpu: [arm64]
74307527 os: [linux]
7528 libc: [glibc]
74317529
74327530 lightningcss-linux-arm64-musl@1.30.1:
74337531 resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
74347532 engines: {node: '>= 12.0.0'}
74357533 cpu: [arm64]
74367534 os: [linux]
7535 libc: [musl]
74377536
74387537 lightningcss-linux-arm64-musl@1.31.1:
74397538 resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
74407539 engines: {node: '>= 12.0.0'}
74417540 cpu: [arm64]
74427541 os: [linux]
7542 libc: [musl]
74437543
74447544 lightningcss-linux-arm64-musl@1.32.0:
74457545 resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
74467546 engines: {node: '>= 12.0.0'}
74477547 cpu: [arm64]
74487548 os: [linux]
7549 libc: [musl]
74497550
74507551 lightningcss-linux-x64-gnu@1.30.1:
74517552 resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
74527553 engines: {node: '>= 12.0.0'}
74537554 cpu: [x64]
74547555 os: [linux]
7556 libc: [glibc]
74557557
74567558 lightningcss-linux-x64-gnu@1.31.1:
74577559 resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
74587560 engines: {node: '>= 12.0.0'}
74597561 cpu: [x64]
74607562 os: [linux]
7563 libc: [glibc]
74617564
74627565 lightningcss-linux-x64-gnu@1.32.0:
74637566 resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
74647567 engines: {node: '>= 12.0.0'}
74657568 cpu: [x64]
74667569 os: [linux]
7570 libc: [glibc]
74677571
74687572 lightningcss-linux-x64-musl@1.30.1:
74697573 resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
74707574 engines: {node: '>= 12.0.0'}
74717575 cpu: [x64]
74727576 os: [linux]
7577 libc: [musl]
74737578
74747579 lightningcss-linux-x64-musl@1.31.1:
74757580 resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
74767581 engines: {node: '>= 12.0.0'}
74777582 cpu: [x64]
74787583 os: [linux]
7584 libc: [musl]
74797585
74807586 lightningcss-linux-x64-musl@1.32.0:
74817587 resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
74827588 engines: {node: '>= 12.0.0'}
74837589 cpu: [x64]
74847590 os: [linux]
7591 libc: [musl]
74857592
74867593 lightningcss-win32-arm64-msvc@1.30.1:
74877594 resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
......@@ -14269,19 +14376,19 @@ snapshots:
1426914376 '@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)
1427014377 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)':
1427314380 dependencies:
1427414381 '@testing-library/dom': 10.4.1
1427514382 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
14276 '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))
14277 vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))
14383 '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)
14384 vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))
1427814385 transitivePeerDependencies:
1427914386 - bufferutil
1428014387 - msw
1428114388 - utf-8-validate
1428214389 - 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)':
1428514392 dependencies:
1428614393 '@blazediff/core': 1.9.1
1428714394 '@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:
1429014397 pngjs: 7.0.0
1429114398 sirv: 3.0.2
1429214399 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))
1429414401 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1429514402 transitivePeerDependencies:
1429614403 - bufferutil
......@@ -19894,8 +20001,8 @@ snapshots:
1989420001 dependencies:
1989520002 '@oxc-project/types': 0.136.0
1989620003 '@oxlint/plugins': 1.68.0
19897 '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))
19898 '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))
20004 '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)
20005 '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)
1989920006 '@vitest/expect': 4.1.9
1990020007 '@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))
1990120008 '@vitest/pretty-format': 4.1.9
......@@ -19908,7 +20015,7 @@ snapshots:
1990820015 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))
1990920016 oxlint-tsgolint: 0.23.0
1991020017 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))
1991220019 optionalDependencies:
1991320020 '@voidzero-dev/vite-plus-darwin-arm64': 0.2.1
1991420021 '@voidzero-dev/vite-plus-darwin-x64': 0.2.1
......@@ -19954,7 +20061,7 @@ snapshots:
1995420061 optionalDependencies:
1995520062 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)):
1995820065 dependencies:
1995920066 '@vitest/expect': 4.1.9
1996020067 '@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:
1997820085 why-is-node-running: 2.3.0
1997920086 optionalDependencies:
1998020087 '@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)
1998220089 transitivePeerDependencies:
1998320090 - msw
1998420091
pnpm-workspace.yaml+14
......@@ -5,6 +5,20 @@ packages:
55 - packages/*
66 - 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
822catalog:
923 "@clerk/backend": 3.8.3-snapshot.v20260622234151
1024 "@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) => {
210210 cpu: ["x64"],
211211 },
212212 });
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 });
213222 assert.deepStrictEqual(createStageWorkspaceConfig("win", "arm64"), {
214223 supportedArchitectures: {
215 os: ["win32"],
224 os: ["win32", "linux"],
216225 cpu: ["arm64"],
226 libc: ["glibc"],
217227 },
218228 });
219229 assert.deepStrictEqual(createStageWorkspaceConfig("mac", "universal"), {
......@@ -400,6 +410,25 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
400410 }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
401411 );
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
403432 it("promotes target fff binaries to direct staged dependencies", () => {
404433 assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), {
405434 "@ff-labs/fff-bin-darwin-arm64": "0.9.4",
......@@ -411,6 +440,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
411440 assert.deepStrictEqual(resolveFffNativeDependencies("win", "x64", "0.9.4"), {
412441 "@ff-labs/fff-bin-win32-x64": "0.9.4",
413442 });
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 });
414447 assert.deepStrictEqual(resolveFffNativeDependencies("linux", "arm64", "0.9.4"), {
415448 "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4",
416449 "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4",
......@@ -496,6 +529,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
496529 verbose: Option.none(),
497530 mockUpdates: Option.none(),
498531 mockUpdateServerPort: Option.none(),
532 wslPrebuild: Option.none(),
499533 }).pipe(
500534 Effect.provide(
501535 Layer.mergeAll(
......@@ -533,6 +567,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
533567 verbose: Option.some(false),
534568 mockUpdates: Option.some(false),
535569 mockUpdateServerPort: Option.none(),
570 wslPrebuild: Option.none(),
536571 }).pipe(
537572 Effect.provide(
538573 ConfigProvider.layer(
scripts/build-desktop-artifact.ts+171-5
......@@ -47,6 +47,7 @@ const StageWorkspaceConfig = Schema.Struct({
4747 supportedArchitectures: Schema.Struct({
4848 os: Schema.Array(Schema.String),
4949 cpu: Schema.Array(Schema.String),
50 libc: Schema.optional(Schema.Array(Schema.String)),
5051 }),
5152});
5253
......@@ -55,6 +56,9 @@ const RepoRoot = Effect.service(Path.Path).pipe(
5556);
5657const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString);
5758const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig));
59const decodeNodePtyManifest = Schema.decodeUnknownEffect(
60 Schema.fromJsonString(Schema.Struct({ version: Schema.String })),
61);
5862const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig));
5963
6064const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () {
......@@ -107,6 +111,7 @@ interface BuildCliInput {
107111 readonly verbose: Option.Option<boolean>;
108112 readonly mockUpdates: Option.Option<boolean>;
109113 readonly mockUpdateServerPort: Option.Option<number>;
114 readonly wslPrebuild: Option.Option<string>;
110115}
111116
112117function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {
......@@ -360,6 +365,29 @@ export class DesktopBuildNoArtifactsProducedError extends Schema.TaggedErrorClas
360365 }
361366}
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
363391export class LinuxIconResizeError extends Schema.TaggedErrorClass<LinuxIconResizeError>()(
364392 "LinuxIconResizeError",
365393 {
......@@ -517,6 +545,7 @@ interface ResolvedBuildOptions {
517545 readonly verbose: boolean;
518546 readonly mockUpdates: boolean;
519547 readonly mockUpdateServerPort: number | undefined;
548 readonly wslPrebuild: string | undefined;
520549}
521550
522551interface StagePackageJson {
......@@ -851,10 +880,26 @@ export function createStageWorkspaceConfig(
851880 platform: typeof BuildPlatform.Type,
852881 arch: typeof BuildArch.Type,
853882): 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 }
854899 return {
855900 supportedArchitectures: {
856 os: [platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"],
857 cpu: arch === "universal" ? ["arm64", "x64"] : [arch],
901 os: [hostOs],
902 cpu: hostCpu,
858903 },
859904 };
860905}
......@@ -905,6 +950,11 @@ const BuildEnvConfig = Config.all({
905950 verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)),
906951 mockUpdates: Config.boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)),
907952 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),
908958});
909959
910960const MockUpdateServerPortSchema = Schema.NumberFromString.check(
......@@ -988,6 +1038,9 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
9881038 ),
9891039 ));
9901040
1041 const wslPrebuild =
1042 Option.getOrUndefined(input.wslPrebuild) ?? Option.getOrUndefined(env.wslPrebuild);
1043
9911044 return {
9921045 platform,
9931046 target,
......@@ -1000,6 +1053,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
10001053 verbose,
10011054 mockUpdates,
10021055 mockUpdateServerPort,
1056 wslPrebuild,
10031057 } satisfies ResolvedBuildOptions;
10041058});
10051059
......@@ -1300,10 +1354,23 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
13001354 appId: DESKTOP_APP_ID,
13011355 productName: resolveDesktopProductName(version),
13021356 artifactName: "T3-Code-${version}-${arch}.${ext}",
1303 asarUnpack: [...DESKTOP_ASAR_UNPACK],
13041357 directories: {
13051358 buildResources: "apps/desktop/resources",
13061359 },
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/**"],
13071374 };
13081375 const updateChannel = resolveDesktopUpdateChannel(version);
13091376 const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);
......@@ -1357,11 +1424,13 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
13571424 const winConfig: Record<string, unknown> = {
13581425 target: [target],
13591426 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,
13601431 };
13611432 if (signed) {
13621433 winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig;
1363 } else {
1364 winConfig.signAndEditExecutable = false;
13651434 }
13661435 buildConfig.win = winConfig;
13671436 }
......@@ -1390,6 +1459,76 @@ const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(f
13901459 }
13911460});
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
13931532const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
13941533 options: ResolvedBuildOptions,
13951534) {
......@@ -1558,6 +1697,17 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
15581697 options.arch,
15591698 serverPackageJson.dependencies["@ff-labs/fff-node"],
15601699 ),
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 : {}),
15611711 };
15621712 const stagePnpmConfig = createStagePnpmConfig(workspacePatchedDependencies, stageDependencies);
15631713 const stagePackageJson: StagePackageJson = {
......@@ -1616,6 +1766,16 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
16161766 );
16171767 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
16191779 // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="")
16201780 // as enabled, so copy the host env and scrub empty values instead of relying
16211781 // on `extendEnv` merging.
......@@ -1769,6 +1929,12 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", {
17691929 Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."),
17701930 Flag.optional,
17711931 ),
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 ),
17721938}).pipe(
17731939 Command.withDescription("Build a desktop artifact for T3 Code."),
17741940 Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)),