mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 20:05:23 +00:00
fix: surface desktop WSL startup failures
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -55,6 +55,7 @@ const loadingComplete = defer<void>()
|
||||
const pendingDeepLinks: string[] = []
|
||||
|
||||
const serverReady = defer<ServerReadyData>()
|
||||
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<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
|
||||
@@ -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<void> }
|
||||
|
||||
@@ -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<never>((_, 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<boolean> {
|
||||
let healthUrl: URL
|
||||
try {
|
||||
|
||||
@@ -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 (
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
|
||||
<div class="flex flex-col items-center max-w-md text-center">
|
||||
<Splash class="w-12 h-15 mb-4" />
|
||||
<p class="text-16-medium text-text-strong">Local Server failed to start</p>
|
||||
<p class="mt-2 text-12-regular text-text-weak whitespace-pre-wrap break-words">{props.message}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
class="mt-4"
|
||||
onClick={() =>
|
||||
dialog.show(() => (
|
||||
<Dialog title="Local Server" dismissOutside={false}>
|
||||
<DialogLocalServer />
|
||||
</Dialog>
|
||||
))
|
||||
}
|
||||
>
|
||||
Open Local Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders locale={locale.latest}>
|
||||
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading}>
|
||||
<Show when={!defaultServer.loading && !startup.loading && !windowCount.loading && !locale.loading}>
|
||||
{(_) => {
|
||||
if (startup.latest?.error) {
|
||||
return <LocalServerStartupError message={startup.latest.error} />
|
||||
}
|
||||
return (
|
||||
<AppInterface
|
||||
defaultServer={
|
||||
defaultServer.latest ?? ServerConnection.Key.make(sidecar.latest?.local.key ?? "local:windows")
|
||||
defaultServer.latest ??
|
||||
ServerConnection.Key.make(startup.latest?.sidecar?.local.key ?? "local:windows")
|
||||
}
|
||||
servers={servers()}
|
||||
router={MemoryRouter}
|
||||
|
||||
Reference in New Issue
Block a user