|
|
@@ -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<void> }
|
|
|
|
|
|
+type SidecarMessage =
|
|
|
+ | { type: "sqlite"; progress: SqliteMigrationProgress }
|
|
|
+ | { type: "ready" }
|
|
|
+ | { type: "stopped" }
|
|
|
+ | { type: "error"; error: { message: string; stack?: string } }
|
|
|
+
|
|
|
+export type SidecarListener = { stop: () => Promise<void> }
|
|
|
+
|
|
|
+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<number>()
|
|
|
+
|
|
|
+ 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<void>((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<void> | 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<boolean> {
|
|
|
@@ -100,3 +231,31 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
|
|
return false
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+function createSidecarEnv(): Record<string, string> {
|
|
|
+ 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<void>((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<T>() {
|
|
|
+ let resolve!: (value: T) => void
|
|
|
+ let reject!: (error: Error) => void
|
|
|
+ const promise = new Promise<T>((res, rej) => {
|
|
|
+ resolve = res
|
|
|
+ reject = rej
|
|
|
+ })
|
|
|
+ return { promise, resolve, reject }
|
|
|
+}
|