diff options
| author | Brendan Allan <[email protected]> | 2026-04-09 13:18:46 +0800 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-09 13:18:46 +0800 |
| commit | ee23043d644d7b87c2834b09e4d1b372ae820611 (patch) | |
| tree | eceb0b449958dbdba4af881bf66ba8a400c6cad5 /packages/desktop-electron/src | |
| parent | 9c1c061b84cfc4b132faa9dc2e0de3a1e34d87dc (diff) | |
| download | opencode-ee23043d644d7b87c2834b09e4d1b372ae820611.tar.gz opencode-ee23043d644d7b87c2834b09e4d1b372ae820611.zip | |
Remove CLI from electron app (#17803)
Co-authored-by: LukeParkerDev <[email protected]>
Diffstat (limited to 'packages/desktop-electron/src')
| -rw-r--r-- | packages/desktop-electron/src/main/cli.ts | 283 | ||||
| -rw-r--r-- | packages/desktop-electron/src/main/env.d.ts | 22 | ||||
| -rw-r--r-- | packages/desktop-electron/src/main/index.ts | 32 | ||||
| -rw-r--r-- | packages/desktop-electron/src/main/ipc.ts | 2 | ||||
| -rw-r--r-- | packages/desktop-electron/src/main/menu.ts | 5 | ||||
| -rw-r--r-- | packages/desktop-electron/src/main/server.ts | 46 | ||||
| -rw-r--r-- | packages/desktop-electron/src/main/shell-env.ts | 26 |
7 files changed, 75 insertions, 341 deletions
diff --git a/packages/desktop-electron/src/main/cli.ts b/packages/desktop-electron/src/main/cli.ts deleted file mode 100644 index ebaf89fda..000000000 --- a/packages/desktop-electron/src/main/cli.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { execFileSync, spawn } from "node:child_process" -import { EventEmitter } from "node:events" -import { chmodSync, readFileSync, unlinkSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { dirname, join } from "node:path" -import readline from "node:readline" -import { fileURLToPath } from "node:url" -import { app } from "electron" -import treeKill from "tree-kill" - -import { WSL_ENABLED_KEY } from "./constants" -import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env" -import { store } from "./store" - -const CLI_INSTALL_DIR = ".opencode/bin" -const CLI_BINARY_NAME = "opencode" - -export type ServerConfig = { - hostname?: string - port?: number -} - -export type Config = { - server?: ServerConfig -} - -export type TerminatedPayload = { code: number | null; signal: number | null } - -export type CommandEvent = - | { type: "stdout"; value: string } - | { type: "stderr"; value: string } - | { type: "error"; value: string } - | { type: "terminated"; value: TerminatedPayload } - | { type: "sqlite"; value: SqliteMigrationProgress } - -export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" } - -export type CommandChild = { - pid: number | undefined - kill: () => void -} - -const root = dirname(fileURLToPath(import.meta.url)) - -export function getSidecarPath() { - const suffix = process.platform === "win32" ? ".exe" : "" - const path = app.isPackaged - ? join(process.resourcesPath, `opencode-cli${suffix}`) - : join(root, "../../resources", `opencode-cli${suffix}`) - console.log(`[cli] Sidecar path resolved: ${path} (isPackaged: ${app.isPackaged})`) - return path -} - -export async function getConfig(): Promise<Config | null> { - const { events } = spawnCommand("debug config", {}) - let output = "" - - await new Promise<void>((resolve) => { - events.on("stdout", (line: string) => { - output += line - }) - events.on("stderr", (line: string) => { - output += line - }) - events.on("terminated", () => resolve()) - events.on("error", () => resolve()) - }) - - try { - return JSON.parse(output) as Config - } catch { - return null - } -} - -export async function installCli(): Promise<string> { - if (process.platform === "win32") { - throw new Error("CLI installation is only supported on macOS & Linux") - } - - const sidecar = getSidecarPath() - const scriptPath = join(app.getAppPath(), "install") - const script = readFileSync(scriptPath, "utf8") - const tempScript = join(tmpdir(), "opencode-install.sh") - - writeFileSync(tempScript, script, "utf8") - chmodSync(tempScript, 0o755) - - const cmd = spawn(tempScript, ["--binary", sidecar], { stdio: "pipe" }) - return await new Promise<string>((resolve, reject) => { - cmd.on("exit", (code: number | null) => { - try { - unlinkSync(tempScript) - } catch {} - if (code === 0) { - const installPath = getCliInstallPath() - if (installPath) return resolve(installPath) - return reject(new Error("Could not determine install path")) - } - reject(new Error("Install script failed")) - }) - }) -} - -export function syncCli() { - if (!app.isPackaged) return - const installPath = getCliInstallPath() - if (!installPath) return - - let version = "" - try { - version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim() - } catch { - return - } - - const cli = parseVersion(version) - const appVersion = parseVersion(app.getVersion()) - if (!cli || !appVersion) return - if (compareVersions(cli, appVersion) >= 0) return - void installCli().catch(() => undefined) -} - -export function serve(hostname: string, port: number, password: string) { - const args = `--print-logs --log-level WARN serve --hostname ${hostname} --port ${port}` - const env = { - OPENCODE_SERVER_USERNAME: "opencode", - OPENCODE_SERVER_PASSWORD: password, - } - - return spawnCommand(args, env) -} - -export function spawnCommand(args: string, extraEnv: Record<string, string>) { - console.log(`[cli] Spawning command with args: ${args}`) - const base = Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"), - ) - const env = { - ...base, - OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_CLIENT: "desktop", - XDG_STATE_HOME: app.getPath("userData"), - ...extraEnv, - } - const shell = process.platform === "win32" ? null : getUserShell() - const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env - - const { cmd, cmdArgs } = buildCommand(args, envs, shell) - console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`) - const child = spawn(cmd, cmdArgs, { - env: envs, - detached: process.platform !== "win32", - windowsHide: true, - stdio: ["ignore", "pipe", "pipe"], - }) - console.log(`[cli] Spawned process with PID: ${child.pid}`) - - const events = new EventEmitter() - const exit = new Promise<TerminatedPayload>((resolve) => { - child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => { - console.log(`[cli] Process exited with code: ${code}, signal: ${signal}`) - resolve({ code: code ?? null, signal: null }) - }) - child.on("error", (error: Error) => { - console.error(`[cli] Process error: ${error.message}`) - events.emit("error", error.message) - }) - }) - - const stdout = child.stdout - const stderr = child.stderr - - if (stdout) { - readline.createInterface({ input: stdout }).on("line", (line: string) => { - if (handleSqliteProgress(events, line)) return - events.emit("stdout", `${line}\n`) - }) - } - - if (stderr) { - readline.createInterface({ input: stderr }).on("line", (line: string) => { - if (handleSqliteProgress(events, line)) return - events.emit("stderr", `${line}\n`) - }) - } - - exit.then((payload) => { - events.emit("terminated", payload) - }) - - const kill = () => { - if (!child.pid) return - treeKill(child.pid) - } - - return { events, child: { pid: child.pid, kill }, exit } -} - -function handleSqliteProgress(events: EventEmitter, line: string) { - const stripped = line.startsWith("sqlite-migration:") ? line.slice("sqlite-migration:".length).trim() : null - if (!stripped) return false - if (stripped === "done") { - events.emit("sqlite", { type: "Done" }) - return true - } - const value = Number.parseInt(stripped, 10) - if (!Number.isNaN(value)) { - events.emit("sqlite", { type: "InProgress", value }) - return true - } - return false -} - -function buildCommand(args: string, env: Record<string, string>, shell: string | null) { - if (process.platform === "win32" && isWslEnabled()) { - console.log(`[cli] Using WSL mode`) - const version = app.getVersion() - const script = [ - "set -e", - 'BIN="$HOME/.opencode/bin/opencode"', - 'if [ ! -x "$BIN" ]; then', - ` curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)} --no-modify-path`, - "fi", - `${envPrefix(env)} exec "$BIN" ${args}`, - ].join("\n") - - return { cmd: "wsl", cmdArgs: ["-e", "bash", "-lc", script] } - } - - if (process.platform === "win32") { - const sidecar = getSidecarPath() - console.log(`[cli] Windows direct mode, sidecar: ${sidecar}`) - return { cmd: sidecar, cmdArgs: args.split(" ") } - } - - const sidecar = getSidecarPath() - const user = shell || getUserShell() - const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}` - console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`) - return { cmd: user, cmdArgs: ["-l", "-c", line] } -} - -function envPrefix(env: Record<string, string>) { - const entries = Object.entries(env).map(([key, value]) => `${key}=${shellEscape(value)}`) - return entries.join(" ") -} - -function shellEscape(input: string) { - if (!input) return "''" - return `'${input.replace(/'/g, `'"'"'`)}'` -} - -function getCliInstallPath() { - const home = process.env.HOME - if (!home) return null - return join(home, CLI_INSTALL_DIR, CLI_BINARY_NAME) -} - -function isWslEnabled() { - return store.get(WSL_ENABLED_KEY) === true -} - -function parseVersion(value: string) { - const parts = value - .replace(/^v/, "") - .split(".") - .map((part) => Number.parseInt(part, 10)) - if (parts.some((part) => Number.isNaN(part))) return null - return parts -} - -function compareVersions(a: number[], b: number[]) { - const len = Math.max(a.length, b.length) - for (let i = 0; i < len; i += 1) { - const left = a[i] ?? 0 - const right = b[i] ?? 0 - if (left > right) return 1 - if (left < right) return -1 - } - return 0 -} diff --git a/packages/desktop-electron/src/main/env.d.ts b/packages/desktop-electron/src/main/env.d.ts index 0ee0c551d..1de56e1c9 100644 --- a/packages/desktop-electron/src/main/env.d.ts +++ b/packages/desktop-electron/src/main/env.d.ts @@ -5,3 +5,25 @@ 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 + export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener + } + export namespace Config { + export const get: typeof import("../../../opencode/dist/types/src/node").Config.get + export type Info = import("../../../opencode/dist/types/src/node").Config.Info + } + export namespace Log { + export const init: typeof import("../../../opencode/dist/types/src/node").Log.init + } + export namespace Database { + export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path + export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client + } + export namespace JsonMigration { + export type Progress = import("../../../opencode/dist/types/src/node").JsonMigration.Progress + export const run: typeof import("../../../opencode/dist/types/src/node").JsonMigration.run + } + export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap +} diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index b635caa4e..89e7c61ac 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -11,6 +11,8 @@ import pkg from "electron-updater" import contextMenu from "electron-context-menu" contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) +process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" + const APP_NAMES: Record<string, string> = { dev: "OpenCode Dev", beta: "OpenCode Beta", @@ -27,8 +29,6 @@ const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" -import type { CommandChild } from "./cli" -import { installCli, syncCli } from "./cli" import { CHANNEL, UPDATER_ENABLED } from "./constants" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" import { initLogging } from "./logging" @@ -36,12 +36,13 @@ import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" +import type { Server } from "virtual:opencode-server" const initEmitter = new EventEmitter() let initStep: InitStep = { phase: "server_waiting" } let mainWindow: BrowserWindow | null = null -let sidecar: CommandChild | null = null +let server: Server.Listener | null = null const loadingComplete = defer<void>() const pendingDeepLinks: string[] = [] @@ -96,11 +97,9 @@ function setupApp() { } void app.whenReady().then(async () => { - // migrate() app.setAsDefaultProtocolClient("opencode") setDockIcon() setupAutoUpdater() - syncCli() await initialize() }) } @@ -134,8 +133,8 @@ async function initialize() { const password = randomUUID() logger.log("spawning sidecar", { url }) - const { child, health, events } = spawnLocalServer(hostname, port, password) - sidecar = child + const { listener, health } = await spawnLocalServer(hostname, port, password) + server = listener serverReady.resolve({ url, username: "opencode", @@ -145,7 +144,7 @@ async function initialize() { const loadingTask = (async () => { logger.log("sidecar connection started", { url }) - events.on("sqlite", (progress: SqliteMigrationProgress) => { + initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => { setInitStep({ phase: "sqlite_waiting" }) if (overlay) sendSqliteMigrationProgress(overlay, progress) if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) @@ -198,9 +197,6 @@ function wireMenu() { if (!mainWindow) return createMenu({ trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), - installCli: () => { - void installCli() - }, checkForUpdates: () => { void checkForUpdates(true) }, @@ -215,7 +211,6 @@ function wireMenu() { registerIpcHandlers({ killSidecar: () => killSidecar(), - installCli: async () => installCli(), awaitInitialization: async (sendStep) => { sendStep(initStep) const listener = (step: InitStep) => sendStep(step) @@ -247,16 +242,9 @@ registerIpcHandlers({ }) function killSidecar() { - if (!sidecar) return - const pid = sidecar.pid - sidecar.kill() - sidecar = null - // tree-kill is async; also send process group signal as immediate fallback - if (pid && process.platform !== "win32") { - try { - process.kill(-pid, "SIGTERM") - } catch {} - } + if (!server) return + server.stop() + server = null } function ensureLoopbackNoProxy() { diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index d2cfc2524..52d87ed7e 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -13,7 +13,6 @@ const pickerFilters = (ext?: string[]) => { type Deps = { killSidecar: () => void - installCli: () => Promise<string> awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData> getDefaultServerUrl: () => Promise<string | null> | string | null setDefaultServerUrl: (url: string | null) => Promise<void> | void @@ -34,7 +33,6 @@ type Deps = { export function registerIpcHandlers(deps: Deps) { ipcMain.handle("kill-sidecar", () => deps.killSidecar()) - ipcMain.handle("install-cli", () => deps.installCli()) ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => { const send = (step: InitStep) => event.sender.send("init-step", step) return deps.awaitInitialization(send) diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts index 12e2445bc..fcf209fb6 100644 --- a/packages/desktop-electron/src/main/menu.ts +++ b/packages/desktop-electron/src/main/menu.ts @@ -5,7 +5,6 @@ import { createMainWindow } from "./windows" type Deps = { trigger: (id: string) => void - installCli: () => void checkForUpdates: () => void reload: () => void relaunch: () => void @@ -25,10 +24,6 @@ export function createMenu(deps: Deps) { click: () => deps.checkForUpdates(), }, { - label: "Install CLI...", - click: () => deps.installCli(), - }, - { label: "Reload Webview", click: () => deps.reload(), }, diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 2d09d119f..5a6050013 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -1,5 +1,6 @@ -import { serve, type CommandChild } from "./cli" +import { app } from "electron" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" +import { getUserShell, loadShellEnv } from "./shell-env" import { store } from "./store" export type WslConfig = { enabled: boolean } @@ -29,8 +30,16 @@ export function setWslConfig(config: WslConfig) { store.set(WSL_ENABLED_KEY, config.enabled) } -export function spawnLocalServer(hostname: string, port: number, password: string) { - const { child, exit, events } = serve(hostname, port, password) +export async function spawnLocalServer(hostname: string, port: number, password: string) { + prepareServerEnv(password) + const { Log, Server } = await import("virtual:opencode-server") + await Log.init({ level: "WARN" }) + const listener = await Server.listen({ + port, + hostname, + username: "opencode", + password, + }) const wait = (async () => { const url = `http://${hostname}:${port}` @@ -42,19 +51,26 @@ export function spawnLocalServer(hostname: string, port: number, password: strin } } - const terminated = async () => { - const payload = await exit - throw new Error( - `Sidecar terminated before becoming healthy (code=${payload.code ?? "unknown"} signal=${ - payload.signal ?? "unknown" - })`, - ) - } - - await Promise.race([ready(), terminated()]) + await ready() })() - return { child, health: { wait }, events } + return { listener, health: { wait } } +} + +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: app.getPath("userData"), + } + Object.assign(process.env, env) } export async function checkHealth(url: string, password?: string | null): Promise<boolean> { @@ -82,5 +98,3 @@ export async function checkHealth(url: string, password?: string | null): Promis return false } } - -export type { CommandChild } diff --git a/packages/desktop-electron/src/main/shell-env.ts b/packages/desktop-electron/src/main/shell-env.ts index 300084821..8453a5730 100644 --- a/packages/desktop-electron/src/main/shell-env.ts +++ b/packages/desktop-electron/src/main/shell-env.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process" import { basename } from "node:path" -const SHELL_ENV_TIMEOUT = 5_000 +const TIMEOUT = 5_000 type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" } @@ -20,28 +20,28 @@ export function parseShellEnv(out: Buffer) { return env } -function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe { +function probe(shell: string, mode: "-il" | "-l"): Probe { const out = spawnSync(shell, [mode, "-c", "env -0"], { stdio: ["ignore", "pipe", "ignore"], - timeout: SHELL_ENV_TIMEOUT, + timeout: TIMEOUT, windowsHide: true, }) const err = out.error as NodeJS.ErrnoException | undefined if (err) { if (err.code === "ETIMEDOUT") return { type: "Timeout" } - console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`) + console.log(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`) return { type: "Unavailable" } } if (out.status !== 0) { - console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`) + console.log(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`) return { type: "Unavailable" } } const env = parseShellEnv(out.stdout) if (Object.keys(env).length === 0) { - console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`) + console.log(`[server] Shell env probe returned empty env for ${shell} ${mode}`) return { type: "Unavailable" } } @@ -56,27 +56,27 @@ export function isNushell(shell: string) { export function loadShellEnv(shell: string) { if (isNushell(shell)) { - console.log(`[cli] Skipping shell env probe for nushell: ${shell}`) + console.log(`[server] Skipping shell env probe for nushell: ${shell}`) return null } - const interactive = probeShellEnv(shell, "-il") + const interactive = probe(shell, "-il") if (interactive.type === "Loaded") { - console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) + console.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) return interactive.value } if (interactive.type === "Timeout") { - console.warn(`[cli] Interactive shell env probe timed out: ${shell}`) + console.warn(`[server] Interactive shell env probe timed out: ${shell}`) return null } - const login = probeShellEnv(shell, "-l") + const login = probe(shell, "-l") if (login.type === "Loaded") { - console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) + console.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) return login.value } - console.warn(`[cli] Falling back to app environment: ${shell}`) + console.warn(`[server] Falling back to app environment: ${shell}`) return null } |
