From 9c8b50b9899e361d91c732e6d7f0fe153d53b264 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:20:48 +1000 Subject: [PATCH] feat(deskltop): use opencode2 in WSL (#42199) --- packages/cli/script/build.ts | 40 ++- packages/desktop/scripts/dev.ts | 3 + packages/desktop/src/main/background-cli.ts | 30 +- packages/desktop/src/main/cli-version.ts | 6 + packages/desktop/src/main/index.ts | 83 +++-- packages/desktop/src/main/wsl/ipc.ts | 17 +- packages/desktop/src/main/wsl/local.ts | 38 +++ packages/desktop/src/main/wsl/policy.ts | 33 -- packages/desktop/src/main/wsl/runtime.ts | 49 ++- packages/desktop/src/main/wsl/servers.test.ts | 263 ++++++--------- packages/desktop/src/main/wsl/servers.ts | 315 ++++++------------ packages/desktop/src/main/wsl/sidecar.ts | 27 +- packages/desktop/src/main/wsl/startup.ts | 37 -- 13 files changed, 411 insertions(+), 530 deletions(-) create mode 100644 packages/desktop/src/main/cli-version.ts create mode 100644 packages/desktop/src/main/wsl/local.ts delete mode 100644 packages/desktop/src/main/wsl/policy.ts delete mode 100644 packages/desktop/src/main/wsl/startup.ts diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index af764ab7f6..39a09bde86 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -22,6 +22,7 @@ await rm(outdir, { recursive: true, force: true }) const singleFlag = process.argv.includes("--single") const baselineFlag = process.argv.includes("--baseline") +const requestedTarget = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length) const skipInstall = process.argv.includes("--skip-install") const skipWebUi = process.argv.includes("--skip-web-ui") const solidPlugin = createSolidTransformPlugin() @@ -46,13 +47,16 @@ const allTargets: { { os: "win32", arch: "x64", avx2: false }, ] -const targets = singleFlag - ? allTargets.filter((item) => { - if (item.os !== process.platform || item.arch !== process.arch) return false - if (item.avx2 === false) return baselineFlag - return item.abi === undefined - }) - : allTargets +const targets = requestedTarget !== undefined + ? allTargets.filter((item) => targetName(item) === requestedTarget) + : singleFlag + ? allTargets.filter((item) => { + if (item.os !== process.platform || item.arch !== process.arch) return false + if (item.avx2 === false) return baselineFlag + return item.abi === undefined + }) + : allTargets +if (!targets.length) throw new Error(`Unknown build target: ${requestedTarget}`) if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}` const appArchive = await buildAppArchive(Script.channel, { skipBuild: skipWebUi }) @@ -81,15 +85,7 @@ for (const item of targets) { })) }, } - const target = [ - binary, - item.os === "win32" ? "windows" : item.os, - item.arch, - item.avx2 === false ? "baseline" : undefined, - item.abi, - ] - .filter(Boolean) - .join("-") + const target = targetName(item) const name = target.replace(binary, "cli") console.log(`building ${name}`) const result = await Bun.build({ @@ -143,3 +139,15 @@ for (const item of targets) { ), ) } + +function targetName(item: (typeof allTargets)[number]) { + return [ + binary, + item.os === "win32" ? "windows" : item.os, + item.arch, + item.avx2 === false ? "baseline" : undefined, + item.abi, + ] + .filter(Boolean) + .join("-") +} diff --git a/packages/desktop/scripts/dev.ts b/packages/desktop/scripts/dev.ts index beca28dfb6..398894a955 100644 --- a/packages/desktop/scripts/dev.ts +++ b/packages/desktop/scripts/dev.ts @@ -40,6 +40,9 @@ async function prepareServer(source: ServerSource) { if (source.type === "download") return downloadCliToResources(source.version, windowsify("resources/opencode-cli-dev")) process.env.OPENCODE_DESKTOP_CLI_DEV = join(import.meta.dirname, "../../cli") + if (process.platform !== "win32") return + process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD = join(import.meta.dirname, "../../cli/script/build.ts") + process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT = join(import.meta.dirname, "../resources/opencode-cli-wsl") } async function startDesktop(args: string[]) { diff --git a/packages/desktop/src/main/background-cli.ts b/packages/desktop/src/main/background-cli.ts index 65ff6f6a5b..bfb0e0eaa6 100644 --- a/packages/desktop/src/main/background-cli.ts +++ b/packages/desktop/src/main/background-cli.ts @@ -6,6 +6,7 @@ import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" import { promisify } from "node:util" import { app } from "electron" +import { parseCliVersion } from "./cli-version" const execFileAsync = promisify(execFile) const root = dirname(fileURLToPath(import.meta.url)) @@ -18,10 +19,18 @@ type Logger = { export async function startBackgroundCli(logger: Logger) { const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1" const development = !app.isPackaged && process.env.OPENCODE_DESKTOP_CLI_DEV + const developmentVersion = process.env.OPENCODE_VERSION ?? "local" const cli = development ? { - version: "local", - command: ["bun", "run", "--cwd", development, "dev", "--"], + version: developmentVersion, + command: [ + "bun", + "run", + "--cwd", + development, + `--define=OPENCODE_VERSION=${JSON.stringify(developmentVersion)}`, + "src/index.ts", + ], binary: undefined, } : await resolveBundledCli(isolated, logger) @@ -46,6 +55,14 @@ export async function startBackgroundCli(logger: Logger) { url: service.url, username: service.auth.username, password: service.auth.password, + version: cli.version, + wslBuild: + app.isPackaged || !process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD || !process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT + ? undefined + : { + script: process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD, + output: process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT, + }, } } @@ -54,7 +71,7 @@ async function resolveBundledCli(isolated: boolean, logger: Logger) { ? join(process.resourcesPath, executableName()) : join(root, "../../resources", isolated ? developmentExecutableName() : executableName()) logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged }) - const version = parseVersion(await run(bundled, ["--version"], logger)) + const version = parseCliVersion(await run(bundled, ["--version"], logger)) const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled return { version, binary, command: [binary] } } @@ -115,13 +132,6 @@ async function run(binary: string, args: string[], logger: Logger) { ) } -function parseVersion(output: string) { - const marker = output.lastIndexOf(" v") - const version = marker === -1 ? output : output.slice(marker + 2) - if (!version) throw new Error("V2 CLI did not provide a version") - return version -} - function endpoint(url: string | undefined) { if (!url || !URL.canParse(url)) return {} const parsed = new URL(url) diff --git a/packages/desktop/src/main/cli-version.ts b/packages/desktop/src/main/cli-version.ts new file mode 100644 index 0000000000..4e70636d79 --- /dev/null +++ b/packages/desktop/src/main/cli-version.ts @@ -0,0 +1,6 @@ +export function parseCliVersion(output: string) { + const marker = output.lastIndexOf(" v") + const version = marker === -1 ? output : output.slice(marker + 2) + if (!version) throw new Error("V2 CLI did not provide a version") + return version +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 91d4035446..1176ec3d36 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -36,9 +36,7 @@ import { setDockIcon, restoreMainWindows, } from "./windows" -import { createWslServersController } from "./wsl/servers" import { registerWslIpcHandlers } from "./wsl/ipc" -import { spawnWslSidecar } from "./wsl/sidecar" import { migrate } from "./migrate" import { cleanupStoreFiles } from "./store-cleanup" import { startBackgroundCli } from "./background-cli" @@ -134,25 +132,10 @@ const main = Effect.gen(function* () { logger = initLogging() initCrashReporter() - const wslServers = createWslServersController( - VERSION, - async (distro) => { - logger.log("spawning wsl sidecar", { distro }) - return spawnWslSidecar(distro, { - onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }), - }) - }, - { - logger: { - log: (message, meta) => logger.log(message, meta), - error: (message, meta) => logger.error(message, meta), - }, - }, - ) - const stopSidecars = async () => wslServers.stopAll() + let stopWslServers = async () => {} const relaunch = () => { setAppQuitting() - void stopSidecars().finally(() => { + void stopWslServers().finally(() => { app.relaunch() app.quit() }) @@ -205,12 +188,12 @@ const main = Effect.gen(function* () { app.on("before-quit", () => { setAppQuitting() - void stopSidecars() + void stopWslServers() }) app.on("will-quit", () => { setAppQuitting() - void stopSidecars() + void stopWslServers() }) app.on("child-process-gone", (_event, details) => { @@ -228,7 +211,7 @@ const main = Effect.gen(function* () { for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { setAppQuitting() - void stopSidecars().finally(() => app.quit()) + void stopWslServers().finally(() => app.quit()) }) } @@ -253,7 +236,7 @@ const main = Effect.gen(function* () { app.setAsDefaultProtocolClient("opencode") registerRendererProtocol() setDockIcon() - const updater = setupAutoUpdater(stopSidecars) + const updater = setupAutoUpdater(() => stopWslServers()) const menuDeps = { trigger: (id: string) => { const win = getLastFocusedWindow() @@ -293,7 +276,6 @@ const main = Effect.gen(function* () { }, }) registerUpdaterIpc(updater) - registerWslIpcHandlers(wslServers) void updater.start() const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000) updateTimer.unref() @@ -311,16 +293,14 @@ const main = Effect.gen(function* () { useEnvProxy() logger.log("starting v2 background service") - const sidecar = yield* Effect.promise(() => startBackgroundCli(logger)) - yield* Deferred.succeed(serverReady, { - url: sidecar.url, - username: sidecar.username, - password: sidecar.password, - }) + const background = yield* Effect.promise(() => startBackgroundCli(logger)) + stopWslServers = yield* Effect.promise(() => startWslServers(background)) - if (process.platform === "win32") { - void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) - } + yield* Deferred.succeed(serverReady, { + url: background.url, + username: background.username, + password: background.password, + }) logger.log("loading task finished") }).pipe(forwardInitializationFailure(serverReady), Effect.forkChild) @@ -340,4 +320,41 @@ const main = Effect.gen(function* () { if (windows.length) createMenu(menuDeps) }) +async function startWslServers(cli: { version: string; wslBuild?: { script: string; output: string } }) { + if (process.platform !== "win32") { + registerWslIpcHandlers() + return async () => {} + } + + const { createWslServersController } = await import("./wsl/servers") + const { spawnWslSidecar } = await import("./wsl/sidecar") + const local = cli.wslBuild + const controller = createWslServersController({ + cli: { version: cli.version }, + installCli: local + ? async (distro) => { + const { buildLocalWslCli } = await import("./wsl/local") + const { installWslCli } = await import("./wsl/runtime") + await installWslCli(distro, { + version: cli.version, + binary: await buildLocalWslCli({ ...local, version: cli.version }), + }) + } + : undefined, + spawnSidecar: async (distro) => { + logger.log("spawning wsl sidecar", { distro }) + return spawnWslSidecar(distro, { + onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }), + }) + }, + logger: { + log: (message, meta) => logger.log(message, meta), + error: (message, meta) => logger.error(message, meta), + }, + }) + registerWslIpcHandlers(controller) + controller.startConfiguredServers() + return async () => controller.stopServers() +} + Effect.runFork(main) diff --git a/packages/desktop/src/main/wsl/ipc.ts b/packages/desktop/src/main/wsl/ipc.ts index 839efad585..ac7e1ac581 100644 --- a/packages/desktop/src/main/wsl/ipc.ts +++ b/packages/desktop/src/main/wsl/ipc.ts @@ -1,12 +1,11 @@ import { app, ipcMain } from "electron" import type { IpcMainInvokeEvent } from "electron" import type { WslServersController } from "./servers" -import { requireWslIpcString, requireWslIpcStrings } from "./policy" import type { WslServersState } from "../../preload/types" import { nativeT } from "../native-translations" -export function registerWslIpcHandlers(controller: WslServersController) { - if (process.platform !== "win32") { +export function registerWslIpcHandlers(controller?: WslServersController) { + if (!controller) { registerUnavailableWslIpcHandlers() return } @@ -67,6 +66,18 @@ export function registerWslIpcHandlers(controller: WslServersController) { ) } +function requireWslIpcString(name: string, value: unknown) { + if (typeof value === "string" && value.length > 0) return value + throw new Error(`Invalid ${name}`) +} + +function requireWslIpcStrings(name: string, value: unknown) { + if (!Array.isArray(value)) throw new Error(`Invalid ${name}`) + const values = value.map((item) => requireWslIpcString(name, item)) + if (values.length) return values + throw new Error(`Invalid ${name}`) +} + function registerUnavailableWslIpcHandlers() { const unavailable = () => { throw new Error(nativeT("desktop.wsl.error.windowsOnly")) diff --git a/packages/desktop/src/main/wsl/local.ts b/packages/desktop/src/main/wsl/local.ts new file mode 100644 index 0000000000..e1ec23eb0d --- /dev/null +++ b/packages/desktop/src/main/wsl/local.ts @@ -0,0 +1,38 @@ +import { execFile } from "node:child_process" +import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { promisify } from "node:util" + +const execFileAsync = promisify(execFile) + +export async function buildLocalWslCli(input: { version: string; script: string; output: string }) { + const directory = await mkdtemp(join(tmpdir(), "opencode-wsl-cli-")) + const root = join(dirname(input.script), "../../..") + const packageManager = (JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { packageManager: string }) + .packageManager + const target = `linux-${process.arch}` + try { + await execFileAsync("bunx", [packageManager, "install", "--os=*", "--cpu=*", "--frozen-lockfile"], { + cwd: root, + env: process.env, + windowsHide: true, + }) + await execFileAsync( + "bunx", + [ + packageManager, + input.script, + `--target=opencode2-${target}`, + "--skip-install", + "--skip-web-ui", + `--outdir=${directory}`, + ], + { cwd: root, env: { ...process.env, OPENCODE_VERSION: input.version }, windowsHide: true }, + ) + await copyFile(join(directory, `cli-${target}`, "bin", "opencode2"), input.output) + return input.output + } finally { + await rm(directory, { recursive: true, force: true }) + } +} diff --git a/packages/desktop/src/main/wsl/policy.ts b/packages/desktop/src/main/wsl/policy.ts deleted file mode 100644 index 4ceee26c6b..0000000000 --- a/packages/desktop/src/main/wsl/policy.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { WslDistroProbe, WslOpencodeCheck, WslServerItem } from "../../preload/types" - -export function wslServerIdToRestart(servers: WslServerItem[], distro: string) { - return servers.find((item) => item.config.distro === distro)?.config.id -} - -export function clearWslDistroState( - distroProbes: Record, - opencodeChecks: Record, - distro: string, -) { - const nextDistroProbes = { ...distroProbes } - const nextOpencodeChecks = { ...opencodeChecks } - delete nextDistroProbes[distro] - delete nextOpencodeChecks[distro] - return { distroProbes: nextDistroProbes, opencodeChecks: nextOpencodeChecks } -} - -export function wslTerminalArgs(distro?: string | null) { - return ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])] -} - -export function requireWslIpcString(name: string, value: unknown) { - if (typeof value === "string" && value.length > 0) return value - throw new Error(`Invalid ${name}`) -} - -export function requireWslIpcStrings(name: string, value: unknown) { - if (!Array.isArray(value)) throw new Error(`Invalid ${name}`) - const values = value.map((item) => requireWslIpcString(name, item)) - if (values.length > 0) return values - throw new Error(`Invalid ${name}`) -} diff --git a/packages/desktop/src/main/wsl/runtime.ts b/packages/desktop/src/main/wsl/runtime.ts index ffbc91fa30..95b24711c7 100644 --- a/packages/desktop/src/main/wsl/runtime.ts +++ b/packages/desktop/src/main/wsl/runtime.ts @@ -3,7 +3,7 @@ import { existsSync } from "node:fs" import { join } from "node:path" import * as pty from "@lydell/node-pty" import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types" -import { wslTerminalArgs } from "./policy" +import { parseCliVersion } from "../cli-version" import { nativeT } from "../native-translations" export type WslCommandLine = { @@ -31,6 +31,11 @@ export type RunWslOptions = { timeoutMs?: number } +export type WslCliBuild = { + version: string + binary?: string +} + const DEFAULT_WSL_TIMEOUT_MS = 20_000 const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000 @@ -252,28 +257,34 @@ export async function installWslRuntimeElevated(opts?: RunWslOptions) { "$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru", "if ($null -ne $process.ExitCode) { exit $process.ExitCode }", ].join("; ") - return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS)) + const result = await runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS)) + requireSuccess(result, nativeT("desktop.wsl.error.installWsl")) } -export async function installWslDistro(name: string, opts?: RunWslOptions) { - return runInteractiveCommand( +export async function installWslDistro(distro: string, opts?: RunWslOptions) { + const result = await runInteractiveCommand( resolveSystem32Command("wsl.exe"), - ["--install", "-d", name, "--web-download", "--no-launch"], + ["--install", "-d", distro, "--web-download", "--no-launch"], withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), DEFAULT_WSL_INSTALL_TIMEOUT_MS, ) + requireSuccess(result, nativeT("desktop.wsl.error.installDistro", { distro })) } -export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) { - return runInteractiveCommand( +export async function installWslCli(distro: string, cli: WslCliBuild, opts?: RunWslOptions) { + const result = await runInteractiveCommand( resolveSystem32Command("wsl.exe"), - wslArgs( - ["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`], - distro, - ), + wslArgs(["bash", "-lc", wslCliInstallCommand(cli)], distro), withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), DEFAULT_WSL_INSTALL_TIMEOUT_MS, ) + requireSuccess(result, nativeT("desktop.wsl.error.installOpencode")) +} + +export function wslCliInstallCommand(cli: WslCliBuild) { + const installer = "curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s --" + if (!cli.binary) return `${installer} --version ${shellEscape(cli.version)}` + return `${installer} --binary "$(wslpath -a ${shellEscape(cli.binary)})"` } export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise { @@ -307,11 +318,11 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis } } -export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) { +export async function resolveWslCli(distro: string, opts?: RunWslOptions) { return firstLine( ( await runWslSh( - 'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi', + 'if [ -x "$HOME/.opencode/bin/opencode2" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode2"; fi', distro, opts, ) @@ -319,14 +330,15 @@ export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) { ) } -export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) { +export async function readWslCliVersion(command: string, distro: string, opts?: RunWslOptions) { const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts) - return firstLine(result.stdout) + const output = firstLine(result.stdout) + return output ? parseCliVersion(output) : null } export function openWslTerminal(distro?: string | null) { return new Promise((resolve, reject) => { - const child = spawn("cmd.exe", wslTerminalArgs(distro), { + const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])], { detached: true, stdio: "ignore", windowsHide: true, @@ -386,6 +398,11 @@ export function summarize(value: string) { .join("\n") } +function requireSuccess(result: WslCommandResult, fallback: string) { + if (result.code === 0) return + throw new Error(summarize(result.stderr || result.stdout) || fallback) +} + export function shellEscape(value: string) { return `'${value.replace(/'/g, `'"'"'`)}'` } diff --git a/packages/desktop/src/main/wsl/servers.test.ts b/packages/desktop/src/main/wsl/servers.test.ts index 35b688d3c8..ba6c6a01bf 100644 --- a/packages/desktop/src/main/wsl/servers.test.ts +++ b/packages/desktop/src/main/wsl/servers.test.ts @@ -1,153 +1,80 @@ import { expect, test } from "bun:test" -import { - clearWslDistroState, - requireWslIpcString, - requireWslIpcStrings, - wslServerIdToRestart, - wslTerminalArgs, -} from "./policy" -import { - expectOpencodeVersion, - pendingRestartAfterWslInstall, - pollWslHealth, - wslServerIdsToStartOnInitialize, -} from "./startup" -import { createWslServersController, type WslServerConfig } from "./servers" +import type { WslServerConfig } from "../../preload/types" +import { wslCliInstallCommand } from "./runtime" +import { createWslServersController } from "./servers" + +type ControllerOptions = Parameters[0] let persistedServers: WslServerConfig[] = [] -let releaseOpencodeResolve: (() => void) | undefined -test("starts every configured WSL server on initialization", () => { - expect( - wslServerIdsToStartOnInitialize([ - { id: "wsl:Debian", distro: "Debian" }, - { id: "wsl:Ubuntu-24.04", distro: "Ubuntu-24.04" }, - ]), - ).toEqual(["wsl:Debian", "wsl:Ubuntu-24.04"]) -}) - -test("rejects an update that did not install the desktop version", () => { - expect(() => expectOpencodeVersion("1.16.2", "1.16.2")).not.toThrow() - expect(() => expectOpencodeVersion("1.14.35", "1.16.2")).toThrow( - "OpenCode update finished but Debian still reports 1.14.35; expected 1.16.2", +test("passes a local CLI path directly to the V2 installer", () => { + expect(wslCliInstallCommand({ version: "local", binary: "C:\\build\\opencode2" })).toBe( + `curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s -- --binary "$(wslpath -a 'C:\\build\\opencode2')"`, ) }) -test("restarts an existing distro server after updating OpenCode", () => { - expect( - wslServerIdToRestart( - [ - { - config: { id: "wsl:Debian", distro: "Debian" }, - runtime: { kind: "ready", url: "", username: null, password: null }, - }, - ], - "Debian", - ), - ).toBe("wsl:Debian") - expect(wslServerIdToRestart([], "Debian")).toBeUndefined() -}) - -test("clears cached distro probes when removing a WSL server", () => { - expect( - clearWslDistroState( - { Debian: { name: "Debian", canExecute: true, hasBash: true, hasCurl: true, error: null } }, - { - Debian: { - distro: "Debian", - resolvedPath: "/home/luke/.opencode/bin/opencode", - version: "1.16.2", - expectedVersion: "1.16.2", - matchesDesktop: true, - error: null, - }, - }, - "Debian", - ), - ).toEqual({ distroProbes: {}, opencodeChecks: {} }) -}) - -test("opens terminals for distro names containing spaces", () => { - expect(wslTerminalArgs("Ubuntu Preview")).toEqual(["/c", "start", "", "wsl", "-d", "Ubuntu Preview"]) -}) - -test("stops health polling when sidecar startup settles", async () => { - const abort = new AbortController() - let checks = 0 - const polling = pollWslHealth( - async () => { - checks++ - return false - }, - abort.signal, - 1, - ) - - await new Promise((resolve) => setTimeout(resolve, 5)) - abort.abort() - await polling - const settled = checks - await new Promise((resolve) => setTimeout(resolve, 5)) - expect(checks).toBe(settled) -}) - -test("validates WSL IPC identifiers at the module boundary", () => { - expect(requireWslIpcString("distro", "Debian")).toBe("Debian") - expect(requireWslIpcStrings("distro", ["Debian", "Ubuntu"])).toEqual(["Debian", "Ubuntu"]) - expect(() => requireWslIpcString("distro", "")).toThrow("Invalid distro") - expect(() => requireWslIpcString("server id", undefined)).toThrow("Invalid server id") - expect(() => requireWslIpcStrings("distro", [])).toThrow("Invalid distro") -}) - -test("derives a required Windows restart from the post-install runtime probe", () => { - expect(pendingRestartAfterWslInstall({ available: false, version: null, error: "WSL unavailable" })).toBe(true) - expect(pendingRestartAfterWslInstall({ available: true, version: "WSL version: 2.6.1", error: null })).toBe(false) -}) - -test("ignores stale background OpenCode checks after removing a WSL server", async () => { +test("installs and verifies the bundled CLI version", async () => { persistedServers = [] - releaseOpencodeResolve = undefined + const installs: string[][] = [] const controller = createWslServersController( - "1.16.2", - async () => ({ - listener: { - stop: () => undefined, - onExit: () => undefined, + testControllerOptions({ + installCli: async (distro, cli) => { + installs.push([distro, cli.version]) }, - url: "http://127.0.0.1:4096", - username: "opencode", - password: "secret", + resolveCli: async () => "/home/me/.opencode/bin/opencode2", }), - testControllerOptions(), ) - await controller.addServer("Debian") - await waitFor(() => !!releaseOpencodeResolve) - await controller.removeServer("wsl:Debian") - releaseOpencodeResolve?.() - await new Promise((resolve) => setTimeout(resolve, 0)) + await controller.installOpencode("Debian") - expect(controller.getState().servers).toEqual([]) - expect(controller.getState().opencodeChecks).toEqual({}) + expect(installs).toEqual([["Debian", "0.0.0-next-16365"]]) + expect(controller.getState().opencodeChecks.Debian?.matchesDesktop).toBe(true) }) -test("ignores stale startup OpenCode checks after removing a WSL server", async () => { - persistedServers = [{ id: "wsl:Debian", distro: "Debian" }] - releaseOpencodeResolve = undefined +test("rejects a WSL CLI version that differs from the bundled version", async () => { + persistedServers = [] const controller = createWslServersController( - "1.16.2", - async () => new Promise(() => undefined), - testControllerOptions(), + testControllerOptions({ + installCli: async () => undefined, + resolveCli: async () => "/home/me/.opencode/bin/opencode2", + readCliVersion: async () => "0.0.0-next-older", + }), ) - await controller.initialize() - await waitFor(() => !!releaseOpencodeResolve) - await controller.removeServer("wsl:Debian") - releaseOpencodeResolve?.() - await new Promise((resolve) => setTimeout(resolve, 0)) + await expect(controller.installOpencode("Debian")).rejects.toThrow( + "OpenCode update finished but Debian still reports 0.0.0-next-older; expected 0.0.0-next-16365", + ) +}) - expect(controller.getState().servers).toEqual([]) - expect(controller.getState().opencodeChecks).toEqual({}) +test("stops a running WSL server before replacing its CLI", async () => { + persistedServers = [{ id: "wsl:Debian", distro: "Debian" }] + const events: string[] = [] + const controller = createWslServersController( + testControllerOptions({ + spawnSidecar: async () => { + events.push("start") + return { + stop: async () => { + events.push("stop") + }, + onExit: () => undefined, + url: "http://127.0.0.1:4096", + username: "opencode", + password: "secret", + } + }, + installCli: async () => { + events.push("install") + }, + }), + ) + controller.startConfiguredServers() + await waitFor(() => controller.getState().servers[0]?.runtime.kind === "ready") + + await controller.installOpencode("Debian") + + expect(events).toEqual(["start", "stop", "install", "start"]) + await controller.stopServers() }) test("probes addable distros in parallel before checking OpenCode", async () => { @@ -155,18 +82,20 @@ test("probes addable distros in parallel before checking OpenCode", async () => const started: string[] = [] const release = new Map void>() const opencode: string[] = [] - const controller = createWslServersController("1.16.2", async () => new Promise(() => undefined), { - ...testControllerOptions(), - probeDistro: async (distro) => { - started.push(distro) - await new Promise((resolve) => release.set(distro, resolve)) - return { name: distro, canExecute: true, hasBash: true, hasCurl: true, error: null } - }, - resolveOpencode: async (distro) => { - opencode.push(distro) - return "/home/me/.opencode/bin/opencode" - }, - }) + const controller = createWslServersController( + testControllerOptions({ + spawnSidecar: pendingSidecar, + probeDistro: async (distro) => { + started.push(distro) + await new Promise((resolve) => release.set(distro, resolve)) + return { name: distro, canExecute: true, hasBash: true, hasCurl: true, error: null } + }, + resolveCli: async (distro) => { + opencode.push(distro) + return "/home/me/.opencode/bin/opencode2" + }, + }), + ) const task = controller.probeAddable(["Debian", "Ubuntu"]) await waitFor(() => started.length === 2) @@ -184,20 +113,22 @@ test("probes addable distros in parallel before checking OpenCode", async () => test("does not check OpenCode in addable distros that cannot execute commands", async () => { persistedServers = [] const opencode: string[] = [] - const controller = createWslServersController("1.16.2", async () => new Promise(() => undefined), { - ...testControllerOptions(), - probeDistro: async (distro) => ({ - name: distro, - canExecute: distro === "Debian", - hasBash: distro === "Debian", - hasCurl: distro === "Debian", - error: distro === "Debian" ? null : "Open Ubuntu once to finish setup", + const controller = createWslServersController( + testControllerOptions({ + spawnSidecar: pendingSidecar, + probeDistro: async (distro) => ({ + name: distro, + canExecute: distro === "Debian", + hasBash: distro === "Debian", + hasCurl: distro === "Debian", + error: distro === "Debian" ? null : "Open Ubuntu once to finish setup", + }), + resolveCli: async (distro) => { + opencode.push(distro) + return "/home/me/.opencode/bin/opencode2" + }, }), - resolveOpencode: async (distro) => { - opencode.push(distro) - return "/home/me/.opencode/bin/opencode" - }, - }) + ) await controller.probeAddable(["Debian", "Ubuntu"]) @@ -214,18 +145,24 @@ async function waitFor(check: () => boolean) { throw new Error("Timed out waiting for condition") } -function testControllerOptions() { +function testControllerOptions(overrides: Partial = {}): ControllerOptions { return { + cli: { version: "0.0.0-next-16365" }, + spawnSidecar: async () => ({ + stop: async () => undefined, + onExit: () => undefined, + url: "http://127.0.0.1:4096", + username: "opencode", + password: "secret", + }), readServers: () => persistedServers, writeServers: (servers: WslServerConfig[]) => { persistedServers = servers }, - readCommandVersion: async () => "1.16.2", - resolveOpencode: async () => { - await new Promise((resolve) => { - releaseOpencodeResolve = resolve - }) - return "/home/me/.opencode/bin/opencode" - }, + readCliVersion: async () => "0.0.0-next-16365", + resolveCli: async () => "/home/me/.opencode/bin/opencode2", + ...overrides, } } + +const pendingSidecar = async () => new Promise(() => undefined) diff --git a/packages/desktop/src/main/wsl/servers.ts b/packages/desktop/src/main/wsl/servers.ts index 749f51431f..fddd6dd673 100644 --- a/packages/desktop/src/main/wsl/servers.ts +++ b/packages/desktop/src/main/wsl/servers.ts @@ -1,10 +1,7 @@ import type { WslDistroProbe, - WslInstalledDistro, WslJob, - WslOnlineDistro, WslOpencodeCheck, - WslRuntimeCheck, WslServerConfig, WslServerItem, WslServerRuntime, @@ -13,25 +10,24 @@ import type { } from "../../preload/types" import { WSL_SERVERS_KEY } from "../store-keys" import { getStore } from "../store" -import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup" -import { clearWslDistroState, wslServerIdToRestart } from "./policy" import { nativeT } from "../native-translations" import { + installWslCli, installWslDistro, - installWslOpencode, installWslRuntimeElevated, listInstalledWslDistros, listOnlineWslDistros, openWslTerminal, probeWslDistro, probeWslRuntime, - readWslCommandVersion, - resolveWslOpencode, - summarize, + readWslCliVersion, + resolveWslCli, + type WslCliBuild, } from "./runtime" type RunningSidecar = { - listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void } + stop: () => Promise + onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void url: string username: string | null password: string @@ -45,12 +41,15 @@ type ControllerLogger = { } type WslServersControllerOptions = { + cli: WslCliBuild + spawnSidecar: SpawnSidecar logger?: ControllerLogger readServers?: () => WslServerConfig[] writeServers?: (servers: WslServerConfig[]) => void + installCli?: typeof installWslCli probeDistro?: typeof probeWslDistro - resolveOpencode?: typeof resolveWslOpencode - readCommandVersion?: typeof readWslCommandVersion + resolveCli?: typeof resolveWslCli + readCliVersion?: typeof readWslCliVersion } export type WslServersController = ReturnType @@ -59,20 +58,13 @@ export function wslServerIdForDistro(distro: string) { return `wsl:${distro}` } -export function createWslServersController( - appVersion: string, - spawnSidecar: SpawnSidecar, - options?: WslServersControllerOptions, -) { +export function createWslServersController(options: WslServersControllerOptions) { let state: WslServersState = initialState() const listeners = new Set<(event: WslServersEvent) => void>() const sidecars = new Map() - const startAttempts = new Map() - let jobAbort: AbortController | undefined - const logger = options?.logger - const readServers = options?.readServers ?? readPersistedServers - const writeServers = options?.writeServers ?? writePersistedServers - const probeDistro = options?.probeDistro ?? probeWslDistro + const readServers = options.readServers ?? readPersistedServers + const writeServers = options.writeServers ?? writePersistedServers + const probeDistro = options.probeDistro ?? probeWslDistro const emit = () => { for (const listener of listeners) listener({ type: "state", state }) @@ -83,29 +75,11 @@ export function createWslServersController( emit() } - const persistServers = (servers: WslServerConfig[]) => { - writeServers(servers) - } - const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => { const next = state.servers.map((item) => (item.config.id === id ? update(item) : item)) setState({ servers: next }) } - const beginJob = (job: WslJob): AbortController => { - jobAbort?.abort() - const abort = new AbortController() - jobAbort = abort - setState({ job }) - return abort - } - - const endJob = (abort: AbortController) => { - if (jobAbort !== abort) return - jobAbort = undefined - setState({ job: null }) - } - const refreshFromStore = () => { const persisted = readServers() const items: WslServerItem[] = persisted.map((config) => { @@ -122,7 +96,7 @@ export function createWslServersController( updateServer(id, (item) => ({ ...item, runtime })) } - const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => { + const setCliCheck = (distro: string, check: WslOpencodeCheck) => { setState({ opencodeChecks: { ...state.opencodeChecks, @@ -131,24 +105,24 @@ export function createWslServersController( }) } - const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => { - const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts) - const version = resolved - ? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts) - : null - return opencodeCheck(distro, resolved, version, appVersion) + const inspectCli = async (distro: string) => { + const resolved = await (options.resolveCli ?? resolveWslCli)(distro) + const version = resolved ? await (options.readCliVersion ?? readWslCliVersion)(resolved, distro) : null + return cliCheck(distro, resolved, version, options.cli.version) } - const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => { - setOpencodeCheck(distro, await checkOpencode(distro, opts)) + const refreshCliCheck = async (distro: string) => { + const check = await inspectCli(distro) + setCliCheck(distro, check) + return check } - const probeAddableDistros = async (distros: string[], opts?: { signal?: AbortSignal }) => { + const probeAddableDistros = async (distros: string[]) => { const unique = [...new Set(distros)] const distroProbes = await Promise.all( unique .filter((distro) => !state.distroProbes[distro]) - .map(async (distro) => [distro, await probeDistro(distro, opts)] as const), + .map(async (distro) => [distro, await probeDistro(distro)] as const), ) if (distroProbes.length) { setState({ distroProbes: { ...state.distroProbes, ...Object.fromEntries(distroProbes) } }) @@ -158,86 +132,37 @@ export function createWslServersController( unique .filter((distro) => distroProbeReady(state.distroProbes[distro])) .filter((distro) => !state.opencodeChecks[distro]) - .map(async (distro) => [distro, await checkOpencode(distro, opts)] as const), + .map(async (distro) => [distro, await inspectCli(distro)] as const), ) if (opencodeChecks.length) { setState({ opencodeChecks: { ...state.opencodeChecks, ...Object.fromEntries(opencodeChecks) } }) } } - const hasServer = (id: string, distro: string) => { - return state.servers.some((item) => item.config.id === id && item.config.distro === distro) + const refreshCliCheckSafely = (id: string, distro: string) => { + return refreshCliCheck(distro).catch((error) => { + const message = error instanceof Error ? error.message : String(error) + options.logger?.error("wsl CLI check failed", { id, distro, message }) + }) } - const refreshOpencodeCheckBackground = (id: string, distro: string) => { - void checkOpencode(distro) - .then((check) => { - if (!hasServer(id, distro)) return - setOpencodeCheck(distro, check) - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error) - logger?.error("wsl opencode check failed", { id, distro, message }) - }) + const refreshCliChecks = async () => { + await Promise.all(state.servers.map((item) => refreshCliCheckSafely(item.config.id, item.config.distro))) } - const refreshOpencodeChecks = async () => { - await Promise.all( - state.servers.map((item) => - checkOpencode(item.config.distro) - .then((check) => { - if (!hasServer(item.config.id, item.config.distro)) return - setOpencodeCheck(item.config.distro, check) - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error) - logger?.error("wsl opencode check failed", { - id: item.config.id, - distro: item.config.distro, - message, - }) - }), - ), - ) - } - - const refreshDistroLists = async (opts: { signal?: AbortSignal }) => { - const [installed, online] = await Promise.all([listInstalledWslDistros(opts), listOnlineWslDistros(opts)]) + const refreshDistroLists = async () => { + const [installed, online] = await Promise.all([listInstalledWslDistros(), listOnlineWslDistros()]) return { installed, online } } - const nextStartAttempt = (id: string) => { - const next = (startAttempts.get(id) ?? 0) + 1 - startAttempts.set(id, next) - return next - } - - const invalidateStartAttempt = (id: string) => { - startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1) - } - - const isCurrentStartAttempt = (id: string, attempt: number) => { - return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id) - } - const startServer = async (id: string) => { const item = state.servers.find((x) => x.config.id === id) if (!item) return - const attempt = nextStartAttempt(id) - await stopServerInternal(id) - if (!isCurrentStartAttempt(id, attempt)) return + await stopServer(id) setRuntime(id, { kind: "starting" }) - logger?.log("wsl sidecar starting", { id, distro: item.config.distro }) + options.logger?.log("wsl sidecar starting", { id, distro: item.config.distro }) try { - const sidecar = await spawnSidecar(item.config.distro) - if (!isCurrentStartAttempt(id, attempt)) { - try { - sidecar.listener.stop() - } catch { - // ignore stop errors for stale sidecars - } - return - } + const sidecar = await options.spawnSidecar(item.config.distro) sidecars.set(id, sidecar) setRuntime(id, { kind: "ready", @@ -245,51 +170,36 @@ export function createWslServersController( username: sidecar.username, password: sidecar.password, }) - sidecar.listener.onExit((code, signal) => { + sidecar.onExit((code, signal) => { if (sidecars.get(id) !== sidecar) return sidecars.delete(id) const message = startupFailure(code, signal) setRuntime(id, { kind: "failed", message }) - logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal }) + options.logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal }) }) - refreshOpencodeCheckBackground(id, item.config.distro) - logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url }) + void refreshCliCheckSafely(id, item.config.distro) + options.logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url }) } catch (error) { const message = error instanceof Error ? error.message : String(error) - if (!isCurrentStartAttempt(id, attempt)) return setRuntime(id, { kind: "failed", message }) - // Without this, an Ubuntu-style silent failure leaves no trace in - // main.log — the controller captures the message in its state but - // nothing surfaces unless the user opens the WSL servers dialog. - logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message }) + options.logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message }) } } - const stopServerInternal = async (id: string) => { + const stopServer = async (id: string) => { const existing = sidecars.get(id) if (!existing) return sidecars.delete(id) - try { - existing.listener.stop() - } catch { - // ignore stop errors - } + await existing.stop() + setRuntime(id, { kind: "stopped" }) } - const runJob = async (job: WslJob, runner: (abort: AbortController) => Promise) => { - const abort = beginJob(job) + const runJob = async (job: WslJob, runner: () => Promise) => { + setState({ job }) try { - const value = await runner(abort) - endJob(abort) - return value - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - endJob(abort) - return undefined - } - const err = error instanceof Error ? error : new Error(String(error)) - endJob(abort) - throw err + return await runner() + } finally { + setState({ job: null }) } } @@ -302,15 +212,15 @@ export function createWslServersController( return () => listeners.delete(listener) }, - async initialize() { + startConfiguredServers() { refreshFromStore() - void refreshOpencodeChecks() - for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id) + void refreshCliChecks() + state.servers.forEach((item) => void startServer(item.config.id)) }, async probeRuntime() { - await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => { - const runtime = await probeWslRuntime({ signal: abort.signal }) + await runJob({ kind: "runtime", startedAt: Date.now() }, async () => { + const runtime = await probeWslRuntime() setState({ runtime, pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false, @@ -319,62 +229,48 @@ export function createWslServersController( }, async refreshDistros() { - await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => { - setState(await refreshDistroLists({ signal: abort.signal })) + await runJob({ kind: "distros", startedAt: Date.now() }, async () => { + setState(await refreshDistroLists()) }) }, async installWsl() { - await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => { - const result = await installWslRuntimeElevated({ signal: abort.signal }) - if (result.code !== 0) { - const message = summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installWsl") - throw new Error(message) - } - const runtime = await probeWslRuntime({ signal: abort.signal }) - setState({ runtime, pendingRestart: pendingRestartAfterWslInstall(runtime) }) + await runJob({ kind: "install-wsl", startedAt: Date.now() }, async () => { + await installWslRuntimeElevated() + const runtime = await probeWslRuntime() + setState({ runtime, pendingRestart: !runtime.available }) }) }, - async installDistro(name: string) { - await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => { - const result = await installWslDistro(name, { signal: abort.signal }) - if (result.code !== 0) { - const message = - summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installDistro", { distro: name }) - throw new Error(message) - } - const distros = await refreshDistroLists({ signal: abort.signal }) - const probe = await probeDistro(name, { signal: abort.signal }) + async installDistro(distro: string) { + await runJob({ kind: "install-distro", distro, startedAt: Date.now() }, async () => { + await installWslDistro(distro) + const distros = await refreshDistroLists() + const probe = await probeDistro(distro) setState({ ...distros, - distroProbes: { ...state.distroProbes, [name]: probe }, + distroProbes: { ...state.distroProbes, [distro]: probe }, }) }) }, async probeAddable(distros: string[]) { if (!distros.length) return - await runJob({ kind: "probe-addable", distros, startedAt: Date.now() }, async (abort) => { - await probeAddableDistros(distros, { signal: abort.signal }) - }) + await runJob({ kind: "probe-addable", distros, startedAt: Date.now() }, () => probeAddableDistros(distros)) }, - async installOpencode(name: string) { - await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => { - const result = await installWslOpencode(appVersion, name, { signal: abort.signal }) - if (result.code !== 0) { - throw new Error(summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installOpencode")) - } - await refreshOpencodeCheck(name, { signal: abort.signal }) - expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, appVersion, name) - const id = wslServerIdToRestart(state.servers, name) + async installOpencode(distro: string) { + await runJob({ kind: "install-opencode", distro, startedAt: Date.now() }, async () => { + const id = state.servers.find((item) => item.config.distro === distro)?.config.id + if (id) await stopServer(id) + await (options.installCli ?? installWslCli)(distro, options.cli) + requireMatchingCli(await refreshCliCheck(distro), options.cli.version) if (id) await startServer(id) }) }, - async openTerminal(name: string) { - await openWslTerminal(name) + async openTerminal(distro: string) { + await openWslTerminal(distro) }, async addServer(distro: string): Promise { @@ -386,7 +282,7 @@ export function createWslServersController( id, distro, } - persistServers([...readServers(), config]) + writeServers([...readServers(), config]) setState({ servers: [...state.servers, { config, runtime: { kind: "starting" } }], }) @@ -396,27 +292,19 @@ export function createWslServersController( async removeServer(id: string) { const distro = state.servers.find((item) => item.config.id === id)?.config.distro - invalidateStartAttempt(id) - await stopServerInternal(id) + await stopServer(id) const remaining = readServers().filter((item) => item.id !== id) - persistServers(remaining) + writeServers(remaining) setState({ servers: state.servers.filter((item) => item.config.id !== id), - ...(distro ? clearWslDistroState(state.distroProbes, state.opencodeChecks, distro) : {}), + ...(distro ? removeDistroState(state, distro) : {}), }) }, startServer, - stopAll() { - for (const item of state.servers) invalidateStartAttempt(item.config.id) - for (const existing of sidecars.values()) { - try { - existing.listener.stop() - } catch { - // ignore - } - } + async stopServers() { + await Promise.all([...sidecars.values()].map((sidecar) => sidecar.stop())) sidecars.clear() }, } @@ -464,7 +352,7 @@ function normalizePersistedServer(value: unknown): WslServerConfig[] { ] } -function opencodeCheck( +function cliCheck( distro: string, resolvedPath: string | null, version: string | null, @@ -500,6 +388,25 @@ function opencodeCheck( } } +function requireMatchingCli(check: WslOpencodeCheck, expected: string) { + if (check.version === expected) return + throw new Error( + nativeT("desktop.wsl.error.updateVersion", { + distro: check.distro, + installed: check.version ?? nativeT("desktop.wsl.error.noVersion"), + expected, + }), + ) +} + +function removeDistroState(state: WslServersState, distro: string) { + const distroProbes = { ...state.distroProbes } + const opencodeChecks = { ...state.opencodeChecks } + delete distroProbes[distro] + delete opencodeChecks[distro] + return { distroProbes, opencodeChecks } +} + function distroProbeReady(probe: WslDistroProbe | undefined) { return !!probe?.canExecute && probe.hasBash && probe.hasCurl } @@ -507,17 +414,3 @@ function distroProbeReady(probe: WslDistroProbe | undefined) { function startupFailure(code: number | null, signal: NodeJS.Signals | null) { return nativeT("desktop.wsl.error.serverExited", { code: code ?? "null", signal: signal ?? "null" }) } - -// Re-export types used by callers -export type { - WslInstalledDistro, - WslOnlineDistro, - WslRuntimeCheck, - WslDistroProbe, - WslOpencodeCheck, - WslServerConfig, - WslServerItem, - WslServerRuntime, - WslServersEvent, - WslServersState, -} diff --git a/packages/desktop/src/main/wsl/sidecar.ts b/packages/desktop/src/main/wsl/sidecar.ts index bd052dd846..29d76f081d 100644 --- a/packages/desktop/src/main/wsl/sidecar.ts +++ b/packages/desktop/src/main/wsl/sidecar.ts @@ -3,12 +3,12 @@ import { randomUUID } from "node:crypto" import { createServer } from "node:net" import { app } from "electron" import { checkHealth } from "../server" -import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./runtime" -import { pollWslHealth } from "./startup" +import { type WslCommandLine, resolveWslCli, shellEscape, wslArgs } from "./runtime" import { nativeT } from "../native-translations" export type WslSidecar = { - listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void } + stop: () => Promise + onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void url: string username: string | null password: string @@ -18,7 +18,7 @@ export async function spawnWslSidecar( distro: string, opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {}, ): Promise { - const opencode = await resolveWslOpencode(distro) + const opencode = await resolveWslCli(distro) if (!opencode) throw new Error(nativeT("desktop.wsl.error.opencodeNotInstalled", { distro })) const port = await allocatePort() @@ -35,7 +35,7 @@ export async function spawnWslSidecar( `export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`, `export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`, 'export XDG_STATE_HOME="$HOME/.local/state"', - `exec ${shellEscape(opencode)} --print-logs --log-level ${app.isPackaged ? "WARN" : "INFO"} serve --hostname 0.0.0.0 --port ${port}`, + `exec ${shellEscape(opencode)} --log-level ${app.isPackaged ? "warn" : "info"} serve --hostname 0.0.0.0 --port ${port}`, ].join("\n") const child = spawn("wsl", wslArgs(["bash", "-se"], distro), { stdio: ["pipe", "pipe", "pipe"], @@ -80,16 +80,27 @@ export async function spawnWslSidecar( startup.abort() }) return { - listener: { - stop: () => child.kill(), - onExit: (cb) => child.once("exit", cb), + stop: async () => { + if (child.exitCode !== null || child.signalCode !== null) return + await new Promise((resolve) => { + child.once("exit", () => resolve()) + child.kill() + }) }, + onExit: (cb) => child.once("exit", cb), url, username, password, } } +async function pollWslHealth(check: () => Promise, signal: AbortSignal) { + while (!signal.aborted) { + if (await check()) return + await new Promise((resolve) => setTimeout(resolve, 100)) + } +} + function allocatePort() { return new Promise((resolve, reject) => { const server = createServer() diff --git a/packages/desktop/src/main/wsl/startup.ts b/packages/desktop/src/main/wsl/startup.ts deleted file mode 100644 index 7a80c3d613..0000000000 --- a/packages/desktop/src/main/wsl/startup.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { nativeT } from "../native-translations" - -export function wslServerIdsToStartOnInitialize(servers: { id: string }[]) { - return servers.map((server) => server.id) -} - -export function expectOpencodeVersion(installed: string | null, expected: string, distro = "Debian") { - if (installed === expected) return - throw new Error( - nativeT("desktop.wsl.error.updateVersion", { - distro, - installed: installed ?? nativeT("desktop.wsl.error.noVersion"), - expected, - }), - ) -} - -export const pendingRestartAfterWslInstall = (runtime: { available: boolean }) => !runtime.available - -export async function pollWslHealth(check: () => Promise, signal: AbortSignal, interval = 100) { - while (!signal.aborted) { - if (await check()) return - await abortableDelay(interval, signal) - } -} - -function abortableDelay(duration: number, signal: AbortSignal) { - return new Promise((resolve) => { - const done = () => { - clearTimeout(timeout) - signal.removeEventListener("abort", done) - resolve() - } - const timeout = setTimeout(done, duration) - signal.addEventListener("abort", done, { once: true }) - }) -}