Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
dddcc60
Add desktop WSL backend mode
Jgratton24 Apr 25, 2026
7c68e6a
Address WSL backend review feedback
Jgratton24 Apr 26, 2026
571e886
Harden desktop WSL path handling
Jgratton24 Apr 26, 2026
2385754
Handle WSL picker startup edge cases
Jgratton24 Apr 26, 2026
0e7374e
Address third WSL Bugbot review pass
Jgratton24 Apr 26, 2026
7c86886
Roll back persisted WSL config when start fails
Jgratton24 Apr 26, 2026
03d9bee
Fix two crashes that broke the WSL backend on a fresh install
Jgratton24 Apr 27, 2026
bb229b5
Serialize WSL toggle requests and gate node-pty rebuild for packaged …
Jgratton24 Apr 27, 2026
583e9d5
Reset primary env state and re-auth on backend swap
Jgratton24 Apr 27, 2026
f8bdfa8
Polish WSL backend settings UX
Jgratton24 Apr 27, 2026
017f1d6
Stop the new backend before rolling back a failed WSL toggle
Jgratton24 Apr 27, 2026
815bdee
Catch rejections from the scheduled backend restart
Jgratton24 Apr 27, 2026
443ec84
Bump wslpath timeout for cold WSL and guard nested suppression resets
Jgratton24 Apr 27, 2026
ac3a244
Merge remote-tracking branch 'upstream/main' into josh/desktop-wsl-ba…
Jgratton24 May 3, 2026
c60216e
Merge remote-tracking branch 'upstream/main' into josh/desktop-wsl-ba…
Jgratton24 May 4, 2026
280889b
fix: address bugbot review comments on WSL backend merge
Jgratton24 May 4, 2026
a8af75a
fix(wsl): reject distro names with newlines/tabs in config
Jgratton24 May 4, 2026
ecc5d31
fix(desktop): address bugbot follow-ups
Jgratton24 May 4, 2026
22ecc7f
fix(desktop): tighten WSL bootstrap stdin and bootstrap startBackend
Jgratton24 May 4, 2026
11fdae7
fix(desktop): survive WSL2 cold-start of the backend readiness check
Jgratton24 May 4, 2026
12d8ba5
fix(server): bump Claude capabilities probe timeout to 20s
Jgratton24 May 4, 2026
163e4c5
fix: address bugbot follow-ups on suppression + listWslDistros
Jgratton24 May 5, 2026
54d092a
fix(desktop): name missing WSL build tools instead of dumping gyp tail
Jgratton24 May 5, 2026
f49f74f
fix(web): drop unreachable null guards on ProjectFavicon src
Jgratton24 May 5, 2026
8fd37b4
fix(desktop): include node in WSL pre-flight tool check
Jgratton24 May 6, 2026
7aeb2f7
fix: address bugbot follow-ups on rollback reauth and WSL stdin race
Jgratton24 May 6, 2026
24130e4
fix(desktop): validate WSL node version against server engine range
Jgratton24 May 6, 2026
045dedc
fix(desktop): log when scheduled startBackend resolves false
Jgratton24 May 6, 2026
05e21ff
fix(desktop): re-arm restart when scheduled startBackend rejects
Jgratton24 May 6, 2026
4b22017
fix(desktop): address bugbot follow-ups on listWslDistros + rollback …
Jgratton24 May 6, 2026
961a0f7
fix(web): lock in WSL toggle config inside the suppress block
Jgratton24 May 6, 2026
5f7c7d3
fix(desktop): reset backoff on backend readiness, open window on beni…
Jgratton24 May 6, 2026
d8363f5
fix(desktop): keep modal open through full backend swap
Jgratton24 May 8, 2026
ea1348b
fix: address bugbot follow-ups on descriptor refresh and distro regex
Jgratton24 May 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/desktop/src/backendReadiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ describe("waitForHttpReady", () => {
it("returns once the backend serves the requested readiness path", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(new Response(null, { status: 404 }))
.mockResolvedValueOnce(new Response(null, { status: 503 }))
.mockResolvedValueOnce(new Response(null, { status: 200 }));

Expand All @@ -19,7 +20,7 @@ describe("waitForHttpReady", () => {
intervalMs: 0,
});

expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(fetchImpl).toHaveBeenNthCalledWith(
1,
"http://127.0.0.1:3773/",
Expand Down
454 changes: 391 additions & 63 deletions apps/desktop/src/main.ts

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions apps/desktop/src/nodeEngineRange.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";

import { satisfiesNodeEngineRange } from "./nodeEngineRange.js";

const SERVER_RANGE = "^22.16 || ^23.11 || >=24.10";

describe("satisfiesNodeEngineRange", () => {
it("accepts versions inside ^22.16 caret range", () => {
expect(satisfiesNodeEngineRange("22.16.0", SERVER_RANGE)).toBe(true);
expect(satisfiesNodeEngineRange("22.20.5", SERVER_RANGE)).toBe(true);
});

it("rejects versions below the lowest accepted minor in a caret range", () => {
expect(satisfiesNodeEngineRange("22.15.99", SERVER_RANGE)).toBe(false);
expect(satisfiesNodeEngineRange("22.0.0", SERVER_RANGE)).toBe(false);
});

it("does not let a caret cross major versions", () => {
expect(satisfiesNodeEngineRange("23.0.0", "^22.16")).toBe(false);
expect(satisfiesNodeEngineRange("21.5.0", "^22.16")).toBe(false);
});

it("accepts versions above >=24.10 including future majors", () => {
expect(satisfiesNodeEngineRange("24.10.0", SERVER_RANGE)).toBe(true);
expect(satisfiesNodeEngineRange("24.14.0", SERVER_RANGE)).toBe(true);
expect(satisfiesNodeEngineRange("25.0.0", SERVER_RANGE)).toBe(true);
});

it("rejects versions below all alternatives", () => {
expect(satisfiesNodeEngineRange("18.20.0", SERVER_RANGE)).toBe(false);
expect(satisfiesNodeEngineRange("20.11.1", SERVER_RANGE)).toBe(false);
expect(satisfiesNodeEngineRange("21.7.3", SERVER_RANGE)).toBe(false);
});

it("strips a leading v from the version string", () => {
expect(satisfiesNodeEngineRange("v24.14.0", SERVER_RANGE)).toBe(true);
});

it("returns false on an unparseable version", () => {
expect(satisfiesNodeEngineRange("", SERVER_RANGE)).toBe(false);
expect(satisfiesNodeEngineRange("not-a-version", SERVER_RANGE)).toBe(false);
});

it("treats missing minor and patch as zero", () => {
expect(satisfiesNodeEngineRange("24", ">=24.10")).toBe(false);
expect(satisfiesNodeEngineRange("25", ">=24.10")).toBe(true);
});
});
67 changes: 67 additions & 0 deletions apps/desktop/src/nodeEngineRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Minimal semver-range checker for the engines.node ranges in our package
// manifests (e.g. "^22.16 || ^23.11 || >=24.10"). Ported from the same logic
// in PR #2504's REMOTE_NODE_ENGINE_CHECK_SCRIPT so the two paths stay aligned;
// when whichever PR merges second extracts this into packages/shared, the
// other side can drop its copy.

interface ParsedVersion {
readonly major: number;
readonly minor: number;
readonly patch: number;
}

function parseVersion(value: string): ParsedVersion | null {
const match = value
.trim()
.replace(/^v/, "")
.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2] ?? "0"),
patch: Number(match[3] ?? "0"),
};
}

function compare(left: ParsedVersion, right: ParsedVersion): number {
if (left.major !== right.major) return left.major > right.major ? 1 : -1;
if (left.minor !== right.minor) return left.minor > right.minor ? 1 : -1;
if (left.patch !== right.patch) return left.patch > right.patch ? 1 : -1;
return 0;
}

function satisfiesComparator(version: ParsedVersion, comparator: string): boolean {
const trimmed = comparator.trim();
if (!trimmed) return true;
const match = trimmed.match(/^(\^|>=|>|<=|<|=)?\s*v?(\d+(?:\.\d+){0,2})$/);
if (!match) return false;
const operator = match[1] ?? "=";
const target = parseVersion(match[2]!);
if (!target) return false;
const compared = compare(version, target);
switch (operator) {
case "^":
return version.major === target.major && compared >= 0;
case ">=":
return compared >= 0;
case ">":
return compared > 0;
case "<=":
return compared <= 0;
case "<":
return compared < 0;
case "=":
return compared === 0;
default:
return false;
}
}

export function satisfiesNodeEngineRange(version: string, range: string): boolean {
const parsed = parseVersion(version);
if (!parsed) return false;
return range.split("||").some((group) => {
const comparators = group.trim().split(/\s+/).filter(Boolean);
return comparators.length > 0 && comparators.every((c) => satisfiesComparator(parsed, c));
});
}
6 changes: 6 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
const UPDATE_CHECK_CHANNEL = "desktop:update-check";
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
const WSL_LIST_DISTROS_CHANNEL = "desktop:wsl-list-distros";
const WSL_GET_CONFIG_CHANNEL = "desktop:wsl-get-config";
const WSL_SET_CONFIG_CHANNEL = "desktop:wsl-set-config";
const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
Expand Down Expand Up @@ -133,6 +136,9 @@ contextBridge.exposeInMainWorld("desktopBridge", {
checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),
downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),
installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),
wslListDistros: () => ipcRenderer.invoke(WSL_LIST_DISTROS_CHANNEL),
wslGetConfig: () => ipcRenderer.invoke(WSL_GET_CONFIG_CHANNEL),
wslSetConfig: (config) => ipcRenderer.invoke(WSL_SET_CONFIG_CHANNEL, config),
onUpdateState: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {
if (typeof state !== "object" || state === null) return;
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/prepareWslNodePty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env node

import * as Path from "node:path";
import { fileURLToPath } from "node:url";

import serverPackageJson from "../../server/package.json" with { type: "json" };
import { ensureWslNodePty } from "./wsl.ts";

const repoRoot = Path.resolve(Path.dirname(fileURLToPath(import.meta.url)), "../../..");

if (process.platform !== "win32") {
console.error("prepare:wsl must be run from Windows so it can invoke wsl.exe.");
process.exit(1);
}

const result = await ensureWslNodePty(null, repoRoot, {
allowBuild: true,
nodeEngineRange: serverPackageJson.engines.node,
});
if (!result.ok) {
console.error(result.reason);
process.exit(1);
}

console.log("WSL node-pty is prepared for the default distro.");
Loading
Loading