| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256 |
- 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 { 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
- }
- export function setDefaultServerUrl(url: string | null) {
- if (url) {
- getStore().set(DEFAULT_SERVER_URL_KEY, url)
- return
- }
- getStore().delete(DEFAULT_SERVER_URL_KEY)
- }
- export function getWslConfig(): WslConfig {
- const value = getStore().get(WSL_ENABLED_KEY)
- return { enabled: typeof value === "boolean" ? value : false }
- }
- export function setWslConfig(config: WslConfig) {
- getStore().set(WSL_ENABLED_KEY, config.enabled)
- }
- export function preferAppEnv(userDataPath: string) {
- const shell = process.platform === "win32" ? null : getUserShell()
- Object.assign(process.env, {
- ...(shell ? loadShellEnv(shell) : null),
- 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,
- options: SpawnLocalServerOptions,
- ) {
- 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)) {
- healthy = true
- return
- }
- }
- }
- await Promise.race([ready(), gone])
- })()
- let stopping: Promise<void> | undefined
- 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 },
- }
- }
- export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
- let healthUrl: URL
- try {
- healthUrl = new URL("/global/health", url)
- } catch {
- return false
- }
- const headers = new Headers()
- if (password) {
- const auth = Buffer.from(`opencode:${password}`).toString("base64")
- headers.set("authorization", `Basic ${auth}`)
- }
- try {
- const res = await fetch(healthUrl, {
- method: "GET",
- headers,
- signal: AbortSignal.timeout(3000),
- })
- return res.ok
- } catch {
- 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 }
- }
|