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 | |
| 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')
| -rw-r--r-- | packages/desktop-electron/electron-builder.config.ts | 5 | ||||
| -rw-r--r-- | packages/desktop-electron/electron.vite.config.ts | 31 | ||||
| -rw-r--r-- | packages/desktop-electron/package.json | 33 | ||||
| -rw-r--r-- | packages/desktop-electron/scripts/prebuild.ts | 9 | ||||
| -rw-r--r-- | packages/desktop-electron/scripts/predev.ts | 14 | ||||
| -rwxr-xr-x | packages/desktop-electron/scripts/prepare.ts | 18 | ||||
| -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 |
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 } |
