diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index a2763e356e..400fe8bdf2 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -42,15 +42,14 @@ import { createLocalServerController } from "./local-server" import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" -import { getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer } from "./server" +import { getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslLocalServer } from "./server" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" -import type { Server } from "virtual:opencode-server" const initEmitter = new EventEmitter() let initStep: InitStep = { phase: "server_waiting" } let mainWindow: BrowserWindow | null = null -let server: Server.Listener | null = null +let server: { stop(): void } | null = null const loadingComplete = defer() const pendingDeepLinks: string[] = [] @@ -140,26 +139,47 @@ async function initialize() { const hostname = "127.0.0.1" const url = `http://${hostname}:${port}` const password = randomUUID() + const config = localServer.getState().config + const runtime = + config.mode === "wsl" && config.distro + ? { + key: `local:wsl:${config.distro}`, + mode: "wsl" as const, + distro: config.distro, + } + : { + key: "local:windows", + mode: "windows" as const, + distro: null, + } logger.log("spawning sidecar", { url }) - localServer.setRuntime({ key: "local:windows", mode: "windows", distro: null }) + localServer.setRuntime(runtime) localServer.setStatus({ kind: "running", step: null }) - const { listener, health } = await spawnLocalServer(hostname, port, password).catch((error) => { - localServer.setStatus({ - kind: "failed", - step: null, - message: error instanceof Error ? error.message : String(error), - }) - throw error - }) - server = listener - const runtime = localServer.getState().runtime serverReady.resolve({ url, username: "opencode", password, local: runtime, }) + const startup = await (async () => { + try { + if (runtime.mode === "wsl") { + if (!runtime.distro) throw new Error("No WSL distro selected") + return spawnWslLocalServer(runtime.distro, port, password) + } + return spawnLocalServer(hostname, port, password) + } catch (error) { + localServer.setStatus({ + kind: "failed", + step: null, + message: error instanceof Error ? error.message : String(error), + }) + logger.error("local server startup failed", error) + return undefined + } + })() + server = startup?.listener ?? null const loadingTask = (async () => { logger.log("sidecar connection started", { url }) @@ -175,23 +195,25 @@ async function initialize() { await sqliteDone?.promise } - await Promise.race([ - health.wait, - delay(30_000).then(() => { - throw new Error("Sidecar health check timed out") - }), - ]) - .then(() => { - localServer.setStatus({ kind: "ready" }) - }) - .catch((error) => { - localServer.setStatus({ - kind: "failed", - step: null, - message: error instanceof Error ? error.message : String(error), + if (startup) { + await Promise.race([ + startup.health.wait, + delay(30_000).then(() => { + throw new Error("Sidecar health check timed out") + }), + ]) + .then(() => { + localServer.setStatus({ kind: "ready" }) }) - logger.error("sidecar health check failed", error) - }) + .catch((error) => { + localServer.setStatus({ + kind: "failed", + step: null, + message: error instanceof Error ? error.message : String(error), + }) + logger.error("sidecar health check failed", error) + }) + } logger.log("loading task finished") })() diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 34014ed5d6..b367de9db3 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -1,7 +1,9 @@ +import { spawn } from "node:child_process" import { app } from "electron" import { DEFAULT_SERVER_URL_KEY } from "./constants" import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" +import { wslArgs } from "./wsl" export type HealthCheck = { wait: Promise } @@ -46,6 +48,57 @@ export async function spawnLocalServer(hostname: string, port: number, password: return { listener, health: { wait } } } +export async function spawnWslLocalServer(distro: string, port: number, password: string) { + const script = [ + "set -e", + "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true", + "OPENCODE_EXPERIMENTAL_FILEWATCHER=true", + "OPENCODE_CLIENT=desktop", + `OPENCODE_SERVER_USERNAME=${shellEscape("opencode")}`, + `OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`, + 'XDG_STATE_HOME="$HOME/.local/state"', + `exec opencode --print-logs --log-level WARN serve --hostname 0.0.0.0 --port ${port}`, + ].join(" ") + + const child = spawn("wsl", wslArgs(["bash", "-lc", script], distro), { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }) + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + + const exit = new Promise((_, reject) => { + child.once("error", reject) + child.once("exit", (code, signal) => { + reject( + new Error( + `WSL local server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})`, + ), + ) + }) + }) + + const wait = Promise.race([ + (async () => { + const url = `http://127.0.0.1:${port}` + while (true) { + await new Promise((resolve) => setTimeout(resolve, 100)) + if (await checkHealth(url, password)) return + } + })(), + exit, + ]) + + return { + listener: { + stop() { + child.kill() + }, + }, + health: { wait }, + } +} + function prepareServerEnv(password: string) { const shell = process.platform === "win32" ? null : getUserShell() const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} @@ -62,6 +115,10 @@ function prepareServerEnv(password: string) { Object.assign(process.env, env) } +function shellEscape(value: string) { + return `'${value.replace(/'/g, `'"'"'`)}'` +} + export async function checkHealth(url: string, password?: string | null): Promise { let healthUrl: URL try {