diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index a352e03fdd4..52aa699ff60 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -37,7 +37,7 @@ export default defineConfig({ }, build: { rollupOptions: { - input: { index: "src/main/index.ts" }, + input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" }, }, externalizeDeps: { include: [nodePtyPkg] }, }, diff --git a/packages/desktop/src/main/apps.ts b/packages/desktop/src/main/apps.ts index 174da94a5d9..bf25417b834 100644 --- a/packages/desktop/src/main/apps.ts +++ b/packages/desktop/src/main/apps.ts @@ -1,14 +1,22 @@ -import { execFileSync } from "node:child_process" -import { existsSync, readFileSync, readdirSync } from "node:fs" +import { execFile, execFileSync } from "node:child_process" +import { access, readFile, readdir } from "node:fs/promises" import { dirname, extname, join } from "node:path" +import util from "node:util" -export function checkAppExists(appName: string): boolean { +const execFilePromise = util.promisify(execFile) + +const exists = (path: string) => + access(path) + .then(() => true) + .catch(() => false) + +export function checkAppExists(appName: string) { if (process.platform === "win32") return true if (process.platform === "linux") return true return checkMacosApp(appName) } -export function resolveAppPath(appName: string): string | null { +export function resolveAppPath(appName: string) { if (process.platform !== "win32") return appName return resolveWindowsAppPath(appName) } @@ -32,26 +40,25 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string } } -function checkMacosApp(appName: string) { +async function checkMacosApp(appName: string) { const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`] const home = process.env.HOME if (home) locations.push(`${home}/Applications/${appName}.app`) - if (locations.some((location) => existsSync(location))) return true - - try { - execFileSync("which", [appName]) - return true - } catch { - return false + for (const location of locations) { + if (await exists(location)) return true } + + return execFilePromise("which", [appName]) + .then(() => true) + .catch(() => false) } -function resolveWindowsAppPath(appName: string): string | null { +async function resolveWindowsAppPath(appName: string): Promise { let output: string try { - output = execFileSync("where", [appName]).toString() + output = execFilePromise("where", [appName]).toString() } catch { return null } @@ -66,8 +73,8 @@ function resolveWindowsAppPath(appName: string): string | null { const exe = paths.find((path) => hasExt(path, "exe")) if (exe) return exe - const resolveCmd = (path: string) => { - const content = readFileSync(path, "utf8") + const resolveCmd = async (path: string) => { + const content = await readFile(path, "utf8") for (const token of content.split('"').map((value: string) => value.trim())) { const lower = token.toLowerCase() if (!lower.includes(".exe")) continue @@ -85,10 +92,10 @@ function resolveWindowsAppPath(appName: string): string | null { return join(current, part) }, base) - if (existsSync(resolved)) return resolved + if (await exists(resolved)) return resolved } - if (existsSync(token)) return token + if (await exists(token)) return token } return null @@ -96,20 +103,20 @@ function resolveWindowsAppPath(appName: string): string | null { for (const path of paths) { if (hasExt(path, "cmd") || hasExt(path, "bat")) { - const resolved = resolveCmd(path) + const resolved = await resolveCmd(path) if (resolved) return resolved } if (!extname(path)) { const cmd = `${path}.cmd` - if (existsSync(cmd)) { - const resolved = resolveCmd(cmd) + if (await exists(cmd)) { + const resolved = await resolveCmd(cmd) if (resolved) return resolved } const bat = `${path}.bat` - if (existsSync(bat)) { - const resolved = resolveCmd(bat) + if (await exists(bat)) { + const resolved = await resolveCmd(bat) if (resolved) return resolved } } @@ -126,7 +133,7 @@ function resolveWindowsAppPath(appName: string): string | null { const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))] for (const dir of dirs) { try { - for (const entry of readdirSync(dir)) { + for (const entry of await readdir(dir)) { const candidate = join(dir, entry) if (!hasExt(candidate, "exe")) continue const stem = entry.replace(/\.exe$/i, "") diff --git a/packages/desktop/src/main/env.d.ts b/packages/desktop/src/main/env.d.ts index 1de56e1c904..eee21e48cb1 100644 --- a/packages/desktop/src/main/env.d.ts +++ b/packages/desktop/src/main/env.d.ts @@ -5,6 +5,7 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv } + declare module "virtual:opencode-server" { export namespace Server { export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index d3c8fcc04e7..f75cd719a29 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -47,7 +47,15 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" -import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" +import { + getDefaultServerUrl, + getWslConfig, + preferAppEnv, + setDefaultServerUrl, + setWslConfig, + spawnLocalServer, + type SidecarListener, +} from "./server" import { createLoadingWindow, createMainWindow, @@ -55,15 +63,13 @@ import { setBackgroundColor, setDockIcon, } from "./windows" -import { drizzle } from "drizzle-orm/node-sqlite/driver" -import type { Server } from "virtual:opencode-server" import { migrate } from "./migrate" const initEmitter = new EventEmitter() let initStep: InitStep = { phase: "server_waiting" } let mainWindow: BrowserWindow | null = null -let server: Server.Listener | null = null +let server: SidecarListener | null = null const loadingComplete = defer() const pendingDeepLinks: string[] = [] @@ -107,6 +113,8 @@ function setupApp() { return } + preferAppEnv(app.getPath("userData")) + app.on("second-instance", (_event: Event, argv: string[]) => { const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) if (urls.length) { @@ -123,17 +131,16 @@ function setupApp() { }) app.on("before-quit", () => { - killSidecar() + void killSidecar() }) app.on("will-quit", () => { - killSidecar() + void killSidecar() }) for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { - killSidecar() - app.exit(0) + void killSidecar().finally(() => app.exit(0)) }) } @@ -184,7 +191,6 @@ function setInitStep(step: InitStep) { async function initialize() { const needsMigration = !sqliteFileExists() - const sqliteDone = needsMigration ? defer() : undefined let overlay: BrowserWindow | null = null const port = await getSidecarPort() @@ -199,31 +205,26 @@ async function initialize() { setInitStep({ phase: "sqlite_waiting" }) if (overlay) sendSqliteMigrationProgress(overlay, progress) if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) - if (progress.type === "Done") sqliteDone?.resolve() }) - if (needsMigration) { - const { Database, JsonMigration } = await import("virtual:opencode-server") - await JsonMigration.run(drizzle({ client: Database.Client().$client }), { - progress: (event: { current: number; total: number }) => { - const percent = Math.round(event.current / event.total) * 100 - initEmitter.emit("sqlite", { type: "InProgress", value: percent }) - }, - }) - initEmitter.emit("sqlite", { type: "Done" }) - - sqliteDone?.resolve() - } - - if (needsMigration) { - await sqliteDone?.promise - } - logger.log("spawning sidecar", { url }) - const { listener, health } = await spawnLocalServer(hostname, port, password, () => { - ensureLoopbackNoProxy() - useEnvProxy() - }) + const { listener, health } = await spawnLocalServer( + hostname, + port, + password, + () => { + ensureLoopbackNoProxy() + useEnvProxy() + }, + { + needsMigration, + userDataPath: app.getPath("userData"), + onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), + onStdout: (message) => logger.log("sidecar stdout", { message }), + onStderr: (message) => logger.warn("sidecar stderr", { message }), + onExit: (code) => logger.warn("sidecar exited", { code }), + }, + ) server = listener serverReady.resolve({ url, @@ -273,9 +274,10 @@ function wireMenu() { }, reload: () => mainWindow?.reload(), relaunch: () => { - killSidecar() - app.relaunch() - app.exit(0) + void killSidecar().finally(() => { + app.relaunch() + app.exit(0) + }) }, }) } @@ -304,7 +306,7 @@ registerIpcHandlers({ getDisplayBackend: async () => null, setDisplayBackend: async () => undefined, parseMarkdown: async (markdown) => parseMarkdown(markdown), - checkAppExists: async (appName) => checkAppExists(appName), + checkAppExists: (appName) => checkAppExists(appName), wslPath: async (path, mode) => wslPath(path, mode), resolveAppPath: async (appName) => resolveAppPath(appName), loadingWindowComplete: () => loadingComplete.resolve(), @@ -314,10 +316,11 @@ registerIpcHandlers({ setBackgroundColor: (color) => setBackgroundColor(color), }) -function killSidecar() { +async function killSidecar() { if (!server) return - server.stop() + const current = server server = null + await current.stop() } function ensureLoopbackNoProxy() { @@ -440,7 +443,7 @@ async function installUpdate() { logger.log("installing downloaded update", { version: downloadedUpdateVersion, }) - killSidecar() + await killSidecar() autoUpdater.quitAndInstall() } diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 1c4af0eb60a..dbcd4239dc2 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -19,7 +19,7 @@ const pickerFilters = (ext?: string[]) => { } type Deps = { - killSidecar: () => void + killSidecar: () => Promise | void awaitInitialization: (sendStep: (step: InitStep) => void) => Promise getWindowConfig: () => Promise | WindowConfig consumeInitialDeepLinks: () => Promise | string[] diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 4b8cb04943b..635a93578af 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -1,12 +1,37 @@ -import { app } from "electron" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { app, utilityProcess } from "electron" +import type { Details } from "electron" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" -import { getUserShell, loadShellEnv } from "./shell-env" +import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env" import { getStore } from "./store" +import type { SqliteMigrationProgress } from "../preload/types" export type WslConfig = { enabled: boolean } export type HealthCheck = { wait: Promise } +type SidecarMessage = + | { type: "sqlite"; progress: SqliteMigrationProgress } + | { type: "ready" } + | { type: "stopped" } + | { type: "error"; error: { message: string; stack?: string } } + +export type SidecarListener = { stop: () => Promise } + +const SIDECAR_SERVICE_NAME = "opencode server" +const SIDECAR_START_STALL_TIMEOUT = 60_000 +const SIDECAR_STOP_TIMEOUT = 6_000 + +type SpawnLocalServerOptions = { + needsMigration: boolean + userDataPath: string + onSqliteProgress?: (progress: SqliteMigrationProgress) => void + onStdout?: (message: string) => void + onStderr?: (message: string) => void + onExit?: (code: number) => void +} + export function getDefaultServerUrl(): string | null { const value = getStore().get(DEFAULT_SERVER_URL_KEY) return typeof value === "string" ? value : null @@ -30,49 +55,155 @@ export function setWslConfig(config: WslConfig) { getStore().set(WSL_ENABLED_KEY, config.enabled) } -export async function spawnLocalServer(hostname: string, port: number, password: string, configureEnv?: () => void) { - prepareServerEnv(password) +export function preferAppEnv(userDataPath: string) { + const shell = process.platform === "win32" ? null : getUserShell() + Object.assign( + process.env, + mergeShellEnv(shell ? loadShellEnv(shell) : null, { + ...process.env, + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_CLIENT: "desktop", + XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, + }), + ) +} + +export async function spawnLocalServer( + hostname: string, + port: number, + password: string, + configureEnv: () => void, + options: SpawnLocalServerOptions, +) { configureEnv?.() - const { Log, Server } = await import("virtual:opencode-server") - await Log.init({ level: "WARN" }) - const listener = await Server.listen({ - port, - hostname, - username: "opencode", - password, - cors: ["oc://renderer"], + const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js") + const child = utilityProcess.fork(sidecar, [], { + cwd: process.cwd(), + env: createSidecarEnv(), + serviceName: SIDECAR_SERVICE_NAME, + stdio: "pipe", + }) + let exited = false + const exit = defer() + + const onProcessGone = (_event: unknown, details: Details) => { + if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return + options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`) + } + + app.on("child-process-gone", onProcessGone) + child.once("exit", (code) => { + exited = true + app.off("child-process-gone", onProcessGone) + options.onExit?.(code) + exit.resolve(code) + }) + child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`)) + + child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd())) + child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd())) + + await new Promise((resolve, reject) => { + let done = false + let timeout: NodeJS.Timeout + + const fail = (error: Error) => { + if (done) return + done = true + cleanup() + reject(error) + } + + const refreshTimeout = () => { + clearTimeout(timeout) + timeout = setTimeout(() => { + fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`)) + }, SIDECAR_START_STALL_TIMEOUT) + } + + const onMessage = (message: SidecarMessage) => { + if (message.type === "sqlite") { + refreshTimeout() + options.onSqliteProgress?.(message.progress) + return + } + if (message.type === "ready") { + if (done) return + done = true + cleanup() + resolve() + return + } + if (message.type === "error") { + fail(Object.assign(new Error(message.error.message), { stack: message.error.stack })) + } + } + const onExit = (code: number) => { + fail(new Error(`Sidecar exited before ready with code ${code}`)) + } + const cleanup = () => { + clearTimeout(timeout) + child.off("message", onMessage) + child.off("exit", onExit) + } + + child.on("message", onMessage) + child.on("exit", onExit) + refreshTimeout() + child.postMessage({ + type: "start", + hostname, + port, + password, + userDataPath: options.userDataPath, + needsMigration: options.needsMigration, + }) + }).catch((error) => { + if (!exited) child.kill() + throw error }) const wait = (async () => { const url = `http://${hostname}:${port}` + let healthy = false + const gone = exit.promise.then((code) => { + if (healthy) return + throw new Error(`Sidecar exited before health check passed with code ${code}`) + }) const ready = async () => { while (true) { await new Promise((resolve) => setTimeout(resolve, 100)) - if (await checkHealth(url, password)) return + if (await checkHealth(url, password)) { + healthy = true + return + } } } - await ready() + await Promise.race([ready(), gone]) })() - return { listener, health: { wait } } -} + let stopping: Promise | undefined -function prepareServerEnv(password: string) { - const shell = process.platform === "win32" ? null : getUserShell() - const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} - const env = { - ...process.env, - ...shellEnv, - OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_CLIENT: "desktop", - OPENCODE_SERVER_USERNAME: "opencode", - OPENCODE_SERVER_PASSWORD: password, - XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? app.getPath("userData"), + return { + listener: { + stop: () => { + if (stopping) return stopping + if (exited) return Promise.resolve() + child.postMessage({ type: "stop" }) + stopping = Promise.race([ + exit.promise.then(() => undefined), + delay(SIDECAR_STOP_TIMEOUT).then(() => { + if (!exited) child.kill() + }), + ]) + return stopping + }, + }, + health: { wait }, } - Object.assign(process.env, env) } export async function checkHealth(url: string, password?: string | null): Promise { @@ -100,3 +231,31 @@ export async function checkHealth(url: string, password?: string | null): Promis return false } } + +function createSidecarEnv(): Record { + const env = Object.fromEntries( + Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])), + ) + delete env.DEBUG + if (process.platform === "linux") delete env.LD_PRELOAD + return env +} + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function serializeError(error: unknown) { + if (error instanceof Error) return { message: error.message, stack: error.stack } + return { message: String(error) } +} + +function defer() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} diff --git a/packages/desktop/src/main/sidecar.ts b/packages/desktop/src/main/sidecar.ts new file mode 100644 index 00000000000..e7d652b6e1a --- /dev/null +++ b/packages/desktop/src/main/sidecar.ts @@ -0,0 +1,178 @@ +import { drizzle } from "drizzle-orm/node-sqlite/driver" +import * as http from "node:http" +import * as tls from "node:tls" + +type NodeHttpWithEnvProxy = typeof http & { + setGlobalProxyFromEnv: () => void +} + +type NodeTlsWithSystemCertificates = typeof tls & { + getCACertificates: (type: "default" | "system") => string[] + setDefaultCACertificates: (certificates: string[]) => void +} + +type StartCommand = { + type: "start" + hostname: string + port: number + password: string + userDataPath: string + needsMigration: boolean +} + +type StopCommand = { type: "stop" } +type SidecarCommand = StartCommand | StopCommand + +type SidecarMessage = + | { type: "sqlite"; progress: { type: "InProgress"; value: number } | { type: "Done" } } + | { type: "ready" } + | { type: "stopped" } + | { type: "error"; error: { message: string; stack?: string } } + +type ParentPort = { + postMessage(message: SidecarMessage): void + on(event: "message", listener: (event: { data: unknown }) => void): void +} + +type Listener = { + stop(close?: boolean): void | Promise +} + +const parentPort = getParentPort() +let listener: Listener | undefined + +parentPort.on("message", (event) => { + const command = parseCommand(event.data) + if (!command) return + if (command.type === "stop") { + void stop() + return + } + void start(command) +}) + +async function start(command: StartCommand) { + try { + prepareSidecarEnv(command.password, command.userDataPath) + ensureLoopbackNoProxy() + useSystemCertificates() + useEnvProxy() + const { Database, JsonMigration, Log, Server } = await import("virtual:opencode-server") + await Log.init({ level: "WARN" }) + + if (command.needsMigration) { + await JsonMigration.run(drizzle({ client: Database.Client().$client }), { + progress: (event: { current: number; total: number }) => { + parentPort.postMessage({ + type: "sqlite", + progress: { + type: "InProgress", + value: event.total === 0 ? 100 : Math.round((event.current / event.total) * 100), + }, + }) + }, + }) + parentPort.postMessage({ type: "sqlite", progress: { type: "Done" } }) + } + + listener = await Server.listen({ + port: command.port, + hostname: command.hostname, + username: "opencode", + password: command.password, + cors: ["oc://renderer"], + }) + parentPort.postMessage({ type: "ready" }) + } catch (error) { + parentPort.postMessage({ type: "error", error: serializeError(error) }) + setImmediate(() => process.exit(1)) + } +} + +async function stop() { + try { + await listener?.stop() + } finally { + listener = undefined + parentPort.postMessage({ type: "stopped" }) + setImmediate(() => process.exit(0)) + } +} + +function prepareSidecarEnv(password: string, userDataPath: string) { + Object.assign(process.env, { + OPENCODE_SERVER_USERNAME: "opencode", + OPENCODE_SERVER_PASSWORD: password, + XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, + }) +} + +function ensureLoopbackNoProxy() { + const loopback = ["127.0.0.1", "localhost", "::1"] + const upsert = (key: string) => { + const items = (process.env[key] ?? "") + .split(",") + .map((value: string) => value.trim()) + .filter((value: string) => Boolean(value)) + + for (const host of loopback) { + if (items.some((value: string) => value.toLowerCase() === host)) continue + items.push(host) + } + + process.env[key] = items.join(",") + } + + upsert("NO_PROXY") + upsert("no_proxy") +} + +function useSystemCertificates() { + try { + const nodeTls = tls as NodeTlsWithSystemCertificates + nodeTls.setDefaultCACertificates([ + ...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]), + ]) + } catch (error) { + console.warn("failed to load system certificates", error) + } +} + +function useEnvProxy() { + try { + ;(http as NodeHttpWithEnvProxy).setGlobalProxyFromEnv() + } catch (error) { + console.warn("failed to load proxy environment", error) + } +} + +function parseCommand(value: unknown): SidecarCommand | undefined { + if (!value || typeof value !== "object") return + const command = value as Partial + if (command.type === "stop") return { type: "stop" } + if (command.type !== "start") return + if (typeof command.hostname !== "string") return + if (typeof command.port !== "number") return + if (typeof command.password !== "string") return + if (typeof command.userDataPath !== "string") return + if (typeof command.needsMigration !== "boolean") return + return { + type: "start", + hostname: command.hostname, + port: command.port, + password: command.password, + userDataPath: command.userDataPath, + needsMigration: command.needsMigration, + } +} + +function serializeError(error: unknown) { + if (error instanceof Error) return { message: error.message, stack: error.stack } + return { message: String(error) } +} + +function getParentPort() { + const port = process.parentPort as ParentPort | undefined + if (!port) throw new Error("Sidecar parent port unavailable") + return port +}