diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 9a11def8fd..969732dda6 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,4 +1,5 @@ export { AppBaseProviders, AppInterface } from "./app" +export { DialogLocalServer } from "./components/dialog-local-server" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index cc2b7a8e7f..aa2eee7b6f 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -55,6 +55,7 @@ const loadingComplete = defer() const pendingDeepLinks: string[] = [] const serverReady = defer() +void serverReady.promise.catch(() => undefined) const localServer = createLocalServerController(app.getVersion()) const logger = initLogging() @@ -164,26 +165,31 @@ async function initialize() { logger.log("spawning sidecar", { url }) localServer.setRuntime(runtime) localServer.setStatus({ kind: "running", step: null }) - serverReady.resolve({ + const startupData = { url, username: "opencode", password, local: runtime, - }) + } + let startupError: Error | null = null 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 spawnWslLocalServer(runtime.distro, port, password, { + onLine: (line) => + logger.log("wsl sidecar startup", { distro: runtime.distro, stream: line.stream, text: line.text }), + }) } return spawnLocalServer(hostname, port, password) } catch (error) { + startupError = asError(error) localServer.setStatus({ kind: "failed", step: null, - message: error instanceof Error ? error.message : String(error), + message: startupError.message, }) - logger.error("local server startup failed", error) + logger.error("local server startup failed", startupError) return undefined } })() @@ -212,15 +218,20 @@ async function initialize() { ]) .then(() => { localServer.setStatus({ kind: "ready" }) + serverReady.resolve(startupData) }) .catch((error) => { + startupError = asError(error) localServer.setStatus({ kind: "failed", step: null, - message: error instanceof Error ? error.message : String(error), + message: startupError.message, }) - logger.error("sidecar health check failed", error) + logger.error("sidecar health check failed", startupError) + serverReady.reject(startupError) }) + } else { + serverReady.reject(startupError ?? new Error("Local server startup failed")) } logger.log("loading task finished") @@ -510,6 +521,10 @@ function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } +function asError(error: unknown) { + return error instanceof Error ? error : new Error(String(error)) +} + function defer() { let resolve!: (value: T) => void let reject!: (error: Error) => void diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index b367de9db3..7ccb33b2f2 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -3,7 +3,7 @@ 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" +import { type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl" export type HealthCheck = { wait: Promise } @@ -48,33 +48,48 @@ 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(" ") +export async function spawnWslLocalServer( + distro: string, + port: number, + password: string, + opts: { onLine?: (line: WslCommandLine) => void } = {}, +) { + const opencode = await resolveWslOpencode(distro) + if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`) - const child = spawn("wsl", wslArgs(["bash", "-lc", script], distro), { - stdio: ["ignore", "pipe", "pipe"], + const script = [ + "set -euo pipefail", + "export OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true", + "export OPENCODE_EXPERIMENTAL_FILEWATCHER=true", + "export OPENCODE_CLIENT=desktop", + `export OPENCODE_SERVER_USERNAME=${shellEscape("opencode")}`, + `export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`, + 'export XDG_STATE_HOME="$HOME/.local/state"', + `exec ${shellEscape(opencode)} --print-logs --log-level WARN serve --hostname 0.0.0.0 --port ${port}`, + ].join("\n") + + const child = spawn("wsl", wslArgs(["bash", "-se"], distro), { + stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }) - child.stdout.setEncoding("utf8") - child.stderr.setEncoding("utf8") + child.stdin.end(script) + + let settled = false + const recentOutput: string[] = [] + const emit = (line: WslCommandLine) => { + if (settled || !line.text.trim()) return + recentOutput.push(`[${line.stream}] ${line.text}`) + if (recentOutput.length > 12) recentOutput.shift() + opts.onLine?.(line) + } + + forwardLines(child.stdout, "stdout", emit) + forwardLines(child.stderr, "stderr", emit) 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"})`, - ), - ) + reject(new Error(startupFailure(code, signal, recentOutput))) }) }) @@ -87,7 +102,9 @@ export async function spawnWslLocalServer(distro: string, port: number, password } })(), exit, - ]) + ]).finally(() => { + settled = true + }) return { listener: { @@ -119,6 +136,29 @@ function shellEscape(value: string) { return `'${value.replace(/'/g, `'"'"'`)}'` } +function forwardLines( + stream: NodeJS.ReadableStream, + source: WslCommandLine["stream"], + onLine: (line: WslCommandLine) => void, +) { + let pending = "" + stream.setEncoding("utf8") + stream.on("data", (chunk: string) => { + pending += chunk + const lines = pending.split(/\r?\n/g) + pending = lines.pop() ?? "" + for (const line of lines) onLine({ stream: source, text: line }) + }) + stream.on("end", () => { + if (pending) onLine({ stream: source, text: pending }) + }) +} + +function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) { + const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : "" + return `WSL local server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}` +} + export async function checkHealth(url: string, password?: string | null): Promise { let healthUrl: URL try { diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 330f90ccca..df61018671 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -5,6 +5,7 @@ import { ACCEPTED_FILE_TYPES, AppBaseProviders, AppInterface, + DialogLocalServer, handleNotificationClick, loadLocaleDict, normalizeLocale, @@ -23,6 +24,10 @@ import { initI18n, t } from "./i18n" import { UPDATER_ENABLED } from "./updater" import { webviewZoom } from "./webview-zoom" import "./styles.css" +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Splash } from "@opencode-ai/ui/logo" import { useTheme } from "@opencode-ai/ui/theme" const root = document.getElementById("root") @@ -48,6 +53,34 @@ const listenForDeepLinks = () => { return window.api.onDeepLink((urls) => emitDeepLinks(urls)) } +function LocalServerStartupError(props: { message: string }) { + const dialog = useDialog() + + return ( +
+
+ +

Local Server failed to start

+

{props.message}

+ +
+
+ ) +} + const createPlatform = (): Platform => { const os = (() => { const ua = navigator.userAgent @@ -275,8 +308,19 @@ render(() => { const [windowCount] = createResource(() => window.api.getWindowCount()) - // Fetch sidecar credentials (available immediately, before health check) - const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined)) + const [startup] = createResource(async () => { + try { + return { + error: null, + sidecar: await window.api.awaitInitialization(() => undefined), + } + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + sidecar: null, + } + } + }) const [defaultServer] = createResource(() => platform.getDefaultServer?.().then((url) => { @@ -286,7 +330,7 @@ render(() => { const [locale] = createResource(loadLocale) const servers = () => { - const data = sidecar() + const data = startup.latest?.sidecar if (!data) return [] const server: ServerConnection.Sidecar = { displayName: "Local Server", @@ -339,12 +383,16 @@ render(() => { return ( - + {(_) => { + if (startup.latest?.error) { + return + } return (