summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop-electron
diff options
context:
space:
mode:
authorBrendan Allan <[email protected]>2026-04-09 13:18:46 +0800
committerGitHub <[email protected]>2026-04-09 13:18:46 +0800
commitee23043d644d7b87c2834b09e4d1b372ae820611 (patch)
treeeceb0b449958dbdba4af881bf66ba8a400c6cad5 /packages/desktop-electron
parent9c1c061b84cfc4b132faa9dc2e0de3a1e34d87dc (diff)
downloadopencode-ee23043d644d7b87c2834b09e4d1b372ae820611.tar.gz
opencode-ee23043d644d7b87c2834b09e4d1b372ae820611.zip
Remove CLI from electron app (#17803)
Co-authored-by: LukeParkerDev <[email protected]>
Diffstat (limited to 'packages/desktop-electron')
-rw-r--r--packages/desktop-electron/electron-builder.config.ts5
-rw-r--r--packages/desktop-electron/electron.vite.config.ts31
-rw-r--r--packages/desktop-electron/package.json33
-rw-r--r--packages/desktop-electron/scripts/prebuild.ts9
-rw-r--r--packages/desktop-electron/scripts/predev.ts14
-rwxr-xr-xpackages/desktop-electron/scripts/prepare.ts18
-rw-r--r--packages/desktop-electron/src/main/cli.ts283
-rw-r--r--packages/desktop-electron/src/main/env.d.ts22
-rw-r--r--packages/desktop-electron/src/main/index.ts32
-rw-r--r--packages/desktop-electron/src/main/ipc.ts2
-rw-r--r--packages/desktop-electron/src/main/menu.ts5
-rw-r--r--packages/desktop-electron/src/main/server.ts46
-rw-r--r--packages/desktop-electron/src/main/shell-env.ts26
13 files changed, 139 insertions, 387 deletions
diff --git a/packages/desktop-electron/electron-builder.config.ts b/packages/desktop-electron/electron-builder.config.ts
index 70441d8d7..b3fcd1708 100644
--- a/packages/desktop-electron/electron-builder.config.ts
+++ b/packages/desktop-electron/electron-builder.config.ts
@@ -35,11 +35,6 @@ const getBase = (): Configuration => ({
files: ["out/**/*", "resources/**/*"],
extraResources: [
{
- from: "resources/",
- to: "",
- filter: ["opencode-cli*"],
- },
- {
from: "native/",
to: "native/",
filter: ["index.js", "index.d.ts", "build/Release/mac_window.node", "swift-build/**"],
diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts
index 6903d5ed2..e2b296a3e 100644
--- a/packages/desktop-electron/electron.vite.config.ts
+++ b/packages/desktop-electron/electron.vite.config.ts
@@ -1,5 +1,6 @@
import { defineConfig } from "electron-vite"
import appPlugin from "@opencode-ai/app/vite"
+import * as fs from "node:fs/promises"
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
@@ -7,6 +8,10 @@ const channel = (() => {
return "dev"
})()
+const OPENCODE_SERVER_DIST = "../opencode/dist/node"
+
+const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
+
export default defineConfig({
main: {
define: {
@@ -16,7 +21,33 @@ export default defineConfig({
rollupOptions: {
input: { index: "src/main/index.ts" },
},
+ externalizeDeps: { include: [nodePtyPkg] },
},
+ plugins: [
+ {
+ name: "opencode:node-pty-narrower",
+ enforce: "pre",
+ resolveId(s) {
+ 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: {
build: {
diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json
index 66d2144d5..d39331b36 100644
--- a/packages/desktop-electron/package.json
+++ b/packages/desktop-electron/package.json
@@ -13,7 +13,7 @@
"typecheck": "tsgo -b",
"predev": "bun ./scripts/predev.ts",
"dev": "electron-vite dev",
- "prebuild": "bun ./scripts/copy-icons.ts",
+ "prebuild": "bun ./scripts/prebuild.ts",
"build": "electron-vite build",
"preview": "electron-vite preview",
"package": "electron-builder --config electron-builder.config.ts",
@@ -24,31 +24,42 @@
},
"main": "./out/main/index.js",
"dependencies": {
- "@opencode-ai/app": "workspace:*",
- "@opencode-ai/ui": "workspace:*",
- "@solid-primitives/i18n": "2.2.1",
- "@solid-primitives/storage": "catalog:",
- "@solidjs/meta": "catalog:",
- "@solidjs/router": "0.15.4",
"effect": "catalog:",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",
"electron-window-state": "^5.0.3",
- "marked": "^15",
- "solid-js": "catalog:",
- "tree-kill": "^1.2.2"
+ "marked": "^15"
},
"devDependencies": {
"@actions/artifact": "4.0.0",
+ "@lydell/node-pty": "catalog:",
+ "@opencode-ai/app": "workspace:*",
+ "@opencode-ai/ui": "workspace:*",
+ "@solid-primitives/i18n": "2.2.1",
+ "@solid-primitives/storage": "catalog:",
+ "@solidjs/meta": "catalog:",
+ "@solidjs/router": "0.15.4",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
+ "@valibot/to-json-schema": "1.6.0",
"electron": "40.4.1",
"electron-builder": "^26",
"electron-vite": "^5",
+ "solid-js": "catalog:",
+ "sury": "11.0.0-alpha.4",
"typescript": "~5.6.2",
- "vite": "catalog:"
+ "vite": "catalog:",
+ "zod-openapi": "5.4.6"
+ },
+ "optionalDependencies": {
+ "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10",
+ "@lydell/node-pty-darwin-x64": "1.2.0-beta.10",
+ "@lydell/node-pty-linux-arm64": "1.2.0-beta.10",
+ "@lydell/node-pty-linux-x64": "1.2.0-beta.10",
+ "@lydell/node-pty-win32-arm64": "1.2.0-beta.10",
+ "@lydell/node-pty-win32-x64": "1.2.0-beta.10"
}
}
diff --git a/packages/desktop-electron/scripts/prebuild.ts b/packages/desktop-electron/scripts/prebuild.ts
new file mode 100644
index 000000000..46a2475ea
--- /dev/null
+++ b/packages/desktop-electron/scripts/prebuild.ts
@@ -0,0 +1,9 @@
+#!/usr/bin/env bun
+import { $ } from "bun"
+
+import { resolveChannel } from "./utils"
+
+const channel = resolveChannel()
+await $`bun ./scripts/copy-icons.ts ${channel}`
+
+await $`cd ../opencode && bun script/build-node.ts`
diff --git a/packages/desktop-electron/scripts/predev.ts b/packages/desktop-electron/scripts/predev.ts
index a688d0e7f..37c31d7ee 100644
--- a/packages/desktop-electron/scripts/predev.ts
+++ b/packages/desktop-electron/scripts/predev.ts
@@ -1,17 +1,5 @@
import { $ } from "bun"
-import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
-
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
-const RUST_TARGET = Bun.env.RUST_TARGET
-
-const sidecarConfig = getCurrentSidecar(RUST_TARGET)
-
-const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
-
-await (sidecarConfig.ocBinary.includes("-baseline")
- ? $`cd ../opencode && bun run build --single --baseline`
- : $`cd ../opencode && bun run build --single`)
-
-await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)
+await $`cd ../opencode && bun script/build-node.ts`
diff --git a/packages/desktop-electron/scripts/prepare.ts b/packages/desktop-electron/scripts/prepare.ts
index 3704b2e61..0dfd5a35c 100755
--- a/packages/desktop-electron/scripts/prepare.ts
+++ b/packages/desktop-electron/scripts/prepare.ts
@@ -1,25 +1,9 @@
#!/usr/bin/env bun
-import { $ } from "bun"
-
import { Script } from "@opencode-ai/script"
-import { copyBinaryToSidecarFolder, getCurrentSidecar, resolveChannel, windowsify } from "./utils"
-const channel = resolveChannel()
-await $`bun ./scripts/copy-icons.ts ${channel}`
+await import("./prebuild")
const pkg = await Bun.file("./package.json").json()
pkg.version = Script.version
await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
console.log(`Updated package.json version to ${Script.version}`)
-
-const sidecarConfig = getCurrentSidecar()
-const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
-
-const dir = "resources/opencode-binaries"
-
-await $`mkdir -p ${dir}`
-await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
-
-await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
-
-await $`rm -rf ${dir}`
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
}