From 1be6d94267a4e16b12e5927bb2357ceb83020c85 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:03:34 +1000 Subject: [PATCH] fix(desktop): bootstrap v2 background service (#39309) --- .../desktop/electron-builder.config.test.ts | 33 +++++ packages/desktop/electron-builder.config.ts | 11 +- packages/desktop/electron.vite.config.ts | 21 +-- packages/desktop/scripts/prebuild.ts | 4 +- packages/desktop/scripts/predev.ts | 3 +- packages/desktop/scripts/utils.ts | 64 ++++++--- packages/desktop/src/main/background-cli.ts | 125 ++++++++++++++++++ packages/desktop/src/main/index.ts | 83 ++---------- packages/desktop/src/main/server.ts | 4 +- 9 files changed, 228 insertions(+), 120 deletions(-) create mode 100644 packages/desktop/src/main/background-cli.ts diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index 4ae53fa043d..3fb1adb6c17 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -56,3 +56,36 @@ test("keeps a hidden prod launcher for old Linux pins", async () => { expect(desktop).toContain("StartupWMClass=ai.opencode.desktop") expect(desktop).toContain("NoDisplay=true") }) + +test("bundles the CLI outside the dev app archive", async () => { + const previous = process.env.OPENCODE_CHANNEL + process.env.OPENCODE_CHANNEL = "dev" + const module = await import("./electron-builder.config.ts?cli-resource") + const config = module.default as Configuration + if (previous === undefined) delete process.env.OPENCODE_CHANNEL + else process.env.OPENCODE_CHANNEL = previous + + expect(config.files).toContain("!resources/opencode-cli*") + expect(config.extraResources).toContainEqual({ + from: "resources/", + to: "", + filter: ["opencode-cli*"], + }) +}) + +for (const channel of ["beta", "prod"] as const) { + test(`does not bundle the CLI in ${channel} builds`, async () => { + const previous = process.env.OPENCODE_CHANNEL + process.env.OPENCODE_CHANNEL = channel + const module = await import(`./electron-builder.config.ts?no-cli-resource=${channel}`) + const config = module.default as Configuration + if (previous === undefined) delete process.env.OPENCODE_CHANNEL + else process.env.OPENCODE_CHANNEL = previous + + expect(config.extraResources).not.toContainEqual({ + from: "resources/", + to: "", + filter: ["opencode-cli*"], + }) + }) +} diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index a10a757dbb8..508c0df5e91 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -55,8 +55,17 @@ const getBase = (appId: string): Configuration => ({ extraMetadata: { desktopName: `${appId}.desktop`, }, - files: ["out/**/*", "resources/**/*"], + files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"], extraResources: [ + ...(channel === "dev" + ? [ + { + from: "resources/", + to: "", + filter: ["opencode-cli*"], + }, + ] + : []), { from: "native/", to: "native/", diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 5d25be209e3..89971098885 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -1,9 +1,6 @@ import { sentryVitePlugin } from "@sentry/vite-plugin" import { defineConfig } from "electron-vite" import appPlugin from "@opencode-ai/app/vite" -import * as fs from "node:fs/promises" - -const OPENCODE_SERVER_DIST = "../opencode/dist/node" const channel = (() => { const raw = process.env.OPENCODE_CHANNEL @@ -38,7 +35,7 @@ export default defineConfig({ }, build: { rollupOptions: { - input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" }, + input: { index: "src/main/index.ts" }, // Keep this identical to electron-vite's Node 20.11+ shim. Its regex insertion can // corrupt bundled TypeScript, while a Rollup banner places the shim safely. output: { @@ -61,22 +58,6 @@ const require = __cjs_mod__.createRequire(import.meta.url); if (s === "@lydell/node-pty") return nodePtyPkg }, }, - { - name: "opencode:virtual-server-module", - enforce: "pre", - resolveId(id) { - if (id === "virtual:opencode-server") return this.resolve(`${OPENCODE_SERVER_DIST}/node.js`) - }, - }, - { - name: "opencode:copy-server-assets", - async writeBundle() { - for (const l of await fs.readdir(OPENCODE_SERVER_DIST)) { - if (!l.endsWith(".wasm")) continue - await fs.writeFile(`./out/main/chunks/${l}`, await fs.readFile(`${OPENCODE_SERVER_DIST}/${l}`)) - } - }, - }, ], }, preload: { diff --git a/packages/desktop/scripts/prebuild.ts b/packages/desktop/scripts/prebuild.ts index 79b0e30afcd..8d1041180ab 100644 --- a/packages/desktop/scripts/prebuild.ts +++ b/packages/desktop/scripts/prebuild.ts @@ -1,10 +1,10 @@ #!/usr/bin/env bun import { $ } from "bun" -import { resolveChannel } from "./utils" +import { downloadCliToResources, resolveChannel } from "./utils" const channel = resolveChannel() await $`bun ./scripts/copy-icons.ts ${channel}` await $`bun ./scripts/copy-metainfo.ts ${channel}` -await $`cd ../opencode && bun script/build-node.ts` +if (channel === "dev") await downloadCliToResources() diff --git a/packages/desktop/scripts/predev.ts b/packages/desktop/scripts/predev.ts index 4a9682306de..538302e3624 100644 --- a/packages/desktop/scripts/predev.ts +++ b/packages/desktop/scripts/predev.ts @@ -1,7 +1,8 @@ import { $ } from "bun" +import { downloadCliToResources } from "./utils" await $`bun run install-electron` await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}` -await $`cd ../opencode && bun script/build-node.ts` +await downloadCliToResources() diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts index 19b96b0a161..e5f87491e15 100644 --- a/packages/desktop/scripts/utils.ts +++ b/packages/desktop/scripts/utils.ts @@ -1,4 +1,9 @@ import { $ } from "bun" +import { chmod, copyFile, mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +const CLI_VERSION = "0.0.0-next-16365" export type Channel = "dev" | "beta" | "prod" @@ -8,36 +13,42 @@ export function resolveChannel(): Channel { return "dev" } -export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; assetExt: string }> = [ +export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [ { rustTarget: "aarch64-apple-darwin", - ocBinary: "opencode-darwin-arm64", - assetExt: "zip", + package: "@opencode-ai/cli-darwin-arm64", + os: "darwin", + cpu: "arm64", }, { rustTarget: "x86_64-apple-darwin", - ocBinary: "opencode-darwin-x64-baseline", - assetExt: "zip", + package: "@opencode-ai/cli-darwin-x64-baseline", + os: "darwin", + cpu: "x64", }, { rustTarget: "aarch64-pc-windows-msvc", - ocBinary: "opencode-windows-arm64", - assetExt: "zip", + package: "@opencode-ai/cli-windows-arm64", + os: "win32", + cpu: "arm64", }, { rustTarget: "x86_64-pc-windows-msvc", - ocBinary: "opencode-windows-x64-baseline", - assetExt: "zip", + package: "@opencode-ai/cli-windows-x64-baseline", + os: "win32", + cpu: "x64", }, { rustTarget: "x86_64-unknown-linux-gnu", - ocBinary: "opencode-linux-x64-baseline", - assetExt: "tar.gz", + package: "@opencode-ai/cli-linux-x64-baseline", + os: "linux", + cpu: "x64", }, { rustTarget: "aarch64-unknown-linux-gnu", - ocBinary: "opencode-linux-arm64", - assetExt: "tar.gz", + package: "@opencode-ai/cli-linux-arm64", + os: "linux", + cpu: "arm64", }, ] @@ -51,24 +62,33 @@ function nativeTarget() { throw new Error(`Unsupported platform: ${platform}/${arch}`) } -export function getCurrentSidecar(target = RUST_TARGET ?? nativeTarget()) { - const binaryConfig = SIDECAR_BINARIES.find((b) => b.rustTarget === target) - if (!binaryConfig) throw new Error(`Sidecar configuration not available for Rust target '${target}'`) +export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) { + const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target) + if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`) return binaryConfig } -export async function copyBinaryToSidecarFolder(source: string) { - const dir = `resources` - await $`mkdir -p ${dir}` - const dest = windowsify(`${dir}/opencode-cli`) - await $`cp ${source} ${dest}` +export async function downloadCliToResources() { + const cli = getCurrentCli() + const directory = await mkdtemp(join(tmpdir(), "opencode-cli-")) + const dest = windowsify("resources/opencode-cli") + try { + await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${CLI_VERSION}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}` + await copyFile( + join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"), + dest, + ) + } finally { + await rm(directory, { recursive: true, force: true }) + } + if (process.platform !== "win32") await chmod(dest, 0o755) if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}` } if (process.platform === "darwin") await $`codesign --force --sign - ${dest}` - console.log(`Copied ${source} to ${dest}`) + console.log(`Copied ${cli.package} to ${dest}`) } export function windowsify(path: string) { diff --git a/packages/desktop/src/main/background-cli.ts b/packages/desktop/src/main/background-cli.ts new file mode 100644 index 00000000000..66602d51f11 --- /dev/null +++ b/packages/desktop/src/main/background-cli.ts @@ -0,0 +1,125 @@ +import { execFile } from "node:child_process" +import { existsSync } from "node:fs" +import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { promisify } from "node:util" +import { app } from "electron" + +const execFileAsync = promisify(execFile) +const root = dirname(fileURLToPath(import.meta.url)) +const stateHome = process.env.XDG_STATE_HOME +const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"] + +type Logger = { + log(message: string, meta?: Record): void + error(message: string, meta?: Record): void +} + +export async function startBackgroundCli(logger: Logger, shellStateHome?: string) { + const bundled = app.isPackaged + ? join(process.resourcesPath, executableName()) + : join(root, "../../resources", executableName()) + logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged }) + const version = await run(bundled, ["--version"], logger) + const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled + + const candidates = [ + ...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]), + ].filter((candidate) => candidate === undefined || existsSync(candidate)) + const discovered = await Promise.all( + candidates.map(async (candidate) => ({ + stateHome: candidate, + url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })), + })), + ) + const found = discovered.find((candidate) => candidate.url !== undefined) + logger.log("v2 CLI background instance checked", { + detected: Boolean(found), + ...endpoint(found?.url), + }) + + const daemonStateHome = found?.stateHome ?? stateHome + const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome }) + const password = await run(binary, ["service", "get", "password"], logger, { + redact: true, + stateHome: daemonStateHome, + }) + logger.log("v2 CLI background service ready", { + existing: Boolean(found), + username: "opencode", + ...endpoint(url), + }) + return { + url, + username: "opencode", + password, + } +} + +async function installCli(source: string, version: string, logger: Logger) { + const directory = join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-")) + const destination = join(directory, executableName()) + if (existsSync(destination)) { + logger.log("v2 CLI staged executable reused", { path: destination, version }) + return destination + } + + const temp = destination + `.${process.pid}.tmp` + await mkdir(directory, { recursive: true }) + await copyFile(source, temp) + if (process.platform !== "win32") await chmod(temp, 0o755) + await rename(temp, destination).catch(async (error) => { + await rm(temp, { force: true }) + throw error + }) + logger.log("v2 CLI executable staged", { source, path: destination, version }) + return destination +} + +async function run( + binary: string, + args: string[], + logger: Logger, + options: { redact?: boolean; stateHome?: string } = {}, +) { + logger.log("v2 CLI command started", { binary, args }) + const env = { ...process.env } + if (options.stateHome === undefined) delete env.XDG_STATE_HOME + else env.XDG_STATE_HOME = options.stateHome + return execFileAsync(binary, args, { env, windowsHide: true }).then( + (result) => { + const stdout = result.stdout.trim() + const stderr = result.stderr.trim() + logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr }) + return stdout + }, + (error: unknown) => { + const output = error as { stdout?: string; stderr?: string } + logger.error("v2 CLI command failed", { + args, + error: error instanceof Error ? error.message : String(error), + stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""), + stderr: output.stderr?.trim() ?? "", + }) + throw error + }, + ) +} + +function serviceUrl(status: string) { + if (URL.canParse(status)) return status + if (!status.startsWith("running ")) return + const url = status.slice("running ".length).trim() + return URL.canParse(url) ? url : undefined +} + +function endpoint(url: string | undefined) { + if (!url || !URL.canParse(url)) return {} + const parsed = new URL(url) + return { url, hostname: parsed.hostname, port: parsed.port } +} + +function executableName() { + return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli" +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index c7c1643092d..381996fb0e6 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto" import { mkdirSync, rmSync } from "node:fs" import * as http from "node:http" -import { createServer } from "node:net" import { homedir, tmpdir } from "node:os" import { join } from "node:path" import { getCACertificates, setDefaultCACertificates } from "node:tls" @@ -25,13 +24,7 @@ import { isFirstLaunchOnboardingPending, isOldLayoutEligible, } from "./onboarding" -import { - getDefaultServerUrl, - preferAppEnv, - setDefaultServerUrl, - spawnLocalServer, - type SidecarListener, -} from "./server" +import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server" import { setupAutoUpdater, showUpdaterDialog } from "./updater" import { safeWebContentsURL } from "./window-state" import { @@ -48,6 +41,7 @@ import { registerWslIpcHandlers } from "./wsl/ipc" import { spawnWslSidecar } from "./wsl/sidecar" import { migrate } from "./migrate" import { cleanupStoreFiles } from "./store-cleanup" +import { startBackgroundCli } from "./background-cli" const APP_NAMES: Record = { dev: "OpenCode Dev", @@ -63,7 +57,6 @@ const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports" let logger: ReturnType -let server: SidecarListener | null = null const pendingDeepLinks: string[] = [] @@ -83,13 +76,6 @@ function emitDeepLinks(urls: string[]) { if (win) sendDeepLinks(win, urls) } -async function killSidecar() { - if (!server) return - const current = server - server = null - await current.stop() -} - function ensureLoopbackNoProxy() { const loopback = ["127.0.0.1", "localhost", "::1"] const upsert = (key: string) => { @@ -162,10 +148,7 @@ const main = Effect.gen(function* () { }, }, ) - const stopSidecars = async () => { - await killSidecar() - wslServers.stopAll() - } + const stopSidecars = async () => wslServers.stopAll() const relaunch = () => { setAppQuitting() void stopSidecars().finally(() => { @@ -198,7 +181,7 @@ const main = Effect.gen(function* () { return } - preferAppEnv(app.getPath("userData")) + const shellEnv = preferAppEnv(app.getPath("userData")) app.on("second-instance", (_event: Event, argv: string[]) => { const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) @@ -271,7 +254,7 @@ const main = Effect.gen(function* () { setDockIcon() const updater = setupAutoUpdater(stopSidecars) registerIpcHandlers({ - killSidecar: () => killSidecar(), + killSidecar: () => undefined, relaunch, awaitInitialization: Effect.fnUntraced( function* () { @@ -312,68 +295,22 @@ const main = Effect.gen(function* () { ), ) - const port = yield* Effect.gen(function* () { - const fromEnv = process.env.OPENCODE_PORT - if (fromEnv) { - const parsed = Number.parseInt(fromEnv, 10) - if (!Number.isNaN(parsed)) return parsed - } - - const res = yield* Deferred.make() - const server = createServer() - server.on("error", (e) => Deferred.failSync(res, () => e)) - server.listen(0, "127.0.0.1", () => { - const address = server.address() - if (typeof address !== "object" || !address) { - server.close() - Deferred.failSync(res, () => new Error("Failed to get port")) - return - } - const port = address.port - server.close(() => Effect.runSync(Deferred.succeed(res, port))) - }) - - return yield* Deferred.await(res) - }) - const hostname = "127.0.0.1" - const url = `http://${hostname}:${port}` - const password = randomUUID() - const loadingTask = yield* Effect.gen(function* () { - logger.log("sidecar connection started", { url }) - ensureLoopbackNoProxy() useEnvProxy() - logger.log("spawning sidecar", { url }) - const { listener, health } = yield* Effect.promise(() => - spawnLocalServer(hostname, port, password, { - userDataPath: app.getPath("userData"), - onStdout: (message) => writeLog("server", "stdout", { message }), - onStderr: (message) => writeLog("server", "stderr", { message }, "warn"), - onExit: (code) => writeLog("utility", "sidecar exited", { code }, "warn"), - }), - ) - server = listener + logger.log("starting v2 background service") + const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME)) yield* Deferred.succeed(serverReady, { - url, - username: "opencode", - password, + url: sidecar.url, + username: sidecar.username, + password: sidecar.password, }) if (process.platform === "win32") { void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) } - yield* Effect.promise(() => health.wait).pipe( - Effect.timeout("30 seconds"), - Effect.catch((e) => - Effect.sync(() => { - logger.error("sidecar health check failed", e.toString()) - }), - ), - ) - logger.log("loading task finished") }).pipe(forwardInitializationFailure(serverReady), Effect.forkChild) diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index c620b76ae88..ae1a98efdfa 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -43,13 +43,15 @@ export function setDefaultServerUrl(url: string | null) { export function preferAppEnv(userDataPath: string) { const shell = process.platform === "win32" ? null : getUserShell() + const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null Object.assign(process.env, { - ...(shell ? loadShellEnv(shell, getLogger()) : null), + ...shellEnv, OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", OPENCODE_CLIENT: "desktop", XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, }) + return shellEnv } export async function spawnLocalServer(