feat: 品牌替换 + 启动优化 + AGENTS.md 模板定制

- 品牌替换:OpenCode/opencode → AirCoding/aircoding(16+ 文件)
- Logo ASCII art:修复 left/right 行数不匹配导致的启动崩溃
- 启动诊断:添加 OPENCODE_PRINT_TIMING 计时探针
- dev 模式默认 --pure 跳过外部插件加载
- AGENTS.md 模板:追加 AirCoding 多 Agent 专项段落
- architect prompt + plugin:强化 AGENTS.md 产出验证
This commit is contained in:
airlongdian
2026-06-14 09:31:29 +08:00
commit e2fd375a1c
5757 changed files with 1170016 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
import { execFile } from "node:child_process"
import { access, readFile, readdir } from "node:fs/promises"
import { dirname, extname, join } from "node:path"
import util from "node:util"
const execFilePromise = util.promisify(execFile)
const exists = (path: string) =>
access(path)
.then(() => true)
.catch(() => false)
export function checkAppExists(appName: string) {
if (process.platform === "win32") return true
if (process.platform === "linux") return true
return checkMacosApp(appName)
}
export function resolveAppPath(appName: string) {
if (process.platform !== "win32") return appName
return resolveWindowsAppPath(appName)
}
async function checkMacosApp(appName: string) {
const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`]
const home = process.env.HOME
if (home) locations.push(`${home}/Applications/${appName}.app`)
for (const location of locations) {
if (await exists(location)) return true
}
return execFilePromise("which", [appName])
.then(() => true)
.catch(() => false)
}
async function resolveWindowsAppPath(appName: string): Promise<string | null> {
let output: string
try {
output = await execFilePromise("where", [appName]).then((r) => r.stdout.toString())
} catch {
return null
}
const paths = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
const hasExt = (path: string, ext: string) => extname(path).toLowerCase() === `.${ext}`
const exe = paths.find((path) => hasExt(path, "exe"))
if (exe) return exe
const resolveCmd = async (path: string) => {
const content = await readFile(path, "utf8")
for (const token of content.split('"').map((value: string) => value.trim())) {
const lower = token.toLowerCase()
if (!lower.includes(".exe")) continue
const index = lower.indexOf("%~dp0")
if (index >= 0) {
const base = dirname(path)
const suffix = token.slice(index + 5)
const resolved = suffix
.replace(/\//g, "\\")
.split("\\")
.filter((part: string) => part && part !== ".")
.reduce((current: string, part: string) => {
if (part === "..") return dirname(current)
return join(current, part)
}, base)
if (await exists(resolved)) return resolved
}
if (await exists(token)) return token
}
return null
}
for (const path of paths) {
if (hasExt(path, "cmd") || hasExt(path, "bat")) {
const resolved = await resolveCmd(path)
if (resolved) return resolved
}
if (!extname(path)) {
const cmd = `${path}.cmd`
if (await exists(cmd)) {
const resolved = await resolveCmd(cmd)
if (resolved) return resolved
}
const bat = `${path}.bat`
if (await exists(bat)) {
const resolved = await resolveCmd(bat)
if (resolved) return resolved
}
}
}
const key = appName
.split("")
.filter((value: string) => /[a-z0-9]/i.test(value))
.map((value: string) => value.toLowerCase())
.join("")
if (key) {
for (const path of paths) {
const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))]
for (const dir of dirs) {
try {
for (const entry of await readdir(dir)) {
const candidate = join(dir, entry)
if (!hasExt(candidate, "exe")) continue
const stem = entry.replace(/\.exe$/i, "")
const name = stem
.split("")
.filter((value: string) => /[a-z0-9]/i.test(value))
.map((value: string) => value.toLowerCase())
.join("")
if (name.includes(key) || key.includes(name)) return candidate
}
} catch {
continue
}
}
}
}
return paths[0] ?? null
}

View File

@@ -0,0 +1,87 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import {
assertAttachmentBudget,
createPickedFileAuthorizations,
MAX_ATTACHMENT_BYTES,
readAttachment,
} from "./attachment-picker"
describe("assertAttachmentBudget", () => {
test("accepts selections within the media ingest limit", () => {
expect(() =>
assertAttachmentBudget([{ size: MAX_ATTACHMENT_BYTES / 2 }, { size: MAX_ATTACHMENT_BYTES / 2 }]),
).not.toThrow()
})
test("rejects the selection before files are read when its total exceeds the limit", () => {
expect(() => assertAttachmentBudget([{ size: MAX_ATTACHMENT_BYTES }, { size: 1 }])).toThrow("20 MB limit")
})
test("reads an approved file through a bounded buffer", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-attachment-"))
const file = join(directory, "example.txt")
try {
await writeFile(file, "lorem ipsum")
expect(new TextDecoder().decode(await readAttachment(file))).toBe("lorem ipsum")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("rejects an oversized file before allocating its contents", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-attachment-"))
const file = join(directory, "oversized.txt")
try {
await writeFile(file, "")
await truncate(file, MAX_ATTACHMENT_BYTES + 1)
await expect(readAttachment(file)).rejects.toThrow("20 MB limit")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
})
describe("picked file authorizations", () => {
const read = async (path: string) => new TextEncoder().encode(path).buffer
test("keeps concurrent picker selections isolated", async () => {
const authorizations = createPickedFileAuthorizations(read)
const first = authorizations.add(1, ["a.txt", "b.txt"])
const second = authorizations.add(1, ["c.txt"])
expect(new TextDecoder().decode(await authorizations.read(1, first, "a.txt"))).toBe("a.txt")
expect(new TextDecoder().decode(await authorizations.read(1, second, "c.txt"))).toBe("c.txt")
expect(new TextDecoder().decode(await authorizations.read(1, first, "b.txt"))).toBe("b.txt")
})
test("releases unread files for one picker without affecting another", async () => {
const authorizations = createPickedFileAuthorizations(read)
const first = authorizations.add(1, ["a.txt"])
const second = authorizations.add(1, ["b.txt"])
authorizations.release(1, first)
await expect(authorizations.read(1, first, "a.txt")).rejects.toThrow("not selected")
expect(new TextDecoder().decode(await authorizations.read(1, second, "b.txt"))).toBe("b.txt")
})
test("keeps picker tokens scoped to their renderer", async () => {
const authorizations = createPickedFileAuthorizations(read)
const token = authorizations.add(1, ["a.txt"])
await expect(authorizations.read(2, token, "a.txt")).rejects.toThrow("not selected")
})
test("charges actual reads against the selection budget", async () => {
const authorizations = createPickedFileAuthorizations(async (_path, maxBytes) => {
if (6 > maxBytes) throw new Error("budget exceeded")
return new ArrayBuffer(6)
}, 10)
const token = authorizations.add(1, ["a.txt", "b.txt"])
await authorizations.read(1, token, "a.txt")
await expect(authorizations.read(1, token, "b.txt")).rejects.toThrow("budget exceeded")
})
})

View File

@@ -0,0 +1,56 @@
import { randomUUID } from "node:crypto"
import { open } from "node:fs/promises"
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
export function createPickedFileAuthorizations(
read: (path: string, maxBytes: number) => Promise<ArrayBuffer> = readAttachment,
budget = MAX_ATTACHMENT_BYTES,
) {
const selections = new Map<string, { sender: number; paths: Set<string>; remaining: number }>()
return {
add(sender: number, paths: string[]) {
const token = randomUUID()
selections.set(token, { sender, paths: new Set(paths), remaining: budget })
return token
},
async read(sender: number, token: string, path: string) {
const selection = selections.get(token)
if (selection?.sender !== sender || !selection.paths.delete(path))
throw new Error("File was not selected by the picker")
const bytes = await read(path, selection.remaining)
selection.remaining -= bytes.byteLength
if (selection.paths.size === 0) selections.delete(token)
return bytes
},
release(sender: number, token: string) {
if (selections.get(token)?.sender === sender) selections.delete(token)
},
}
}
export function assertAttachmentBudget(files: { size: number }[]) {
const total = files.reduce((sum, file) => sum + file.size, 0)
if (total <= MAX_ATTACHMENT_BYTES) return
throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`)
}
export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) {
const file = await open(filePath, "r")
try {
const info = await file.stat()
if (info.size > maxBytes)
throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`)
const bytes = Buffer.allocUnsafe(info.size)
let offset = 0
while (offset < info.size) {
const result = await file.read(bytes, offset, info.size - offset, offset)
if (result.bytesRead === 0) break
offset += result.bytesRead
}
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + offset) as ArrayBuffer
} finally {
await file.close()
}
}

View File

@@ -0,0 +1,7 @@
import { app } from "electron"
type Channel = "dev" | "beta" | "prod"
const raw = import.meta.env.OPENCODE_CHANNEL
export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"

View File

@@ -0,0 +1,84 @@
import { BrowserWindow } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import { createMainWindow, updateTitlebar } from "./windows"
export type DesktopMenuActionHandlers = Partial<{
checkForUpdates: () => void
relaunch: () => void
}>
export function runDesktopMenuAction(
win: BrowserWindow | null,
action: DesktopMenuAction,
handlers: DesktopMenuActionHandlers = {},
) {
switch (action) {
case "app.checkForUpdates":
handlers.checkForUpdates?.()
return
case "app.relaunch":
handlers.relaunch?.()
return
case "window.new":
createMainWindow()
return
case "window.close":
win?.close()
return
case "window.minimize":
win?.minimize()
return
case "window.toggleMaximize":
if (win?.isMaximized()) {
win.unmaximize()
return
}
win?.maximize()
return
case "view.reload":
win?.reload()
return
case "view.toggleDevTools":
win?.webContents.toggleDevTools()
return
case "view.resetZoom":
setZoom(win, 1)
return
case "view.zoomIn":
setZoom(win, (win?.webContents.getZoomFactor() ?? 1) + 0.2)
return
case "view.zoomOut":
setZoom(win, (win?.webContents.getZoomFactor() ?? 1) - 0.2)
return
case "view.toggleFullscreen":
win?.setFullScreen(!win.isFullScreen())
return
case "edit.undo":
win?.webContents.undo()
return
case "edit.redo":
win?.webContents.redo()
return
case "edit.cut":
win?.webContents.cut()
return
case "edit.copy":
win?.webContents.copy()
return
case "edit.paste":
win?.webContents.paste()
return
case "edit.delete":
win?.webContents.delete()
return
case "edit.selectAll":
win?.webContents.selectAll()
return
}
}
function setZoom(win: BrowserWindow | null, value: number) {
if (!win) return
win.webContents.setZoomFactor(Math.min(Math.max(value, 0.2), 10))
updateTitlebar(win)
}

19
packages/desktop/src/main/env.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
interface ImportMetaEnv {
readonly OPENCODE_CHANNEL: string
}
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 const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
import { forwardInitializationFailure } from "./initialization"
describe("desktop initialization", () => {
const failure = new Error("sidecar startup failed")
const expectFailure = (exit: Exit.Exit<unknown, unknown>) => {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isSuccess(exit)) return
expect(Cause.squash(exit.cause)).toBe(failure)
}
test("forwards loading task failures before renderer initialization", () => {
const exit = Effect.runSync(
Effect.gen(function* () {
const initialization = yield* Deferred.make<never, unknown>()
yield* forwardInitializationFailure(initialization)(Effect.die(failure)).pipe(Effect.exit)
return yield* Deferred.await(initialization).pipe(Effect.exit)
}),
)
expectFailure(exit)
})
test("forwards loading task failures while renderer initialization waits", () => {
const exit = Effect.runSync(
Effect.gen(function* () {
const initialization = yield* Deferred.make<never, unknown>()
const waiting = yield* Deferred.await(initialization).pipe(Effect.exit, Effect.forkChild)
yield* forwardInitializationFailure(initialization)(Effect.die(failure)).pipe(Effect.exit)
return yield* Fiber.join(waiting)
}),
)
expectFailure(exit)
})
})

View File

@@ -0,0 +1,367 @@
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"
import type { Event } from "electron"
import { app, BrowserWindow } from "electron"
import { Deferred, Effect, Fiber } from "effect"
import contextMenu from "electron-context-menu"
import type { ServerReadyData } from "../preload/types"
import { checkAppExists, resolveAppPath } from "./apps"
import { CHANNEL } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
import { forwardInitializationFailure } from "./initialization"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import {
getDefaultServerUrl,
preferAppEnv,
setDefaultServerUrl,
spawnLocalServer,
type SidecarListener,
} from "./server"
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
import {
createMainWindow,
registerRendererProtocol,
setRelaunchHandler,
setBackgroundColor,
setDockIcon,
} from "./windows"
import { createWslServersController } from "./wsl/servers"
import { registerWslIpcHandlers } from "./wsl/ipc"
import { spawnWslSidecar } from "./wsl/sidecar"
import { migrate } from "./migrate"
const APP_NAMES: Record<string, string> = {
dev: "OpenCode Dev",
beta: "OpenCode Beta",
prod: "OpenCode",
}
const APP_IDS: Record<string, string> = {
dev: "ai.opencode.desktop.dev",
beta: "ai.opencode.desktop.beta",
prod: "ai.opencode.desktop",
}
const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
let logger: ReturnType<typeof initLogging>
let mainWindow: BrowserWindow | null = null
let server: SidecarListener | null = null
const pendingDeepLinks: string[] = []
function useEnvProxy() {
try {
// Electron 41.2 runs Node 24.14.1; latest @types/node@24 is 24.12.2.
;(http as any).setGlobalProxyFromEnv()
} catch (error) {
logger.warn("failed to load proxy environment", error)
}
}
function emitDeepLinks(urls: string[]) {
if (urls.length === 0) return
pendingDeepLinks.push(...urls)
if (mainWindow) sendDeepLinks(mainWindow, 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) => {
const items = (process.env[key] ?? "")
.split(",")
.map((value: string) => value.trim())
.filter((value: string) => Boolean(value))
for (const host of loopback) {
if (items.some((value: string) => value.toLowerCase() === host)) continue
items.push(host)
}
process.env[key] = items.join(",")
}
upsert("NO_PROXY")
upsert("no_proxy")
}
const main = Effect.gen(function* () {
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
// on macOS apps run in `/` which can cause issues with ripgrep
try {
process.chdir(homedir())
} catch {}
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
const onboardingTestRoot = ((): string | undefined => {
if (!TEST_ONBOARDING) return
const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
rmSync(root, { recursive: true, force: true })
;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
mkdirSync(join(root, dir), { recursive: true }),
)
process.env.OPENCODE_DB = ":memory:"
process.env.XDG_DATA_HOME = join(root, "data")
process.env.XDG_CONFIG_HOME = join(root, "config")
process.env.XDG_CACHE_HOME = join(root, "cache")
process.env.XDG_STATE_HOME = join(root, "state")
return root
})()
app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
app.setAppUserModelId(appId)
app.setPath(
"userData",
onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId),
)
if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
logger = initLogging()
initCrashReporter()
const wslServers = createWslServersController(
app.getVersion(),
async (distro) => {
logger.log("spawning wsl sidecar", { distro })
return spawnWslSidecar(distro, {
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
})
},
{
logger: {
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
},
)
const stopSidecars = async () => {
await killSidecar()
wslServers.stopAll()
}
const relaunch = () => {
void stopSidecars().finally(() => {
app.relaunch()
app.exit(0)
})
}
try {
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
} catch (error) {
logger.warn("failed to load system certificates", error)
}
logger.log("app starting", {
version: app.getVersion(),
packaged: app.isPackaged,
onboardingTest: Boolean(onboardingTestRoot),
})
ensureLoopbackNoProxy()
useEnvProxy()
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
const features = app.commandLine.getSwitchValue("enable-features")
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
if (!app.requestSingleInstanceLock()) {
app.quit()
return
}
preferAppEnv(app.getPath("userData"))
app.on("second-instance", (_event: Event, argv: string[]) => {
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
if (urls.length) {
logger.log("deep link received via second-instance", { urls })
emitDeepLinks(urls)
}
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
}
})
app.on("open-url", (event: Event, url: string) => {
event.preventDefault()
logger.log("deep link received via open-url", { url })
emitDeepLinks([url])
})
app.on("before-quit", () => {
void stopSidecars()
})
app.on("will-quit", () => {
void stopSidecars()
})
app.on("child-process-gone", (_event, details) => {
writeLog("utility", "child process gone", { details }, "error")
})
app.on("render-process-gone", (_event, webContents, details) => {
writeLog("window", "app render process gone", { url: webContents.getURL(), details }, "error")
})
setRelaunchHandler(() => {
relaunch()
})
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
void stopSidecars().finally(() => app.exit(0))
})
}
const serverReady = Deferred.makeUnsafe<ServerReadyData, unknown>()
yield* Effect.promise(() => app.whenReady())
if (!TEST_ONBOARDING) migrate()
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()
const updater = setupAutoUpdater(stopSidecars)
registerIpcHandlers({
killSidecar: () => killSidecar(),
relaunch,
awaitInitialization: Effect.fnUntraced(
function* () {
logger.log("awaiting server ready")
const res = yield* Deferred.await(serverReady)
logger.log("server ready", { url: res.url })
return res
},
(e) => Effect.runPromise(e),
),
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
getDefaultServerUrl: () => getDefaultServerUrl(),
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
getDisplayBackend: async () => null,
setDisplayBackend: async () => undefined,
parseMarkdown: async (markdown) => parseMarkdown(markdown),
checkAppExists: (appName) => checkAppExists(appName),
resolveAppPath: async (appName) => resolveAppPath(appName),
updater,
showUpdater: () => showUpdaterDialog(updater, true),
setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(),
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
})
registerWslIpcHandlers(wslServers)
void updater.start()
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
updateTimer.unref()
app.once("will-quit", () => clearInterval(updateTimer))
yield* Effect.promise(() => startNetLog()).pipe(
Effect.catch((error) =>
Effect.sync(() => {
logger.warn("failed to start net log", error)
}),
),
)
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<number, unknown>()
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
yield* Deferred.succeed(serverReady, {
url,
username: "opencode",
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)
yield* Fiber.await(loadingTask)
mainWindow = createMainWindow()
if (mainWindow) {
createMenu({
trigger: (id) => {
const win = BrowserWindow.getFocusedWindow() ?? mainWindow
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => {
void showUpdaterDialog(updater, true)
},
relaunch: () => {
relaunch()
},
})
}
})
Effect.runFork(main)

View File

@@ -0,0 +1,6 @@
import { Deferred, Effect } from "effect"
export function forwardInitializationFailure<A>(initialization: Deferred.Deferred<A, unknown>) {
return <B, E, R>(effect: Effect.Effect<B, E, R>) =>
effect.pipe(Effect.tapCause((cause) => Deferred.failCause(initialization, cause)))
}

View File

@@ -0,0 +1,242 @@
import { execFile } from "node:child_process"
import { stat } from "node:fs/promises"
import { basename } from "node:path"
import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore } from "./store"
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
import type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
}
const pickedFiles = createPickedFileAuthorizations()
type Deps = {
killSidecar: () => Promise<void> | void
relaunch: () => void
awaitInitialization: () => Promise<ServerReadyData>
consumeInitialDeepLinks: () => Promise<string[]> | string[]
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
getDisplayBackend: () => Promise<string | null>
setDisplayBackend: (backend: string | null) => Promise<void> | void
parseMarkdown: (markdown: string) => Promise<string> | string
checkAppExists: (appName: string) => Promise<boolean> | boolean
resolveAppPath: (appName: string) => Promise<string | null>
updater: UpdaterController
showUpdater: () => Promise<void> | void
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
}
export function registerIpcHandlers(deps: Deps) {
const updaterSubscriptions = createUpdaterSubscriptions()
app.once("will-quit", updaterSubscriptions.clear)
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("await-initialization", () => deps.awaitInitialization())
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
deps.setDefaultServerUrl(url),
)
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
deps.setDisplayBackend(backend),
)
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
ipcMain.handle("updater-subscribe", (event) => {
const id = event.sender.id
updaterSubscriptions.set(
id,
deps.updater.subscribe((state) => {
if (event.sender.isDestroyed()) return updaterSubscriptions.delete(id)
event.sender.send("updater-state", state)
}),
)
event.sender.once("destroyed", () => updaterSubscriptions.delete(id))
})
ipcMain.handle("updater-unsubscribe", (event) => updaterSubscriptions.delete(event.sender.id))
ipcMain.handle("updater-check", () => deps.updater.check())
ipcMain.handle("updater-install", () => deps.updater.install())
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
deps.recordFatalRendererError(error),
)
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
try {
const store = getStore(name)
const value = store.get(key)
if (value === undefined || value === null) return null
return typeof value === "string" ? value : JSON.stringify(value)
} catch {
return null
}
})
ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => {
getStore(name).set(key, value)
})
ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
getStore(name).delete(key)
})
ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
getStore(name).clear()
})
ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => {
const store = getStore(name)
return Object.keys(store.store)
})
ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => {
const store = getStore(name)
return Object.keys(store.store).length
})
ipcMain.handle(
"open-directory-picker",
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
title: opts?.title ?? "Choose a folder",
defaultPath: opts?.defaultPath,
})
if (result.canceled) return null
return opts?.multiple ? result.filePaths : result.filePaths[0]
},
)
ipcMain.handle(
"open-file-picker",
async (
event: IpcMainInvokeEvent,
opts?: { multiple?: boolean; title?: string; defaultPath?: string; extensions?: string[] },
) => {
const result = await dialog.showOpenDialog({
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
title: opts?.title ?? "Choose a file",
defaultPath: opts?.defaultPath,
filters: pickerFilters(opts?.extensions),
})
if (result.canceled) return null
const files = await Promise.all(
result.filePaths.map(async (filePath) => ({
path: filePath,
name: basename(filePath),
size: (await stat(filePath)).size,
})),
)
assertAttachmentBudget(files)
const token = pickedFiles.add(event.sender.id, result.filePaths)
return { token, files }
},
)
ipcMain.handle("read-picked-file", async (event: IpcMainInvokeEvent, token: string, filePath: string) => {
return pickedFiles.read(event.sender.id, token, filePath)
})
ipcMain.handle("release-picked-files", (event: IpcMainInvokeEvent, token: string) => {
pickedFiles.release(event.sender.id, token)
})
ipcMain.handle(
"save-file-picker",
async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
const result = await dialog.showSaveDialog({
title: opts?.title ?? "Save file",
defaultPath: opts?.defaultPath,
})
if (result.canceled) return null
return result.filePath ?? null
},
)
ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
void shell.openExternal(url)
})
ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
if (!app) return shell.openPath(path)
await new Promise<void>((resolve, reject) => {
const [cmd, args] =
process.platform === "darwin" ? (["open", ["-a", app, path]] as const) : ([app, [path]] as const)
execFile(cmd, args, (err) => (err ? reject(err) : resolve()))
})
})
ipcMain.handle("read-clipboard-image", () => {
const image = clipboard.readImage()
if (image.isEmpty()) return null
const buffer = image.toPNG().buffer
const size = image.getSize()
return { buffer, width: size.width, height: size.height }
})
ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => {
new Notification({ title, body }).show()
})
ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFocused() ?? false
})
ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.focus()
})
ipcMain.handle("show-window", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.show()
})
ipcMain.on("relaunch", () => {
deps.relaunch()
})
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => {
event.sender.setZoomFactor(factor)
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
updateTitlebar(win)
})
ipcMain.handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled())
ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => {
setPinchZoomEnabled(enabled)
})
ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
setTitlebar(win, theme)
})
ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => {
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, {
checkForUpdates: () => void deps.showUpdater(),
relaunch: deps.relaunch,
})
})
}
export function sendMenuCommand(win: BrowserWindow, id: string) {
win.webContents.send("menu-command", id)
}
export function sendDeepLinks(win: BrowserWindow, urls: string[]) {
win.webContents.send("deep-link", urls)
}

View File

@@ -0,0 +1,205 @@
import { MainLogger } from "electron-log"
import log from "electron-log/main.js"
import { app, crashReporter, netLog, shell } from "electron"
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"
import { ZipWriter, BlobWriter, BlobReader } from "@zip.js/zip.js"
import { dirname, join } from "node:path"
import { homedir } from "node:os"
const MAX_LOG_AGE_DAYS = 7
const TAIL_LINES = 1000
const EXPORT_WINDOW = 24 * 60 * 60 * 1000
const MAX_EXPORT_FILE_SIZE = 50 * 1024 * 1024
const NET_LOG_SIZE = 20 * 1024 * 1024
let root = ""
let run = ""
let netLogPath: string | undefined
let logger: MainLogger
export const getLogger = () => logger
export function initLogging() {
initRunDirectory()
log.transports.file.maxSize = 5 * 1024 * 1024
log.transports.file.resolvePathFn = (_vars, message) =>
join(
run,
`${safeLogName(message?.scope ?? (message?.variables?.processType === "renderer" ? "renderer" : "main"))}.log`,
)
log.initialize({ preload: false, spyRendererConsole: true })
initConsoleTransport()
cleanup()
return (logger = log)
}
export function initCrashReporter() {
const dir = join(app.getPath("userData"), "Crashpad")
mkdirSync(dir, { recursive: true })
app.setPath("crashDumps", dir)
crashReporter.start({ uploadToServer: false, compress: true })
write("crash", "crash reporter started", { path: dir })
}
export async function startNetLog() {
if (netLog.currentlyLogging) return
netLogPath = join(run, "network.netlog")
await netLog.startLogging(netLogPath, { captureMode: "default", maxFileSize: NET_LOG_SIZE })
write("network", "net log started", { path: netLogPath })
}
export async function exportDebugLogs() {
const restartNetLog = netLog.currentlyLogging
if (restartNetLog) {
await netLog.stopLogging().catch((error) => write("network", "failed to stop net log", { error }))
}
const output = join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`)
try {
write("main", "exporting debug logs", { output })
await writeZip(output, [
{ name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(), null, 2)) },
...collect(root, "desktop"),
...serverLogRoots().flatMap((dir, i) => collect(dir, `server-${i + 1}`)),
...collect(app.getPath("crashDumps"), "crashpad"),
])
shell.showItemInFolder(output)
return output
} finally {
if (restartNetLog) {
await startNetLog().catch((error) => write("network", "failed to restart net log", { error }))
}
}
}
export function write(
name: string,
message: string,
extra?: Record<string, unknown>,
level: "info" | "warn" | "error" = "info",
) {
if (!run) return
const scoped = log.scope(safeLogName(name))
if (extra !== undefined) {
scoped[level](message, extra)
return
}
scoped[level](message)
}
export function tail(): string {
try {
const path = log.transports.file.getFile().path
const contents = readFileSync(path, "utf8")
const lines = contents.split("\n")
return lines.slice(Math.max(0, lines.length - TAIL_LINES)).join("\n")
} catch {
return ""
}
}
function initRunDirectory() {
root = join(app.getPath("userData"), "logs")
run = join(root, stamp())
mkdirSync(run, { recursive: true })
}
function stamp() {
return new Date()
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d+Z$/, "")
}
function safeLogName(name: string) {
return name.replace(/[^a-z0-9_.-]/gi, "_") || "main"
}
function cleanup() {
const dir = root || dirname(log.transports.file.getFile().path)
const cutoff = Date.now() - MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000
for (const entry of readdirSync(dir)) {
const file = join(dir, entry)
try {
const info = statSync(file)
if (info.mtimeMs < cutoff) rmSync(file, { recursive: true, force: true })
} catch {
continue
}
}
}
function manifest() {
return {
generated: new Date().toISOString(),
version: app.getVersion(),
name: app.getName(),
packaged: app.isPackaged,
platform: process.platform,
arch: process.arch,
versions: process.versions,
uptime: process.uptime(),
userData: app.getPath("userData"),
logs: root,
currentRun: run,
crashDumps: app.getPath("crashDumps"),
serverLogs: serverLogRoots(),
netLog: netLogPath,
}
}
function serverLogRoots() {
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
return [...new Set([join(xdgData, "opencode", "log"), join(app.getPath("userData"), "opencode", "log")])]
}
type Entry = { name: string; path?: string; data?: Buffer }
function collect(dir: string, prefix: string): Entry[] {
if (!existsSync(dir)) return []
const cutoff = Date.now() - EXPORT_WINDOW
const result: Entry[] = []
const walk = (current: string) => {
for (const entry of readdirSync(current)) {
const file = join(current, entry)
const info = statSync(file)
if (info.isDirectory()) {
walk(file)
continue
}
if (info.mtimeMs < cutoff) continue
if (info.size > MAX_EXPORT_FILE_SIZE) continue
if (file.endsWith(".heapsnapshot")) continue
result.push({ name: join(prefix, file.slice(dir.length + 1)).replace(/\\/g, "/"), path: file })
}
}
walk(dir)
return result
}
async function writeZip(output: string, entries: Entry[]) {
const writer = new ZipWriter(new BlobWriter("application/zip"))
for (const entry of entries) {
const data = entry.data ?? readFileSync(entry.path!)
await writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(data)])))
}
const zip = await writer.close()
writeFileSync(output, Buffer.from(await zip.arrayBuffer()))
}
function initConsoleTransport() {
const write = log.transports.console.writeFn.bind(log.transports.console)
log.transports.console.writeFn = (options) => {
try {
write(options)
} catch (err) {
if (!isBrokenPipe(err)) throw err
log.transports.console.level = false
}
}
}
function isBrokenPipe(err: unknown) {
return typeof err === "object" && err !== null && "code" in err && err.code === "EPIPE"
}

View File

@@ -0,0 +1,16 @@
import { marked, type Tokens } from "marked"
const renderer = new marked.Renderer()
renderer.link = ({ href, title, text }: Tokens.Link) => {
const titleAttr = title ? ` title="${title}"` : ""
return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
}
export function parseMarkdown(input: string) {
return marked(input, {
renderer,
breaks: false,
gfm: true,
})
}

View File

@@ -0,0 +1,67 @@
import { BrowserWindow, Menu, shell } from "electron"
import type { MenuItemConstructorOptions } from "electron"
import {
DESKTOP_MENU,
desktopMenuVisible,
type DesktopMenuEntry,
type DesktopMenuRole,
} from "@opencode-ai/app/desktop-menu"
import { UPDATER_ENABLED } from "./constants"
import { runDesktopMenuAction } from "./desktop-menu-actions"
type Deps = {
trigger: (id: string) => void
checkForUpdates: () => void
relaunch: () => void
}
export function createMenu(deps: Deps) {
if (process.platform !== "darwin") return
const template = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "macos")).map((menu) => {
if (menu.role) return { role: nativeRole(menu.role) }
return {
label: menu.label,
submenu: menu.items
?.filter((entry) => desktopMenuVisible(entry, "macos"))
.map((entry) => nativeItem(entry, deps)),
}
})
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
if (entry.type === "separator") return { type: "separator" }
if (entry.role) return { role: nativeRole(entry.role) }
const item: MenuItemConstructorOptions = {
label: entry.label,
accelerator: entry.accelerator?.macos,
enabled: entry.enabled === "updater" ? UPDATER_ENABLED : undefined,
}
if (entry.command) {
const command = entry.command
item.click = () => deps.trigger(command)
}
if (entry.action) {
const action = entry.action
item.click = () =>
runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, {
checkForUpdates: deps.checkForUpdates,
relaunch: deps.relaunch,
})
}
if (entry.href) {
const href = entry.href
item.click = () => shell.openExternal(href)
}
return item
}
function nativeRole(role: DesktopMenuRole) {
return role as NonNullable<MenuItemConstructorOptions["role"]>
}

View File

@@ -0,0 +1,91 @@
import { app } from "electron"
import log from "electron-log/main.js"
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { CHANNEL } from "./constants"
import { getStore } from "./store"
const TAURI_MIGRATED_KEY = "tauriMigrated"
// Resolve the directory where Tauri stored its .dat files for the given app identifier.
// Mirrors Tauri's AppLocalData / AppData resolution per OS.
function tauriDir(id: string) {
switch (process.platform) {
case "darwin":
return join(homedir(), "Library", "Application Support", id)
case "win32":
return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), id)
default:
return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), id)
}
}
// The Tauri app identifier changes between dev/beta/prod builds.
const TAURI_APP_IDS: Record<string, string> = {
dev: "ai.opencode.desktop.dev",
beta: "ai.opencode.desktop.beta",
prod: "ai.opencode.desktop",
}
function tauriAppId() {
return app.isPackaged ? TAURI_APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
}
// Migrate a single Tauri .dat file into the corresponding electron-store.
// `opencode.settings.dat` is special: it maps to the `opencode.settings` store
// (the electron-store name without the `.dat` extension). All other .dat files
// keep their full filename as the electron-store name so they match what the
// renderer already passes via IPC (e.g. `"default.dat"`, `"opencode.global.dat"`).
function migrateFile(datPath: string, filename: string) {
let data: Record<string, unknown>
try {
data = JSON.parse(readFileSync(datPath, "utf-8"))
} catch (err) {
log.warn("tauri migration: failed to parse", filename, err)
return
}
// opencode.settings.dat → the electron settings store ("opencode.settings").
// All other .dat files keep their full filename as the store name so they match
// what the renderer passes via IPC (e.g. "default.dat", "opencode.global.dat").
const storeName = filename === "opencode.settings.dat" ? "opencode.settings" : filename
const target = getStore(storeName)
const migrated: string[] = []
const skipped: string[] = []
for (const [key, value] of Object.entries(data)) {
// Don't overwrite values the user has already set in the Electron app.
if (target.has(key)) {
skipped.push(key)
continue
}
target.set(key, value)
migrated.push(key)
}
log.log("tauri migration: migrated", filename, "→", storeName, { migrated, skipped })
}
export function migrate() {
if (getStore().get(TAURI_MIGRATED_KEY)) {
log.log("tauri migration: already done, skipping")
return
}
const dir = tauriDir(tauriAppId())
log.log("tauri migration: starting", { dir })
if (!existsSync(dir)) {
log.log("tauri migration: no tauri data directory found, nothing to migrate")
getStore().set(TAURI_MIGRATED_KEY, true)
return
}
for (const filename of readdirSync(dir)) {
if (!filename.endsWith(".dat")) continue
migrateFile(join(dir, filename), filename)
}
log.log("tauri migration: complete")
getStore().set(TAURI_MIGRATED_KEY, true)
}

View File

@@ -0,0 +1,237 @@
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { app, utilityProcess } from "electron"
import type { Details } from "electron"
import { getLogger } from "./logging"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store"
import { DEFAULT_SERVER_URL_KEY } from "./store-keys"
export type HealthCheck = { wait: Promise<void> }
type SidecarMessage =
| { type: "ready" }
| { type: "stopped" }
| { type: "error"; error: { message: string; stack?: string } }
export type SidecarListener = { stop: () => Promise<void> }
const SIDECAR_SERVICE_NAME = "opencode server"
const SIDECAR_START_STALL_TIMEOUT = 60_000
const SIDECAR_STOP_TIMEOUT = 6_000
type SpawnLocalServerOptions = {
userDataPath: string
onStdout?: (message: string) => void
onStderr?: (message: string) => void
onExit?: (code: number) => void
}
export function getDefaultServerUrl(): string | null {
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
return typeof value === "string" ? value : null
}
export function setDefaultServerUrl(url: string | null) {
if (url) {
getStore().set(DEFAULT_SERVER_URL_KEY, url)
return
}
getStore().delete(DEFAULT_SERVER_URL_KEY)
}
export function preferAppEnv(userDataPath: string) {
const shell = process.platform === "win32" ? null : getUserShell()
Object.assign(process.env, {
...(shell ? loadShellEnv(shell, getLogger()) : null),
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
})
}
export async function spawnLocalServer(
hostname: string,
port: number,
password: string,
options: SpawnLocalServerOptions,
) {
const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js")
const child = utilityProcess.fork(sidecar, [], {
cwd: process.cwd(),
env: createSidecarEnv(),
serviceName: SIDECAR_SERVICE_NAME,
stdio: "pipe",
})
let exited = false
const exit = defer<number>()
const onProcessGone = (_event: unknown, details: Details) => {
if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return
options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`)
}
app.on("child-process-gone", onProcessGone)
child.once("exit", (code) => {
exited = true
app.off("child-process-gone", onProcessGone)
options.onExit?.(code)
exit.resolve(code)
})
child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`))
child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd()))
child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd()))
await new Promise<void>((resolve, reject) => {
let done = false
let timeout: NodeJS.Timeout
const fail = (error: Error) => {
if (done) return
done = true
cleanup()
reject(error)
}
const refreshTimeout = () => {
clearTimeout(timeout)
timeout = setTimeout(() => {
fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`))
}, SIDECAR_START_STALL_TIMEOUT)
}
const onMessage = (message: SidecarMessage) => {
if (message.type === "ready") {
if (done) return
done = true
cleanup()
resolve()
return
}
if (message.type === "error") {
fail(Object.assign(new Error(message.error.message), { stack: message.error.stack }))
}
}
const onExit = (code: number) => {
fail(new Error(`Sidecar exited before ready with code ${code}`))
}
const cleanup = () => {
clearTimeout(timeout)
child.off("message", onMessage)
child.off("exit", onExit)
}
child.on("message", onMessage)
child.on("exit", onExit)
refreshTimeout()
child.postMessage({
type: "start",
hostname,
port,
password,
userDataPath: options.userDataPath,
})
}).catch((error) => {
if (!exited) child.kill()
throw error
})
const wait = (async () => {
const url = `http://${hostname}:${port}`
let healthy = false
const gone = exit.promise.then((code) => {
if (healthy) return
throw new Error(`Sidecar exited before health check passed with code ${code}`)
})
const ready = async () => {
while (true) {
await new Promise((resolve) => setTimeout(resolve, 100))
if (await checkHealth(url, password)) {
healthy = true
return
}
}
}
await Promise.race([ready(), gone])
})()
let stopping: Promise<void> | undefined
return {
listener: {
stop: () => {
if (stopping) return stopping
if (exited) return Promise.resolve()
child.postMessage({ type: "stop" })
stopping = Promise.race([
exit.promise.then(() => undefined),
delay(SIDECAR_STOP_TIMEOUT).then(() => {
if (!exited) child.kill()
}),
])
return stopping
},
},
health: { wait },
}
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
let healthUrl: URL
try {
healthUrl = new URL("/global/health", url)
} catch {
return false
}
const headers = new Headers()
if (password) {
const auth = Buffer.from(`opencode:${password}`).toString("base64")
headers.set("authorization", `Basic ${auth}`)
}
try {
const res = await fetch(healthUrl, {
method: "GET",
headers,
signal: AbortSignal.timeout(3000),
})
return res.ok
} catch {
return false
}
}
function createSidecarEnv(): Record<string, string> {
const env = Object.fromEntries(
Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),
)
delete env.DEBUG
if (process.platform === "linux") delete env.LD_PRELOAD
if (!app.isPackaged) env.OPENCODE_DISABLE_CHANNEL_DB = "1"
return env
}
function delay(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
function serializeError(error: unknown) {
if (error instanceof Error) return { message: error.message, stack: error.stack }
return { message: String(error) }
}
function defer<T>() {
let resolve!: (value: T) => void
let reject!: (error: Error) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}

View File

@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test"
import { isNushell, mergeShellEnv, parseShellEnv, resolveUserShell } from "./shell-env"
describe("shell env", () => {
test("parseShellEnv supports null-delimited pairs", () => {
const env = parseShellEnv(Buffer.from("PATH=/usr/bin:/bin\0FOO=bar=baz\0\0"))
expect(env.PATH).toBe("/usr/bin:/bin")
expect(env.FOO).toBe("bar=baz")
})
test("parseShellEnv ignores invalid entries", () => {
const env = parseShellEnv(Buffer.from("INVALID\0=empty\0OK=1\0"))
expect(Object.keys(env).length).toBe(1)
expect(env.OK).toBe("1")
})
test("mergeShellEnv keeps explicit overrides", () => {
const env = mergeShellEnv(
{
PATH: "/shell/path",
HOME: "/tmp/home",
},
{
PATH: "/desktop/path",
OPENCODE_CLIENT: "desktop",
},
)
expect(env.PATH).toBe("/desktop/path")
expect(env.HOME).toBe("/tmp/home")
expect(env.OPENCODE_CLIENT).toBe("desktop")
})
test("resolveUserShell falls back to the login shell before /bin/sh", () => {
expect(resolveUserShell("/custom/env-shell", "/bin/zsh")).toBe("/custom/env-shell")
expect(resolveUserShell(undefined, "/bin/zsh")).toBe("/bin/zsh")
expect(resolveUserShell(undefined, "unknown")).toBe("/bin/sh")
expect(resolveUserShell(undefined, undefined)).toBe("/bin/sh")
})
test("isNushell handles path and binary name", () => {
expect(isNushell("nu")).toBe(true)
expect(isNushell("/opt/homebrew/bin/nu")).toBe(true)
expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true)
expect(isNushell("/bin/zsh")).toBe(false)
})
})

View File

@@ -0,0 +1,101 @@
import { spawnSync } from "node:child_process"
import { userInfo } from "node:os"
import { basename } from "node:path"
const TIMEOUT = 5_000
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
type ShellEnvLogger = {
log: (message: string) => void
}
export function resolveUserShell(envShell: string | undefined, loginShell: string | null | undefined) {
const resolvedLoginShell = loginShell && loginShell !== "unknown" ? loginShell : undefined
return envShell || resolvedLoginShell || "/bin/sh"
}
export function getUserShell() {
try {
return resolveUserShell(process.env.SHELL, userInfo().shell)
} catch {
return resolveUserShell(process.env.SHELL, undefined)
}
}
export function parseShellEnv(out: Buffer) {
const env: Record<string, string> = {}
for (const line of out.toString("utf8").split("\0")) {
if (!line) continue
const ix = line.indexOf("=")
if (ix <= 0) continue
env[line.slice(0, ix)] = line.slice(ix + 1)
}
return env
}
function probe(shell: string, mode: "-il" | "-l"): Probe {
const out = spawnSync(shell, [mode, "-c", "env -0"], {
stdio: ["ignore", "pipe", "ignore"],
timeout: TIMEOUT,
windowsHide: true,
})
const err = out.error as NodeJS.ErrnoException | undefined
if (err) {
if (err.code === "ETIMEDOUT") return { type: "Timeout" }
console.log(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
return { type: "Unavailable" }
}
if (out.status !== 0) {
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(`[server] Shell env probe returned empty env for ${shell} ${mode}`)
return { type: "Unavailable" }
}
return { type: "Loaded", value: env }
}
export function isNushell(shell: string) {
const name = basename(shell).toLowerCase()
const raw = shell.toLowerCase()
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
}
export function loadShellEnv(shell: string, logger: ShellEnvLogger) {
if (isNushell(shell)) {
logger.log(`[server] Skipping shell env probe for nushell: ${shell}`)
return null
}
const interactive = probe(shell, "-il")
if (interactive.type === "Loaded") {
logger.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
return interactive.value
}
if (interactive.type === "Timeout") {
logger.log(`[server] Interactive shell env probe timed out: ${shell}`)
return null
}
const login = probe(shell, "-l")
if (login.type === "Loaded") {
logger.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
return login.value
}
logger.log(`[server] Falling back to app environment: ${shell}`)
return null
}
export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
return {
...shell,
...env,
}
}

View File

@@ -0,0 +1,157 @@
import * as http from "node:http"
import * as tls from "node:tls"
type NodeHttpWithEnvProxy = typeof http & {
setGlobalProxyFromEnv: () => void
}
type NodeTlsWithSystemCertificates = typeof tls & {
getCACertificates: (type: "default" | "system") => string[]
setDefaultCACertificates: (certificates: string[]) => void
}
type StartCommand = {
type: "start"
hostname: string
port: number
password: string
userDataPath: string
}
type StopCommand = { type: "stop" }
type SidecarCommand = StartCommand | StopCommand
type SidecarMessage =
| { type: "ready" }
| { type: "stopped" }
| { type: "error"; error: { message: string; stack?: string } }
type ParentPort = {
postMessage(message: SidecarMessage): void
on(event: "message", listener: (event: { data: unknown }) => void): void
}
type Listener = {
stop(close?: boolean): void | Promise<void>
}
const parentPort = getParentPort()
let listener: Listener | undefined
parentPort.on("message", (event) => {
const command = parseCommand(event.data)
if (!command) return
if (command.type === "stop") {
void stop()
return
}
void start(command)
})
async function start(command: StartCommand) {
try {
prepareSidecarEnv(command.password, command.userDataPath)
ensureLoopbackNoProxy()
useSystemCertificates()
useEnvProxy()
const { Server } = await import("virtual:opencode-server")
listener = await Server.listen({
port: command.port,
hostname: command.hostname,
username: "opencode",
password: command.password,
cors: ["oc://renderer"],
})
parentPort.postMessage({ type: "ready" })
} catch (error) {
parentPort.postMessage({ type: "error", error: serializeError(error) })
setImmediate(() => process.exit(1))
}
}
async function stop() {
try {
await listener?.stop()
} finally {
listener = undefined
parentPort.postMessage({ type: "stopped" })
setImmediate(() => process.exit(0))
}
}
function prepareSidecarEnv(password: string, userDataPath: string) {
Object.assign(process.env, {
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
})
}
function ensureLoopbackNoProxy() {
const loopback = ["127.0.0.1", "localhost", "::1"]
const upsert = (key: string) => {
const items = (process.env[key] ?? "")
.split(",")
.map((value: string) => value.trim())
.filter((value: string) => Boolean(value))
for (const host of loopback) {
if (items.some((value: string) => value.toLowerCase() === host)) continue
items.push(host)
}
process.env[key] = items.join(",")
}
upsert("NO_PROXY")
upsert("no_proxy")
}
function useSystemCertificates() {
try {
const nodeTls = tls as NodeTlsWithSystemCertificates
nodeTls.setDefaultCACertificates([
...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]),
])
} catch (error) {
console.warn("failed to load system certificates", error)
}
}
function useEnvProxy() {
try {
;(http as NodeHttpWithEnvProxy).setGlobalProxyFromEnv()
} catch (error) {
console.warn("failed to load proxy environment", error)
}
}
function parseCommand(value: unknown): SidecarCommand | undefined {
if (!value || typeof value !== "object") return
const command = value as Partial<StartCommand | StopCommand>
if (command.type === "stop") return { type: "stop" }
if (command.type !== "start") return
if (typeof command.hostname !== "string") return
if (typeof command.port !== "number") return
if (typeof command.password !== "string") return
if (typeof command.userDataPath !== "string") return
return {
type: "start",
hostname: command.hostname,
port: command.port,
password: command.password,
userDataPath: command.userDataPath,
}
}
function serializeError(error: unknown) {
if (error instanceof Error) return { message: error.message, stack: error.stack }
return { message: String(error) }
}
function getParentPort() {
const port = process.parentPort as ParentPort | undefined
if (!port) throw new Error("Sidecar parent port unavailable")
return port
}

View File

@@ -0,0 +1,4 @@
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"

View File

@@ -0,0 +1,23 @@
import Store from "electron-store"
import electron from "electron"
import { SETTINGS_STORE } from "./store-keys"
const cache = new Map<string, Store>()
// We cannot instantiate the electron-store at module load time because
// module import hoisting causes this to run before app.setPath("userData", ...)
// in index.ts has executed, which would result in files being written to the default directory
// (e.g. bad: %APPDATA%\@opencode-ai\desktop\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
export function getStore(name = SETTINGS_STORE) {
const cached = cache.get(name)
if (cached) return cached
const next = new Store({
name,
cwd: electron.app.getPath("userData"),
fileExtension: "",
accessPropertiesByDotNotation: false,
})
cache.set(name, next)
return next
}

View File

@@ -0,0 +1,69 @@
import type { BrowserWindow } from "electron"
import { write as writeLog } from "./logging"
const sampleInterval = 1000
const samplePeriod = 15000
export function createUnresponsiveSampler(win: BrowserWindow, name: string) {
let sampleTimer: ReturnType<typeof setTimeout> | undefined
let stopTimer: ReturnType<typeof setTimeout> | undefined
let sampling = false
const samples = new Map<string, number>()
const active = () => sampling && !win.isDestroyed() && !win.webContents.isDestroyed()
const clearTimers = () => {
if (sampleTimer) clearTimeout(sampleTimer)
if (stopTimer) clearTimeout(stopTimer)
sampleTimer = undefined
stopTimer = undefined
}
const schedule = () => {
sampleTimer = setTimeout(() => {
void collect()
}, sampleInterval)
}
const collect = async () => {
if (!active()) return
const stack = await win.webContents.mainFrame.collectJavaScriptCallStack().catch((error) => {
writeLog("window", "failed to collect unresponsive sample", { window: name, error }, "error")
return undefined
})
if (!active()) return
if (stack) samples.set(stack, (samples.get(stack) ?? 0) + 1)
schedule()
}
const stopAndFlush = () => {
const wasSampling = sampling
sampling = false
clearTimers()
if (samples.size === 0) return wasSampling
const entries = [...samples.entries()].sort((a, b) => b[1] - a[1])
const total = entries.reduce((sum, entry) => sum + entry[1], 0)
const message = [
"renderer unresponsive samples",
`Window: ${name}`,
`URL: ${win.isDestroyed() ? "<destroyed>" : win.webContents.getURL()}`,
...entries.map((entry) => `<${entry[1]}> ${entry[0]}`),
`Total Samples: ${total}`,
].join("\n")
writeLog("window", message, undefined, "error")
samples.clear()
return wasSampling
}
const start = () => {
if (sampling || win.isDestroyed() || win.webContents.isDestroyed() || win.webContents.isDevToolsOpened()) return
sampling = true
samples.clear()
schedule()
stopTimer = setTimeout(stopAndFlush, samplePeriod)
}
win.on("closed", stopAndFlush)
return { start, stopAndFlush }
}

View File

@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test"
import { createUpdaterController, type UpdaterBackend, type UpdaterReadyRecord } from "./updater-controller"
function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) {
const calls: string[] = []
const backend: UpdaterBackend = {
async checkForUpdates() {
calls.push("check")
return { isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }
},
async downloadUpdate() {
calls.push("download")
},
quitAndInstall() {
calls.push("install")
},
}
let ready = input?.ready
const controller = createUpdaterController({
enabled: true,
currentVersion: input?.currentVersion ?? "1.0.0",
backend,
persistence: {
get: () => ready,
set: (value) => {
ready = value
},
clear: () => {
ready = undefined
},
},
stop: async () => {
calls.push("stop")
},
})
return { controller, calls, getReady: () => ready }
}
describe("updater controller", () => {
test("checks, downloads, persists, and publishes one authoritative ready state", async () => {
const app = setup()
const states: ReturnType<typeof app.controller.getState>[] = []
app.controller.subscribe((state) => states.push(state))
await app.controller.start()
expect(app.calls).toEqual(["check", "download"])
expect(app.getReady()).toEqual({ version: "2.0.0" })
expect(states.map((state) => state.status)).toEqual(["idle", "checking", "downloading", "ready"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("revalidates a persisted target through the updater cache on launch", async () => {
const app = setup({ ready: { version: "2.0.0" } })
await app.controller.start()
expect(app.calls).toEqual(["check", "download"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("clears a target already installed before checking", async () => {
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } })
await app.controller.start()
expect(app.getReady()).toBeUndefined()
expect(app.calls).toEqual(["check"])
})
test("coalesces concurrent checks", async () => {
const app = setup()
await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()])
expect(app.calls).toEqual(["check", "download"])
})
test("returns to ready when quitAndInstall returns without exiting", async () => {
const app = setup()
await app.controller.start()
await app.controller.install()
expect(app.calls).toEqual(["check", "download", "stop", "install"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("returns to ready when installation cannot start", async () => {
const app = setup()
await app.controller.start()
const failed = createUpdaterController({
enabled: true,
currentVersion: "1.0.0",
backend: {
checkForUpdates: async () => ({ isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }),
downloadUpdate: async () => {},
quitAndInstall() {},
},
persistence: { get: () => undefined, set() {}, clear() {} },
stop: async () => {
throw new Error("stop failed")
},
})
await failed.start()
await expect(failed.install()).rejects.toThrow("stop failed")
expect(failed.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
})

View File

@@ -0,0 +1,97 @@
import type { UpdaterState } from "@opencode-ai/app/updater"
export type { UpdaterState } from "@opencode-ai/app/updater"
export type UpdaterReadyRecord = { version: string }
export type UpdaterBackend = {
checkForUpdates(): Promise<{ isUpdateAvailable?: boolean; updateInfo?: { version?: string } } | null | undefined>
downloadUpdate(): Promise<unknown>
quitAndInstall(): void
}
type UpdaterPersistence = {
get(): UpdaterReadyRecord | undefined | Promise<UpdaterReadyRecord | undefined>
set(value: UpdaterReadyRecord): void | Promise<void>
clear(): void | Promise<void>
}
export function createUpdaterController(input: {
enabled: boolean
currentVersion: string
backend: UpdaterBackend
persistence: UpdaterPersistence
stop: () => Promise<void>
log?: (message: string, data?: object) => void
}) {
let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" }
let pending: Promise<UpdaterState> | undefined
const listeners = new Set<(state: UpdaterState) => void>()
const transition = (next: UpdaterState) => {
input.log?.("updater state changed", { from: state.status, to: next.status })
state = next
listeners.forEach((listener) => listener(state))
return state
}
const check = () => {
if (!input.enabled) return Promise.resolve(state)
if (state.status === "ready") return Promise.resolve(state)
if (pending) return pending
pending = (async () => {
transition({ status: "checking" })
const result = await input.backend.checkForUpdates()
const version = result?.updateInfo?.version
if (!result?.isUpdateAvailable || !version || version === input.currentVersion) {
await input.persistence.clear()
return transition({ status: "up-to-date" })
}
transition({ status: "downloading", version })
await input.backend.downloadUpdate()
await input.persistence.set({ version })
return transition({ status: "ready", version })
})()
.catch((error) =>
transition({ status: "error", message: error instanceof Error ? error.message : String(error) }),
)
.finally(() => {
pending = undefined
})
return pending
}
return {
getState: () => state,
subscribe(listener: (state: UpdaterState) => void) {
listeners.add(listener)
listener(state)
return () => listeners.delete(listener)
},
async start() {
const ready = await input.persistence.get()
if (ready?.version === input.currentVersion) await input.persistence.clear()
return check()
},
check,
async install() {
if (state.status !== "ready") throw new Error("Update is not ready to install")
const version = state.version
transition({ status: "installing", version })
await input
.stop()
.then(() => {
input.backend.quitAndInstall()
transition({ status: "ready", version })
})
.catch((error) => {
transition({ status: "ready", version })
throw error
})
},
}
}
export type UpdaterController = ReturnType<typeof createUpdaterController>

View File

@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
describe("updater subscriptions", () => {
test("replaces the previous renderer subscription on reload", () => {
const subscriptions = createUpdaterSubscriptions()
const disposed: string[] = []
subscriptions.set(1, () => disposed.push("first"))
subscriptions.set(1, () => disposed.push("second"))
expect(disposed).toEqual(["first"])
subscriptions.delete(1)
expect(disposed).toEqual(["first", "second"])
})
})

View File

@@ -0,0 +1,20 @@
export function createUpdaterSubscriptions() {
const subscriptions = new Map<number, () => void>()
const remove = (id: number) => {
subscriptions.get(id)?.()
subscriptions.delete(id)
}
return {
set(id: number, unsubscribe: () => void) {
remove(id)
subscriptions.set(id, unsubscribe)
},
delete: remove,
clear() {
subscriptions.forEach((unsubscribe) => unsubscribe())
subscriptions.clear()
},
}
}

View File

@@ -0,0 +1,68 @@
import { app, dialog } from "electron"
import pkg from "electron-updater"
import { UPDATER_ENABLED } from "./constants"
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
import { getLogger } from "./logging"
import { getStore } from "./store"
const { autoUpdater } = pkg
const key = "ready"
export function setupAutoUpdater(stop: () => Promise<void>) {
const logger = getLogger()
autoUpdater.logger = logger
autoUpdater.channel = "latest"
autoUpdater.allowPrerelease = false
autoUpdater.allowDowngrade = true
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = false
logger.log("auto updater configured", {
channel: autoUpdater.channel,
allowPrerelease: autoUpdater.allowPrerelease,
allowDowngrade: autoUpdater.allowDowngrade,
currentVersion: app.getVersion(),
})
const store = getStore("opencode.updater")
return createUpdaterController({
enabled: UPDATER_ENABLED,
currentVersion: app.getVersion(),
backend: autoUpdater,
persistence: {
get() {
const value = store.get(key)
if (!value || typeof value !== "object" || !("version" in value) || typeof value.version !== "string") return
return { version: value.version } satisfies UpdaterReadyRecord
},
set: (value) => store.set(key, value),
clear: () => store.delete(key),
},
stop,
log: (message, data) => logger.log(message, data),
})
}
export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoUpdater>, alertOnFail: boolean) {
const state = await controller.check()
if (state.status === "error") {
if (!alertOnFail) return
await dialog.showMessageBox({ type: "error", message: "Update check failed.", title: "Update Error" })
return
}
if (state.status === "up-to-date") {
if (!alertOnFail) return
await dialog.showMessageBox({ type: "info", message: "You're up to date.", title: "No Updates" })
return
}
if (state.status !== "ready") return
const response = await dialog.showMessageBox({
type: "info",
message: `Update ${state.version} downloaded. Restart now?`,
title: "Update Ready",
buttons: ["Restart", "Later"],
defaultId: 0,
cancelId: 1,
})
if (response.response === 0) await controller.install()
}

View File

@@ -0,0 +1,427 @@
import windowState from "electron-window-state"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types"
import { exportDebugLogs, write as writeLog } from "./logging"
import { getStore } from "./store"
import { PINCH_ZOOM_ENABLED_KEY } from "./store-keys"
import { createUnresponsiveSampler } from "./unresponsive"
const root = dirname(fileURLToPath(import.meta.url))
const rendererRoot = join(root, "../renderer")
const rendererProtocol = "oc"
const rendererHost = "renderer"
const clipboardWritePermission = "clipboard-sanitized-write"
const notificationPermission = "notifications"
const rendererPermissions = new Set([clipboardWritePermission, notificationPermission])
const oc2Theme = oc2ThemeJson as DesktopTheme
const oc2Background = {
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
}
const documentPolicyHeader = "Document-Policy"
const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports"
protocol.registerSchemesAsPrivileged([
{
scheme: rendererProtocol,
privileges: {
secure: true,
standard: true,
supportFetchAPI: true,
},
},
])
let backgroundColor: string | undefined
let relaunchHandler = () => {
app.relaunch()
app.exit(0)
}
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
const titlebarHeight = 40
const maxZoomLevel = 10
const minZoomLevel = 0.2
export function setRelaunchHandler(handler: () => void) {
relaunchHandler = handler
}
export function setBackgroundColor(color: string) {
backgroundColor = color
BrowserWindow.getAllWindows().forEach((win) => win.setBackgroundColor(color))
}
export function getBackgroundColor(): string | undefined {
return backgroundColor
}
function iconsDir() {
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
}
function iconPath() {
const ext = process.platform === "win32" ? "ico" : "png"
return join(iconsDir(), `icon.${ext}`)
}
function tone() {
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
}
function defaultBackgroundColor() {
return oc2Background[tone()]
}
function overlay(theme: Partial<TitlebarTheme> = {}, zoom = 1) {
const mode = theme.mode ?? tone()
return {
color: "#00000000",
symbolColor: mode === "dark" ? "white" : "black",
height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)),
}
}
export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
titlebarThemes.set(win, theme)
updateTitlebar(win)
}
export function updateTitlebar(win: BrowserWindow) {
if (process.platform !== "win32") return
win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor()))
}
export function setPinchZoomEnabled(enabled: boolean) {
getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
for (const win of BrowserWindow.getAllWindows()) {
pinchZoomEnabled.set(win, enabled)
win.webContents.send("pinch-zoom-enabled-changed", enabled)
if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
updateZoom(win)
}
}
export function getPinchZoomEnabled() {
return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true
}
export function setDockIcon() {
if (process.platform !== "darwin") return
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
if (!icon.isEmpty()) app.dock?.setIcon(icon)
}
export function createMainWindow() {
const state = windowState({
defaultWidth: 1280,
defaultHeight: 800,
})
const mode = tone()
const win = new BrowserWindow({
x: state.x,
y: state.y,
width: state.width,
height: state.height,
show: false,
autoHideMenuBar: true,
title: "OpenCode",
icon: iconPath(),
backgroundColor: backgroundColor ?? defaultBackgroundColor(),
...(process.platform === "darwin"
? {
titleBarStyle: "hidden" as const,
trafficLightPosition: { x: 12, y: 14 },
}
: {}),
...(process.platform === "win32"
? {
frame: false,
titleBarStyle: "hidden" as const,
titleBarOverlay: overlay({ mode }),
}
: {}),
webPreferences: {
preload: join(root, "../preload/index.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
allowRendererPermissions(win)
wireWindowRecovery(win, "main")
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
const { requestHeaders } = details
upsertKeyValue(requestHeaders, "Access-Control-Allow-Origin", ["*"])
callback({ requestHeaders })
})
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
const { responseHeaders = {} } = details
addRendererHeaders(details.url, responseHeaders)
callback({ responseHeaders })
})
state.manage(win)
loadWindow(win, "index.html")
wireZoom(win)
win.once("ready-to-show", () => {
win.show()
})
return win
}
export function registerRendererProtocol() {
if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, async (request) => {
const url = new URL(request.url)
if (url.host !== rendererHost) {
writeLog("protocol", "rejected host", { url: request.url }, "warn")
return new Response("Not found", { status: 404 })
}
const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = relative(rendererRoot, file)
if (rel.startsWith("..") || isAbsolute(rel)) {
writeLog("protocol", "rejected path", { url: request.url, file }, "warn")
return new Response("Not found", { status: 404 })
}
try {
const response = await net.fetch(pathToFileURL(file).toString())
if (response.status >= 400) {
writeLog(
"protocol",
"fetch failed",
{
url: request.url,
file,
status: response.status,
statusText: response.statusText,
},
"error",
)
}
return addDocumentPolicy(response, file)
} catch (error) {
writeLog("protocol", "fetch error", { url: request.url, file, error }, "error")
return new Response("Not found", { status: 404 })
}
})
}
function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
const url = new URL(html, devUrl)
void win.loadURL(url.toString())
return
}
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
function wireWindowRecovery(win: BrowserWindow, name: string) {
let showing = false
const sampler = createUnresponsiveSampler(win, name)
const handle = async (button: string | undefined, wait: boolean) => {
if (button === "Export Logs") {
const sampling = sampler.stopAndFlush()
await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error }, "error"))
if (wait && sampling) sampler.start()
return true
}
if (button === "Relaunch") {
sampler.stopAndFlush()
relaunchHandler()
return false
}
if (button === "Quit") {
sampler.stopAndFlush()
app.quit()
}
return false
}
const show = async (message: string, detail: string, wait: boolean) => {
if (showing || win.isDestroyed()) return
showing = true
try {
while (!win.isDestroyed()) {
const buttons = wait ? ["Relaunch", "Export Logs", "Keep Waiting"] : ["Relaunch", "Export Logs", "Quit"]
const result = await dialog.showMessageBox(win, {
type: "warning",
buttons,
defaultId: 0,
cancelId: 2,
message,
detail,
})
if (await handle(buttons[result.response], wait)) continue
return
}
} finally {
showing = false
}
}
const failed = (
event: string,
errorCode: number,
errorDescription: string,
validatedURL: string,
isMainFrame: boolean,
) => {
writeLog(
"window",
"renderer load failed",
{
window: name,
event,
errorCode,
errorDescription,
validatedURL,
currentURL: win.webContents.getURL(),
isMainFrame,
},
"error",
)
if (!isMainFrame || errorCode === -3) return
void show(
"OpenCode failed to load",
[`Window: ${name}`, `URL: ${validatedURL}`, `Error: ${errorCode} ${errorDescription}`].join("\n"),
false,
)
}
win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
failed("did-fail-load", errorCode, errorDescription, validatedURL, isMainFrame)
})
win.webContents.on("did-fail-provisional-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
failed("did-fail-provisional-load", errorCode, errorDescription, validatedURL, isMainFrame)
})
win.webContents.on("render-process-gone", (_event, details) => {
sampler.stopAndFlush()
writeLog(
"window",
"renderer process gone",
{ window: name, currentURL: win.webContents.getURL(), details },
"error",
)
void show(
"OpenCode window terminated unexpectedly",
[`Window: ${name}`, `Reason: ${details.reason}`, `Code: ${details.exitCode ?? "<unknown>"}`].join("\n"),
false,
)
})
win.on("unresponsive", () => {
writeLog("window", "renderer unresponsive", { window: name, currentURL: win.webContents.getURL() }, "error")
sampler.start()
void show("OpenCode is not responding", "You can relaunch the app, open the logs, or keep waiting.", true)
})
win.on("responsive", () => {
writeLog("window", "renderer responsive", { window: name, currentURL: win.webContents.getURL() }, "error")
sampler.stopAndFlush()
})
win.webContents.on("console-message", (_event, level, message, line, sourceId) => {
if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) {
writeLog("pty", "console", { window: name, level, message, line, sourceId })
}
})
win.webContents.on("preload-error", (_event, preloadPath, error) => {
writeLog("preload", "preload error", { window: name, preloadPath, error }, "error")
})
}
function addDocumentPolicy(response: Response, file: string) {
if (!file.toLowerCase().endsWith(".html")) return response
const headers = new Headers(response.headers)
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
}
function allowRendererPermissions(win: BrowserWindow) {
win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
callback(
rendererPermissions.has(permission) &&
isTrustedRendererUrl(details.requestingUrl) &&
webContents.id === win.webContents.id,
)
})
win.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
if (!rendererPermissions.has(permission)) return false
if (webContents && webContents.id !== win.webContents.id) return false
return isTrustedRendererUrl(details.requestingUrl) || isTrustedRendererUrl(requestingOrigin)
})
}
function isTrustedRendererUrl(value?: string) {
return isRendererUrl(value)
}
function addRendererHeaders(value: string, headers: Record<string, any>) {
upsertKeyValue(headers, "Access-Control-Allow-Origin", ["*"])
upsertKeyValue(headers, "Access-Control-Allow-Headers", ["*"])
if (isRendererUrl(value, true)) upsertKeyValue(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy])
}
function isRendererUrl(value?: string, html = false) {
if (!value || !URL.canParse(value)) return false
const url = new URL(value)
if (html && !url.pathname.endsWith(".html")) return false
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
const devUrl = process.env.ELECTRON_RENDERER_URL
if (!devUrl || !URL.canParse(devUrl)) return false
return url.origin === new URL(devUrl).origin
}
function wireZoom(win: BrowserWindow) {
pinchZoomEnabled.set(win, getPinchZoomEnabled())
win.webContents.setZoomFactor(1)
win.webContents.on("zoom-changed", (event, zoomDirection) => {
event.preventDefault()
if (pinchZoomEnabled.get(win)) {
win.webContents.setZoomFactor(clampZoom(win.webContents.getZoomFactor() + (zoomDirection === "in" ? 0.2 : -0.2)))
updateZoom(win)
return
}
if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
updateZoom(win)
})
}
function clampZoom(value: number) {
return Math.min(Math.max(value, minZoomLevel), maxZoomLevel)
}
function updateZoom(win: BrowserWindow) {
updateTitlebar(win)
win.webContents.send("zoom-factor-changed", win.webContents.getZoomFactor())
}
function upsertKeyValue(obj: Record<string, any>, keyToChange: string, value: any) {
const keyToChangeLower = keyToChange.toLowerCase()
for (const key of Object.keys(obj)) {
if (key.toLowerCase() === keyToChangeLower) {
// Reassign old key
obj[key] = value
// Done
return
}
}
// Insert at end instead
obj[keyToChange] = value
}

View File

@@ -0,0 +1,107 @@
import { app, ipcMain } from "electron"
import type { IpcMainInvokeEvent } from "electron"
import type { WslServersController } from "./servers"
import { requireWslIpcString } from "./policy"
import type { WslServersState } from "../../preload/types"
export function registerWslIpcHandlers(controller: WslServersController) {
if (process.platform !== "win32") {
registerUnavailableWslIpcHandlers()
return
}
const subscriptions = new Map<number, () => void>()
const unsubscribe = (id: number) => {
const off = subscriptions.get(id)
if (!off) return
off()
subscriptions.delete(id)
}
app.once("will-quit", () => {
subscriptions.forEach((off) => off())
subscriptions.clear()
})
ipcMain.handle("wsl-servers-subscribe", (event) => {
const id = event.sender.id
if (subscriptions.has(id)) return
subscriptions.set(
id,
controller.subscribe((payload) => {
if (event.sender.isDestroyed()) {
unsubscribe(id)
return
}
event.sender.send("wsl-servers-event", payload)
}),
)
event.sender.once("destroyed", () => unsubscribe(id))
})
ipcMain.handle("wsl-servers-unsubscribe", (event) => unsubscribe(event.sender.id))
ipcMain.handle("wsl-servers-get-state", () => controller.getState())
ipcMain.handle("wsl-servers-probe-runtime", () => controller.probeRuntime())
ipcMain.handle("wsl-servers-refresh-distros", () => controller.refreshDistros())
ipcMain.handle("wsl-servers-install-wsl", () => controller.installWsl())
ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
controller.installDistro(requireWslIpcString("distro", name)),
)
ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) =>
controller.probeDistro(requireWslIpcString("distro", name)),
)
ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) =>
controller.probeOpencode(requireWslIpcString("distro", name)),
)
ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) =>
controller.installOpencode(requireWslIpcString("distro", name)),
)
ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) =>
controller.openTerminal(requireWslIpcString("distro", name)),
)
ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) =>
controller.addServer(requireWslIpcString("distro", distro)),
)
ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) =>
controller.removeServer(requireWslIpcString("server id", id)),
)
ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) =>
controller.startServer(requireWslIpcString("server id", id)),
)
}
function registerUnavailableWslIpcHandlers() {
const unavailable = () => {
throw new Error("WSL is only available on Windows")
}
const state = (): WslServersState => ({
runtime: {
available: false,
version: null,
error: "WSL is only available on Windows",
},
installed: [],
online: [],
distroProbes: {},
opencodeChecks: {},
pendingRestart: false,
servers: [],
job: null,
})
ipcMain.handle("wsl-servers-subscribe", (event) => {
event.sender.send("wsl-servers-event", { type: "state", state: state() })
})
ipcMain.handle("wsl-servers-unsubscribe", () => undefined)
ipcMain.handle("wsl-servers-get-state", () => state())
ipcMain.handle("wsl-servers-probe-runtime", unavailable)
ipcMain.handle("wsl-servers-refresh-distros", unavailable)
ipcMain.handle("wsl-servers-install-wsl", unavailable)
ipcMain.handle("wsl-servers-install-distro", unavailable)
ipcMain.handle("wsl-servers-probe-distro", unavailable)
ipcMain.handle("wsl-servers-probe-opencode", unavailable)
ipcMain.handle("wsl-servers-install-opencode", unavailable)
ipcMain.handle("wsl-servers-open-terminal", unavailable)
ipcMain.handle("wsl-servers-add", unavailable)
ipcMain.handle("wsl-servers-remove", unavailable)
ipcMain.handle("wsl-servers-start", unavailable)
}

View File

@@ -0,0 +1,26 @@
import type { WslDistroProbe, WslOpencodeCheck, WslServerItem } from "../../preload/types"
export function wslServerIdToRestart(servers: WslServerItem[], distro: string) {
return servers.find((item) => item.config.distro === distro)?.config.id
}
export function clearWslDistroState(
distroProbes: Record<string, WslDistroProbe>,
opencodeChecks: Record<string, WslOpencodeCheck>,
distro: string,
) {
const nextDistroProbes = { ...distroProbes }
const nextOpencodeChecks = { ...opencodeChecks }
delete nextDistroProbes[distro]
delete nextOpencodeChecks[distro]
return { distroProbes: nextDistroProbes, opencodeChecks: nextOpencodeChecks }
}
export function wslTerminalArgs(distro?: string | null) {
return ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])]
}
export function requireWslIpcString(name: string, value: unknown) {
if (typeof value === "string" && value.length > 0) return value
throw new Error(`Invalid ${name}`)
}

View File

@@ -0,0 +1,400 @@
import { spawn } from "node:child_process"
import { existsSync } from "node:fs"
import { join } from "node:path"
import * as pty from "@lydell/node-pty"
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types"
import { wslTerminalArgs } from "./policy"
export type WslCommandLine = {
stream: "stdout" | "stderr"
text: string
}
export type WslCommandResult = {
code: number | null
signal: NodeJS.Signals | null
stdout: string
stderr: string
}
export type RunWslOptions = {
signal?: AbortSignal
/**
* Ceiling on how long we wait for the child process to exit. When the
* LXSS service or a specific distro wedges (e.g. Ubuntu-24.04 with a
* pending first-run prompt), `wsl.exe` never returns and any command
* that doesn't specify a timeout hangs the entire startup flow. Default
* is 20s — enough for slow cold-starts, short enough to fail fast on
* a wedge. Callers can override for longer-running jobs.
*/
timeoutMs?: number
}
const DEFAULT_WSL_TIMEOUT_MS = 20_000
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
export function wslArgs(args: string[], distro?: string | null, user?: string | null) {
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args]
}
export function runWsl(args: string[], opts: RunWslOptions = {}) {
return runCommand("wsl", args, opts)
}
function runPowerShell(command: string, opts: RunWslOptions = {}) {
return runCommand(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command],
opts,
)
}
function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
return new Promise<WslCommandResult>((resolve, reject) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
signal: opts.signal,
})
// Guard every wsl.exe invocation with a timeout. When the distro or
// the LXSS service is wedged (Ubuntu first-run state, Windows update
// pending, etc.) wsl.exe produces no output and never exits; without
// this the whole sidecar spawn flow stalls the app forever.
const timeoutMs = opts.timeoutMs ?? DEFAULT_WSL_TIMEOUT_MS
const timeoutId = setTimeout(() => {
try {
child.kill()
} catch {
/* ignore */
}
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
}, timeoutMs)
let stdout = ""
let stderr = ""
const stdoutDecoder = createOutputDecoder()
const stderrDecoder = createOutputDecoder()
const append = (stream: WslCommandLine["stream"], chunk: string) => {
if (!chunk) return
if (stream === "stdout") {
stdout += chunk
return
}
stderr += chunk
}
child.stdout.on("data", (chunk: Buffer) => {
append("stdout", stdoutDecoder.decode(chunk))
})
child.stdout.on("end", () => {
append("stdout", stdoutDecoder.flush())
})
child.stderr.on("data", (chunk: Buffer) => {
append("stderr", stderrDecoder.decode(chunk))
})
child.stderr.on("end", () => {
append("stderr", stderrDecoder.flush())
})
child.once("error", (error) => {
clearTimeout(timeoutId)
reject(error)
})
child.once("close", (code, signal) => {
clearTimeout(timeoutId)
resolve({ code, signal, stdout, stderr })
})
})
}
function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) {
return new Promise<WslCommandResult>((resolve, reject) => {
const child = pty.spawn(command, args, {
name: "xterm-color",
cols: 80,
rows: 24,
cwd: process.cwd(),
env: process.env,
useConpty: true,
})
let settled = false
let stdout = ""
const cleanup = () => {
clearTimeout(timeoutId)
abortCleanup?.()
}
const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs
const timeoutId = setTimeout(() => {
try {
child.kill()
} catch {
/* ignore */
}
if (settled) return
settled = true
cleanup()
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
}, timeoutMs)
const abortHandler = () => {
try {
child.kill()
} catch {
/* ignore */
}
if (settled) return
settled = true
cleanup()
reject(new DOMException("Aborted", "AbortError"))
}
const abortCleanup = opts.signal
? (() => {
opts.signal?.addEventListener("abort", abortHandler, { once: true })
return () => opts.signal?.removeEventListener("abort", abortHandler)
})()
: undefined
child.onData((data: string) => {
stdout += data
})
child.onExit((event: { exitCode: number }) => {
if (settled) return
settled = true
cleanup()
resolve({ code: event.exitCode, signal: null, stdout, stderr: "" })
})
})
}
function createOutputDecoder() {
let decoder: TextDecoder | undefined
return {
decode(chunk: Buffer) {
decoder ??= new TextDecoder(detectOutputEncoding(chunk))
return decoder.decode(chunk, { stream: true })
},
flush() {
return decoder?.decode() ?? ""
},
}
}
function detectOutputEncoding(chunk: Uint8Array) {
if (chunk[0] === 0xff && chunk[1] === 0xfe) return "utf-16le"
const pairs = Math.floor(chunk.length / 2)
if (pairs < 2) return "utf-8"
const oddZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2 + 1] === 0).length
const evenZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2] === 0).length
return oddZeroes >= Math.ceil(pairs / 3) && evenZeroes * 2 <= oddZeroes ? "utf-16le" : "utf-8"
}
export function runWslInDistro(args: string[], distro?: string | null, opts?: RunWslOptions) {
return runWsl(wslArgs(args, distro), opts)
}
export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) {
return runWslInDistro(["sh", "-lc", script], distro, opts)
}
export async function probeWslRuntime(opts?: RunWslOptions): Promise<WslRuntimeCheck> {
const version = await runWsl(["--version"], opts).catch((error) => ({
code: 1,
signal: null,
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
}))
if (version.code !== 0) {
return {
available: false,
version: null,
error: summarize(version.stderr || version.stdout) || "WSL is unavailable",
}
}
return {
available: true,
version: firstLine(version.stdout),
error: null,
}
}
export async function listInstalledWslDistros(opts?: RunWslOptions) {
const result = await runWsl(["--list", "--verbose"], opts)
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || "Failed to list installed WSL distros")
}
return parseInstalledDistros(result.stdout)
}
export async function listOnlineWslDistros(opts?: RunWslOptions) {
const result = await runWsl(["--list", "--online"], opts)
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || "Failed to list online WSL distros")
}
return parseOnlineDistros(result.stdout)
}
export async function installWslRuntimeElevated(opts?: RunWslOptions) {
const script = [
"$ErrorActionPreference = 'Stop'",
"$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru",
"if ($null -ne $process.ExitCode) { exit $process.ExitCode }",
].join("; ")
return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
}
export async function installWslDistro(name: string, opts?: RunWslOptions) {
return runInteractiveCommand(
resolveSystem32Command("wsl.exe"),
["--install", "-d", name, "--web-download", "--no-launch"],
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
)
}
export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) {
return runInteractiveCommand(
resolveSystem32Command("wsl.exe"),
wslArgs(
["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`],
distro,
),
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
)
}
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<WslDistroProbe> {
const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({
code: 1,
signal: null,
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
}))
if (executable.code !== 0) {
return {
name,
canExecute: false,
hasBash: false,
hasCurl: false,
error: summarize(executable.stderr || executable.stdout) || "Cannot execute commands in distro",
}
}
const [bash, curl] = await Promise.all([
runWslSh("command -v bash >/dev/null && printf yes || printf no", name, opts),
runWslSh("command -v curl >/dev/null && printf yes || printf no", name, opts),
])
return {
name,
canExecute: true,
hasBash: bash.code === 0 && summarize(bash.stdout) === "yes",
hasCurl: curl.code === 0 && summarize(curl.stdout) === "yes",
error: null,
}
}
export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
return firstLine(
(
await runWslSh(
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
distro,
opts,
)
).stdout,
)
}
export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) {
const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts)
return firstLine(result.stdout)
}
export function openWslTerminal(distro?: string | null) {
return new Promise<void>((resolve, reject) => {
const child = spawn("cmd.exe", wslTerminalArgs(distro), {
detached: true,
stdio: "ignore",
windowsHide: true,
})
child.once("error", reject)
child.once("spawn", () => {
child.unref()
resolve()
})
})
}
function parseInstalledDistros(output: string) {
return output.split(/\r?\n/g).flatMap((line) => {
const trimmed = line.trim()
if (!trimmed) return []
const match = line.match(/^\s*(\*)?\s*(.*?)\s{2,}\S+\s+(\d+)\s*$/)
if (!match) return []
const [, marker, name, version] = match
if (!name || /^name$/i.test(name)) return []
return [
{
name: name.trim(),
version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10),
isDefault: marker === "*",
} satisfies WslInstalledDistro,
]
})
}
function parseOnlineDistros(output: string) {
return output.split(/\r?\n/g).flatMap((line) => {
const trimmed = line.trim()
if (!trimmed) return []
const match = trimmed.match(/^([A-Za-z0-9._-]+)\s{2,}(.+)$/)
if (!match) return []
const [, name, label] = match
if (/^name$/i.test(name)) return []
return [{ name, label: label.trim() } satisfies WslOnlineDistro]
})
}
function firstLine(value: string) {
return (
value
.split(/\r?\n/g)
.map((line) => line.trim())
.find(Boolean) ?? null
)
}
export function summarize(value: string) {
return value
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean)
.join("\n")
}
export function shellEscape(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`
}
function resolveSystem32Command(command: string) {
const root = process.env.SystemRoot ?? process.env.windir
if (!root) return command
const resolved = join(root, "System32", command)
return existsSync(resolved) ? resolved : command
}
function withTimeout(opts: RunWslOptions | undefined, timeoutMs: number): RunWslOptions {
return {
...opts,
timeoutMs: opts?.timeoutMs ?? timeoutMs,
}
}

View File

@@ -0,0 +1,167 @@
import { expect, test } from "bun:test"
import { clearWslDistroState, requireWslIpcString, wslServerIdToRestart, wslTerminalArgs } from "./policy"
import {
expectOpencodeVersion,
pendingRestartAfterWslInstall,
pollWslHealth,
wslServerIdsToStartOnInitialize,
} from "./startup"
import { createWslServersController, type WslServerConfig } from "./servers"
let persistedServers: WslServerConfig[] = []
let releaseOpencodeResolve: (() => void) | undefined
test("starts every configured WSL server on initialization", () => {
expect(
wslServerIdsToStartOnInitialize([
{ id: "wsl:Debian", distro: "Debian" },
{ id: "wsl:Ubuntu-24.04", distro: "Ubuntu-24.04" },
]),
).toEqual(["wsl:Debian", "wsl:Ubuntu-24.04"])
})
test("rejects an update that did not install the desktop version", () => {
expect(() => expectOpencodeVersion("1.16.2", "1.16.2")).not.toThrow()
expect(() => expectOpencodeVersion("1.14.35", "1.16.2")).toThrow(
"OpenCode update finished but Debian still reports 1.14.35; expected 1.16.2",
)
})
test("restarts an existing distro server after updating OpenCode", () => {
expect(
wslServerIdToRestart(
[
{
config: { id: "wsl:Debian", distro: "Debian" },
runtime: { kind: "ready", url: "", username: null, password: null },
},
],
"Debian",
),
).toBe("wsl:Debian")
expect(wslServerIdToRestart([], "Debian")).toBeUndefined()
})
test("clears cached distro probes when removing a WSL server", () => {
expect(
clearWslDistroState(
{ Debian: { name: "Debian", canExecute: true, hasBash: true, hasCurl: true, error: null } },
{
Debian: {
distro: "Debian",
resolvedPath: "/home/luke/.opencode/bin/opencode",
version: "1.16.2",
expectedVersion: "1.16.2",
matchesDesktop: true,
error: null,
},
},
"Debian",
),
).toEqual({ distroProbes: {}, opencodeChecks: {} })
})
test("opens terminals for distro names containing spaces", () => {
expect(wslTerminalArgs("Ubuntu Preview")).toEqual(["/c", "start", "", "wsl", "-d", "Ubuntu Preview"])
})
test("stops health polling when sidecar startup settles", async () => {
const abort = new AbortController()
let checks = 0
const polling = pollWslHealth(
async () => {
checks++
return false
},
abort.signal,
1,
)
await new Promise((resolve) => setTimeout(resolve, 5))
abort.abort()
await polling
const settled = checks
await new Promise((resolve) => setTimeout(resolve, 5))
expect(checks).toBe(settled)
})
test("validates WSL IPC identifiers at the module boundary", () => {
expect(requireWslIpcString("distro", "Debian")).toBe("Debian")
expect(() => requireWslIpcString("distro", "")).toThrow("Invalid distro")
expect(() => requireWslIpcString("server id", undefined)).toThrow("Invalid server id")
})
test("derives a required Windows restart from the post-install runtime probe", () => {
expect(pendingRestartAfterWslInstall({ available: false, version: null, error: "WSL unavailable" })).toBe(true)
expect(pendingRestartAfterWslInstall({ available: true, version: "WSL version: 2.6.1", error: null })).toBe(false)
})
test("ignores stale background OpenCode checks after removing a WSL server", async () => {
persistedServers = []
releaseOpencodeResolve = undefined
const controller = createWslServersController(
"1.16.2",
async () => ({
listener: {
stop: () => undefined,
onExit: () => undefined,
},
url: "http://127.0.0.1:4096",
username: "opencode",
password: "secret",
}),
testControllerOptions(),
)
await controller.addServer("Debian")
await waitFor(() => !!releaseOpencodeResolve)
await controller.removeServer("wsl:Debian")
releaseOpencodeResolve?.()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(controller.getState().servers).toEqual([])
expect(controller.getState().opencodeChecks).toEqual({})
})
test("ignores stale startup OpenCode checks after removing a WSL server", async () => {
persistedServers = [{ id: "wsl:Debian", distro: "Debian" }]
releaseOpencodeResolve = undefined
const controller = createWslServersController(
"1.16.2",
async () => new Promise<never>(() => undefined),
testControllerOptions(),
)
await controller.initialize()
await waitFor(() => !!releaseOpencodeResolve)
await controller.removeServer("wsl:Debian")
releaseOpencodeResolve?.()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(controller.getState().servers).toEqual([])
expect(controller.getState().opencodeChecks).toEqual({})
})
async function waitFor(check: () => boolean) {
for (let attempt = 0; attempt < 20; attempt++) {
if (check()) return
await new Promise((resolve) => setTimeout(resolve, 0))
}
throw new Error("Timed out waiting for condition")
}
function testControllerOptions() {
return {
readServers: () => persistedServers,
writeServers: (servers: WslServerConfig[]) => {
persistedServers = servers
},
readCommandVersion: async () => "1.16.2",
resolveOpencode: async () => {
await new Promise<void>((resolve) => {
releaseOpencodeResolve = resolve
})
return "/home/me/.opencode/bin/opencode"
},
}
}

View File

@@ -0,0 +1,499 @@
import type {
WslDistroProbe,
WslInstalledDistro,
WslJob,
WslOnlineDistro,
WslOpencodeCheck,
WslRuntimeCheck,
WslServerConfig,
WslServerItem,
WslServerRuntime,
WslServersEvent,
WslServersState,
} from "../../preload/types"
import { WSL_SERVERS_KEY } from "../store-keys"
import { getStore } from "../store"
import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup"
import { clearWslDistroState, wslServerIdToRestart } from "./policy"
import {
installWslDistro,
installWslOpencode,
installWslRuntimeElevated,
listInstalledWslDistros,
listOnlineWslDistros,
openWslTerminal,
probeWslDistro,
probeWslRuntime,
readWslCommandVersion,
resolveWslOpencode,
summarize,
} from "./runtime"
type RunningSidecar = {
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
url: string
username: string | null
password: string
}
type SpawnSidecar = (distro: string) => Promise<RunningSidecar>
type ControllerLogger = {
log: (message: string, meta?: unknown) => void
error: (message: string, meta?: unknown) => void
}
type WslServersControllerOptions = {
logger?: ControllerLogger
readServers?: () => WslServerConfig[]
writeServers?: (servers: WslServerConfig[]) => void
resolveOpencode?: typeof resolveWslOpencode
readCommandVersion?: typeof readWslCommandVersion
}
export type WslServersController = ReturnType<typeof createWslServersController>
export function wslServerIdForDistro(distro: string) {
return `wsl:${distro}`
}
export function createWslServersController(
appVersion: string,
spawnSidecar: SpawnSidecar,
options?: WslServersControllerOptions,
) {
let state: WslServersState = initialState()
const listeners = new Set<(event: WslServersEvent) => void>()
const sidecars = new Map<string, RunningSidecar>()
const startAttempts = new Map<string, number>()
let jobAbort: AbortController | undefined
const logger = options?.logger
const readServers = options?.readServers ?? readPersistedServers
const writeServers = options?.writeServers ?? writePersistedServers
const emit = () => {
for (const listener of listeners) listener({ type: "state", state })
}
const setState = (next: Partial<WslServersState>) => {
state = { ...state, ...next }
emit()
}
const persistServers = (servers: WslServerConfig[]) => {
writeServers(servers)
}
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
const next = state.servers.map((item) => (item.config.id === id ? update(item) : item))
setState({ servers: next })
}
const beginJob = (job: WslJob): AbortController => {
jobAbort?.abort()
const abort = new AbortController()
jobAbort = abort
setState({ job })
return abort
}
const endJob = (abort: AbortController) => {
if (jobAbort !== abort) return
jobAbort = undefined
setState({ job: null })
}
const refreshFromStore = () => {
const persisted = readServers()
const items: WslServerItem[] = persisted.map((config) => {
const existing = state.servers.find((item) => item.config.id === config.id)
return {
config,
runtime: existing?.runtime ?? { kind: "stopped" },
}
})
setState({ servers: items })
}
const setRuntime = (id: string, runtime: WslServerRuntime) => {
updateServer(id, (item) => ({ ...item, runtime }))
}
const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => {
setState({
opencodeChecks: {
...state.opencodeChecks,
[distro]: check,
},
})
}
const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => {
const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts)
const version = resolved
? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts)
: null
return opencodeCheck(distro, resolved, version, appVersion)
}
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
setOpencodeCheck(distro, await checkOpencode(distro, opts))
}
const hasServer = (id: string, distro: string) => {
return state.servers.some((item) => item.config.id === id && item.config.distro === distro)
}
const refreshOpencodeCheckBackground = (id: string, distro: string) => {
void checkOpencode(distro)
.then((check) => {
if (!hasServer(id, distro)) return
setOpencodeCheck(distro, check)
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
logger?.error("wsl opencode check failed", { id, distro, message })
})
}
const refreshOpencodeChecks = async () => {
await Promise.all(
state.servers.map((item) =>
checkOpencode(item.config.distro)
.then((check) => {
if (!hasServer(item.config.id, item.config.distro)) return
setOpencodeCheck(item.config.distro, check)
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
logger?.error("wsl opencode check failed", {
id: item.config.id,
distro: item.config.distro,
message,
})
}),
),
)
}
const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
const [installed, online] = await Promise.all([listInstalledWslDistros(opts), listOnlineWslDistros(opts)])
return { installed, online }
}
const nextStartAttempt = (id: string) => {
const next = (startAttempts.get(id) ?? 0) + 1
startAttempts.set(id, next)
return next
}
const invalidateStartAttempt = (id: string) => {
startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1)
}
const isCurrentStartAttempt = (id: string, attempt: number) => {
return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id)
}
const startServer = async (id: string) => {
const item = state.servers.find((x) => x.config.id === id)
if (!item) return
const attempt = nextStartAttempt(id)
await stopServerInternal(id)
if (!isCurrentStartAttempt(id, attempt)) return
setRuntime(id, { kind: "starting" })
logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
try {
const sidecar = await spawnSidecar(item.config.distro)
if (!isCurrentStartAttempt(id, attempt)) {
try {
sidecar.listener.stop()
} catch {
// ignore stop errors for stale sidecars
}
return
}
sidecars.set(id, sidecar)
setRuntime(id, {
kind: "ready",
url: sidecar.url,
username: sidecar.username,
password: sidecar.password,
})
sidecar.listener.onExit((code, signal) => {
if (sidecars.get(id) !== sidecar) return
sidecars.delete(id)
const message = startupFailure(code, signal)
setRuntime(id, { kind: "failed", message })
logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
})
refreshOpencodeCheckBackground(id, item.config.distro)
logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!isCurrentStartAttempt(id, attempt)) return
setRuntime(id, { kind: "failed", message })
// Without this, an Ubuntu-style silent failure leaves no trace in
// main.log — the controller captures the message in its state but
// nothing surfaces unless the user opens the WSL servers dialog.
logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
}
}
const stopServerInternal = async (id: string) => {
const existing = sidecars.get(id)
if (!existing) return
sidecars.delete(id)
try {
existing.listener.stop()
} catch {
// ignore stop errors
}
}
const runJob = async <T>(job: WslJob, runner: (abort: AbortController) => Promise<T>) => {
const abort = beginJob(job)
try {
const value = await runner(abort)
endJob(abort)
return value
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
endJob(abort)
return undefined
}
const err = error instanceof Error ? error : new Error(String(error))
endJob(abort)
throw err
}
}
return {
getState() {
return state
},
subscribe(listener: (event: WslServersEvent) => void) {
listeners.add(listener)
return () => listeners.delete(listener)
},
async initialize() {
refreshFromStore()
void refreshOpencodeChecks()
for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id)
},
async probeRuntime() {
await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => {
const runtime = await probeWslRuntime({ signal: abort.signal })
setState({
runtime,
pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false,
})
})
},
async refreshDistros() {
await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => {
setState(await refreshDistroLists({ signal: abort.signal }))
})
},
async installWsl() {
await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => {
const result = await installWslRuntimeElevated({ signal: abort.signal })
if (result.code !== 0) {
const message = summarize(result.stderr || result.stdout) || "WSL installation failed"
throw new Error(message)
}
const runtime = await probeWslRuntime({ signal: abort.signal })
setState({ runtime, pendingRestart: pendingRestartAfterWslInstall(runtime) })
})
},
async installDistro(name: string) {
await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => {
const result = await installWslDistro(name, { signal: abort.signal })
if (result.code !== 0) {
const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}`
throw new Error(message)
}
const distros = await refreshDistroLists({ signal: abort.signal })
const probe = await probeWslDistro(name, { signal: abort.signal })
setState({
...distros,
distroProbes: { ...state.distroProbes, [name]: probe },
})
})
},
async probeDistro(name: string) {
await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => {
const probe = await probeWslDistro(name, { signal: abort.signal })
setState({ distroProbes: { ...state.distroProbes, [name]: probe } })
})
},
async probeOpencode(name: string) {
await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
await refreshOpencodeCheck(name, { signal: abort.signal })
})
},
async installOpencode(name: string) {
await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
const result = await installWslOpencode(appVersion, name, { signal: abort.signal })
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed")
}
await refreshOpencodeCheck(name, { signal: abort.signal })
expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, appVersion, name)
const id = wslServerIdToRestart(state.servers, name)
if (id) await startServer(id)
})
},
async openTerminal(name: string) {
await openWslTerminal(name)
},
async addServer(distro: string): Promise<WslServerConfig> {
const id = wslServerIdForDistro(distro)
if (state.servers.some((item) => item.config.id === id)) {
throw new Error(`${distro} is already added`)
}
const config: WslServerConfig = {
id,
distro,
}
persistServers([...readServers(), config])
setState({
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
})
void startServer(id)
return config
},
async removeServer(id: string) {
const distro = state.servers.find((item) => item.config.id === id)?.config.distro
invalidateStartAttempt(id)
await stopServerInternal(id)
const remaining = readServers().filter((item) => item.id !== id)
persistServers(remaining)
setState({
servers: state.servers.filter((item) => item.config.id !== id),
...(distro ? clearWslDistroState(state.distroProbes, state.opencodeChecks, distro) : {}),
})
},
startServer,
stopAll() {
for (const item of state.servers) invalidateStartAttempt(item.config.id)
for (const existing of sidecars.values()) {
try {
existing.listener.stop()
} catch {
// ignore
}
}
sidecars.clear()
},
}
}
function initialState(): WslServersState {
return {
runtime: null,
installed: [],
online: [],
distroProbes: {},
opencodeChecks: {},
pendingRestart: false,
servers: [],
job: null,
}
}
function readPersistedServers(): WslServerConfig[] {
const store = getStore()
const existing = store.get(WSL_SERVERS_KEY)
if (existing && typeof existing === "object") {
const record = existing as { servers?: unknown }
const list = Array.isArray(record.servers) ? record.servers : []
return list.flatMap(normalizePersistedServer)
}
return []
}
function writePersistedServers(servers: WslServerConfig[]) {
getStore().set(WSL_SERVERS_KEY, { servers })
}
function normalizePersistedServer(value: unknown): WslServerConfig[] {
if (!value || typeof value !== "object") return []
const record = value as Record<string, unknown>
const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
if (!distro) return []
const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro)
return [
{
id,
distro,
},
]
}
function opencodeCheck(
distro: string,
resolvedPath: string | null,
version: string | null,
expectedVersion: string,
): WslOpencodeCheck {
if (!resolvedPath) {
return {
distro,
resolvedPath: null,
version: null,
expectedVersion,
matchesDesktop: null,
error: "opencode is not installed in this distro",
}
}
if (!version) {
return {
distro,
resolvedPath,
version: null,
expectedVersion,
matchesDesktop: null,
error: "opencode is installed but could not run",
}
}
return {
distro,
resolvedPath,
version,
expectedVersion,
matchesDesktop: version === expectedVersion,
error: null,
}
}
function startupFailure(code: number | null, signal: NodeJS.Signals | null) {
return `WSL server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})`
}
// Re-export types used by callers
export type {
WslInstalledDistro,
WslOnlineDistro,
WslRuntimeCheck,
WslDistroProbe,
WslOpencodeCheck,
WslServerConfig,
WslServerItem,
WslServerRuntime,
WslServersEvent,
WslServersState,
}

View File

@@ -0,0 +1,129 @@
import { spawn } from "node:child_process"
import { randomUUID } from "node:crypto"
import { createServer } from "node:net"
import { app } from "electron"
import { checkHealth } from "../server"
import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./runtime"
import { pollWslHealth } from "./startup"
export type WslSidecar = {
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
url: string
username: string | null
password: string
}
export async function spawnWslSidecar(
distro: string,
opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
): Promise<WslSidecar> {
const opencode = await resolveWslOpencode(distro)
if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`)
const port = await allocatePort()
const password = randomUUID()
const username = "opencode"
const script = [
"set -euo pipefail",
'cd "$HOME" || cd /',
'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")',
"export PATH",
"export WSLENV=",
"export OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true",
"export OPENCODE_CLIENT=desktop",
`export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`,
`export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,
'export XDG_STATE_HOME="$HOME/.local/state"',
`exec ${shellEscape(opencode)} --print-logs --log-level ${app.isPackaged ? "WARN" : "INFO"} serve --hostname 0.0.0.0 --port ${port}`,
].join("\n")
const child = spawn("wsl", wslArgs(["bash", "-se"], distro), {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
})
child.stdin.end(script)
const recentOutput: string[] = []
const emit = (line: WslCommandLine) => {
if (!line.text.trim()) return
recentOutput.push(`[${line.stream}] ${line.text}`)
if (recentOutput.length > 12) recentOutput.shift()
opts.onLine?.(line)
}
forwardLines(child.stdout, "stdout", emit)
forwardLines(child.stderr, "stderr", emit)
const exit = new Promise<never>((_, reject) => {
child.once("error", reject)
child.once("exit", (code, signal) => reject(new Error(startupFailure(code, signal, recentOutput))))
})
const url = `http://127.0.0.1:${port}`
const startup = new AbortController()
const health = pollWslHealth(() => checkHealth(url, password), startup.signal)
const timeoutMs = opts.healthTimeoutMs ?? 30_000
let timeout: ReturnType<typeof setTimeout>
const timedOut = new Promise<never>(
(_, reject) =>
(timeout = setTimeout(
() => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)),
timeoutMs,
)),
)
await Promise.race([health, exit, timedOut])
.catch((error) => {
child.kill()
throw error
})
.finally(() => {
clearTimeout(timeout)
startup.abort()
})
return {
listener: {
stop: () => child.kill(),
onExit: (cb) => child.once("exit", cb),
},
url,
username,
password,
}
}
function allocatePort() {
return new Promise<number>((resolve, reject) => {
const server = createServer()
server.on("error", reject)
server.listen(0, "127.0.0.1", () => {
const address = server.address()
if (typeof address !== "object" || !address) {
server.close()
reject(new Error("Failed to get port"))
return
}
server.close(() => resolve(address.port))
})
})
}
function forwardLines(
stream: NodeJS.ReadableStream,
source: WslCommandLine["stream"],
onLine: (line: WslCommandLine) => void,
) {
let pending = ""
stream.setEncoding("utf8")
stream.on("data", (chunk: string) => {
pending += chunk
const lines = pending.split(/\r?\n/g)
pending = lines.pop() ?? ""
lines.forEach((text) => onLine({ stream: source, text }))
})
stream.on("end", () => {
if (pending) onLine({ stream: source, text: pending })
})
}
function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) {
const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : ""
return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}`
}

View File

@@ -0,0 +1,31 @@
export function wslServerIdsToStartOnInitialize(servers: { id: string }[]) {
return servers.map((server) => server.id)
}
export function expectOpencodeVersion(installed: string | null, expected: string, distro = "Debian") {
if (installed === expected) return
throw new Error(
`OpenCode update finished but ${distro} still reports ${installed ?? "no version"}; expected ${expected}`,
)
}
export const pendingRestartAfterWslInstall = (runtime: { available: boolean }) => !runtime.available
export async function pollWslHealth(check: () => Promise<boolean>, signal: AbortSignal, interval = 100) {
while (!signal.aborted) {
if (await check()) return
await abortableDelay(interval, signal)
}
}
function abortableDelay(duration: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const done = () => {
clearTimeout(timeout)
signal.removeEventListener("abort", done)
resolve()
}
const timeout = setTimeout(done, duration)
signal.addEventListener("abort", done, { once: true })
})
}

View File

@@ -0,0 +1,121 @@
import { contextBridge, ipcRenderer } from "electron"
import type { ElectronAPI, WslServersEvent } from "./types"
import type { UpdaterState } from "@opencode-ai/app/updater"
const updaterCallbacks = new Set<(state: UpdaterState) => void>()
let updaterState: UpdaterState | undefined
let updaterSubscription: Promise<void> | undefined
const updaterHandler = (_: unknown, state: UpdaterState) => {
updaterState = state
updaterCallbacks.forEach((callback) => callback(state))
}
const api: ElectronAPI = {
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
installCli: () => ipcRenderer.invoke("install-cli"),
awaitInitialization: () => ipcRenderer.invoke("await-initialization"),
wslServers: {
getState: () => ipcRenderer.invoke("wsl-servers-get-state"),
subscribe: (cb) => {
const handler = (_: unknown, event: WslServersEvent) => cb(event)
ipcRenderer.on("wsl-servers-event", handler)
void ipcRenderer.invoke("wsl-servers-subscribe")
return () => {
ipcRenderer.removeListener("wsl-servers-event", handler)
void ipcRenderer.invoke("wsl-servers-unsubscribe")
}
},
probeRuntime: () => ipcRenderer.invoke("wsl-servers-probe-runtime"),
refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"),
installWsl: () => ipcRenderer.invoke("wsl-servers-install-wsl"),
installDistro: (name) => ipcRenderer.invoke("wsl-servers-install-distro", name),
probeDistro: (name) => ipcRenderer.invoke("wsl-servers-probe-distro", name),
probeOpencode: (name) => ipcRenderer.invoke("wsl-servers-probe-opencode", name),
installOpencode: (name) => ipcRenderer.invoke("wsl-servers-install-opencode", name),
openTerminal: (name) => ipcRenderer.invoke("wsl-servers-open-terminal", name),
addServer: (distro) => ipcRenderer.invoke("wsl-servers-add", distro),
removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id),
startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id),
},
updater: {
subscribe: async (cb) => {
updaterCallbacks.add(cb)
if (updaterState) cb(updaterState)
if (!updaterSubscription) {
ipcRenderer.on("updater-state", updaterHandler)
updaterSubscription = ipcRenderer.invoke("updater-subscribe")
}
await updaterSubscription
return () => {
updaterCallbacks.delete(cb)
if (updaterCallbacks.size > 0) return
ipcRenderer.removeListener("updater-state", updaterHandler)
updaterSubscription = undefined
void ipcRenderer.invoke("updater-unsubscribe")
}
},
check: () => ipcRenderer.invoke("updater-check"),
install: () => ipcRenderer.invoke("updater-install"),
},
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName),
resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName),
storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key),
storeSet: (name, key, value) => ipcRenderer.invoke("store-set", name, key, value),
storeDelete: (name, key) => ipcRenderer.invoke("store-delete", name, key),
storeClear: (name) => ipcRenderer.invoke("store-clear", name),
storeKeys: (name) => ipcRenderer.invoke("store-keys", name),
storeLength: (name) => ipcRenderer.invoke("store-length", name),
getWindowCount: () => ipcRenderer.invoke("get-window-count"),
onMenuCommand: (cb) => {
const handler = (_: unknown, id: string) => cb(id)
ipcRenderer.on("menu-command", handler)
return () => ipcRenderer.removeListener("menu-command", handler)
},
onDeepLink: (cb) => {
const handler = (_: unknown, urls: string[]) => cb(urls)
ipcRenderer.on("deep-link", handler)
return () => ipcRenderer.removeListener("deep-link", handler)
},
openDirectoryPicker: (opts) => ipcRenderer.invoke("open-directory-picker", opts),
openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts),
readPickedFile: (token, path) => ipcRenderer.invoke("read-picked-file", token, path),
releasePickedFiles: (token) => ipcRenderer.invoke("release-picked-files", token),
saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
openLink: (url) => ipcRenderer.send("open-link", url),
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
showNotification: (title, body) => ipcRenderer.send("show-notification", title, body),
getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),
setWindowFocus: () => ipcRenderer.invoke("set-window-focus"),
showWindow: () => ipcRenderer.invoke("show-window"),
relaunch: () => ipcRenderer.send("relaunch"),
getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
getPinchZoomEnabled: () => ipcRenderer.invoke("get-pinch-zoom-enabled"),
setPinchZoomEnabled: (enabled) => ipcRenderer.invoke("set-pinch-zoom-enabled", enabled),
onPinchZoomEnabledChanged: (cb) => {
const handler = (_: unknown, enabled: boolean) => cb(enabled)
ipcRenderer.on("pinch-zoom-enabled-changed", handler)
return () => ipcRenderer.removeListener("pinch-zoom-enabled-changed", handler)
},
onZoomFactorChanged: (cb) => {
const handler = (_: unknown, factor: number) => cb(factor)
ipcRenderer.on("zoom-factor-changed", handler)
return () => ipcRenderer.removeListener("zoom-factor-changed", handler)
},
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"),
recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error),
}
contextBridge.exposeInMainWorld("api", api)

View File

@@ -0,0 +1,101 @@
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
import type { UpdaterState } from "@opencode-ai/app/updater"
export type {
WslDistroProbe,
WslInstalledDistro,
WslJob,
WslOnlineDistro,
WslOpencodeCheck,
WslRuntimeCheck,
WslServerConfig,
WslServerItem,
WslServerRuntime,
WslServersEvent,
WslServersState,
} from "@opencode-ai/app/wsl/types"
export type ServerReadyData = {
url: string
username: string | null
password: string | null
}
export type WslServersAPI = WslServersPlatform
export type UpdaterAPI = {
subscribe: (cb: (state: UpdaterState) => void) => Promise<() => void>
check: () => Promise<UpdaterState>
install: () => Promise<void>
}
export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = {
mode: "light" | "dark"
}
export type FatalRendererError = {
error: string
url: string
version?: string
platform: string
os?: string
}
export type ElectronAPI = {
killSidecar: () => Promise<void>
installCli: () => Promise<string>
awaitInitialization: () => Promise<ServerReadyData>
wslServers: WslServersAPI
updater: UpdaterAPI
consumeInitialDeepLinks: () => Promise<string[]>
getDefaultServerUrl: () => Promise<string | null>
setDefaultServerUrl: (url: string | null) => Promise<void>
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
parseMarkdownCommand: (markdown: string) => Promise<string>
checkAppExists: (appName: string) => Promise<boolean>
resolveAppPath: (appName: string) => Promise<string | null>
storeGet: (name: string, key: string) => Promise<string | null>
storeSet: (name: string, key: string, value: string) => Promise<void>
storeDelete: (name: string, key: string) => Promise<void>
storeClear: (name: string) => Promise<void>
storeKeys: (name: string) => Promise<string[]>
storeLength: (name: string) => Promise<number>
getWindowCount: () => Promise<number>
onMenuCommand: (cb: (id: string) => void) => () => void
onDeepLink: (cb: (urls: string[]) => void) => () => void
openDirectoryPicker: (opts?: {
multiple?: boolean
title?: string
defaultPath?: string
}) => Promise<string | string[] | null>
openFilePicker: (opts?: {
multiple?: boolean
title?: string
defaultPath?: string
extensions?: string[]
}) => Promise<{ token: string; files: { path: string; name: string; size: number }[] } | null>
readPickedFile: (token: string, path: string) => Promise<ArrayBuffer>
releasePickedFiles: (token: string) => Promise<void>
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
openLink: (url: string) => void
openPath: (path: string, app?: string) => Promise<void>
readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
showNotification: (title: string, body?: string) => void
getWindowFocused: () => Promise<boolean>
setWindowFocus: () => Promise<void>
showWindow: () => Promise<void>
relaunch: () => void
getZoomFactor: () => Promise<number>
setZoomFactor: (factor: number) => Promise<void>
getPinchZoomEnabled: () => Promise<boolean>
setPinchZoomEnabled: (enabled: boolean) => Promise<void>
onPinchZoomEnabledChanged: (cb: (enabled: boolean) => void) => () => void
onZoomFactorChanged: (cb: (factor: number) => void) => () => void
setTitlebar: (theme: TitlebarTheme) => Promise<void>
runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
setBackgroundColor: (color: string) => Promise<void>
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void>
}

View File

@@ -0,0 +1,12 @@
import { initI18n, t } from "./i18n"
export async function installCli(): Promise<void> {
await initI18n()
try {
const path = await window.api.installCli()
window.alert(t("desktop.cli.installed.message", { path }))
} catch (e) {
window.alert(t("desktop.cli.failed.message", { error: String(e) }))
}
}

10
packages/desktop/src/renderer/env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
import type { ElectronAPI } from "../preload/types"
declare global {
interface Window {
api: ElectronAPI
__OPENCODE__?: {
deepLinks?: string[]
}
}
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test"
import { join, dirname, resolve } from "node:path"
import { existsSync } from "node:fs"
import { fileURLToPath } from "node:url"
const dir = dirname(fileURLToPath(import.meta.url))
const root = resolve(dir, "../..")
const html = async (name: string) => Bun.file(join(dir, name)).text()
/**
* Packaged Electron windows load renderer HTML via the privileged `oc://`
* protocol. Root-relative asset paths like `src="/foo.js"` would resolve from
* the protocol origin root instead of relative to the current HTML entrypoint.
*
* All local resource references must use relative paths (`./`).
*/
describe("electron renderer html", () => {
for (const name of ["index.html"]) {
describe(name, () => {
test("script src attributes use relative paths", async () => {
const content = await html(name)
const srcs = [...content.matchAll(/\bsrc=["']([^"']+)["']/g)].map((m) => m[1])
for (const src of srcs) {
expect(src).not.toMatch(/^\/[^/]/)
}
})
test("link href attributes use relative paths", async () => {
const content = await html(name)
const hrefs = [...content.matchAll(/<link[^>]+href=["']([^"']+)["']/g)].map((m) => m[1])
for (const href of hrefs) {
expect(href).not.toMatch(/^\/[^/]/)
}
})
test("no web manifest link (not applicable in Electron)", async () => {
const content = await html(name)
expect(content).not.toContain('rel="manifest"')
})
})
}
})
/**
* Vite resolves `publicDir` relative to `root`, not the config file.
* This test reads the actual values from electron.vite.config.ts to catch
* regressions where the publicDir path no longer resolves correctly
* after the renderer root is accounted for.
*/
describe("electron vite publicDir", () => {
test("configured publicDir resolves to a directory with oc-theme-preload.js", async () => {
const config = await Bun.file(join(root, "electron.vite.config.ts")).text()
const pub = config.match(/publicDir:\s*["']([^"']+)["']/)
const rendererRoot = config.match(/root:\s*["']([^"']+)["']/)
expect(pub).not.toBeNull()
expect(rendererRoot).not.toBeNull()
const resolved = resolve(root, rendererRoot![1], pub![1])
expect(existsSync(resolved)).toBe(true)
expect(existsSync(join(resolved, "oc-theme-preload.js"))).toBe(true)
})
})

View File

@@ -0,0 +1,26 @@
export const dict = {
"desktop.menu.checkForUpdates": "التحقق من وجود تحديثات...",
"desktop.menu.installCli": "تثبيت CLI...",
"desktop.menu.reloadWebview": "إعادة تحميل Webview",
"desktop.menu.restart": "إعادة تشغيل",
"desktop.dialog.chooseFolder": "اختر مجلدًا",
"desktop.dialog.chooseFile": "اختر ملفًا",
"desktop.dialog.saveFile": "حفظ ملف",
"desktop.updater.checkFailed.title": "فشل التحقق من التحديثات",
"desktop.updater.checkFailed.message": "فشل التحقق من وجود تحديثات",
"desktop.updater.none.title": "لا توجد تحديثات متاحة",
"desktop.updater.none.message": "أنت تستخدم بالفعل أحدث إصدار من OpenCode",
"desktop.updater.downloadFailed.title": "فشل التحديث",
"desktop.updater.downloadFailed.message": "فشل تنزيل التحديث",
"desktop.updater.downloaded.title": "تم تنزيل التحديث",
"desktop.updater.downloaded.prompt": "تم تنزيل إصدار {{version}} من OpenCode، هل ترغب في تثبيته وإعادة تشغيله؟",
"desktop.updater.installFailed.title": "فشل التحديث",
"desktop.updater.installFailed.message": "فشل تثبيت التحديث",
"desktop.cli.installed.title": "تم تثبيت CLI",
"desktop.cli.installed.message": "تم تثبيت CLI في {{path}}\n\nأعد تشغيل الطرفية لاستخدام الأمر 'opencode'.",
"desktop.cli.failed.title": "فشل التثبيت",
"desktop.cli.failed.message": "فشل تثبيت CLI: {{error}}",
}

View File

@@ -0,0 +1,27 @@
export const dict = {
"desktop.menu.checkForUpdates": "Verificar atualizações...",
"desktop.menu.installCli": "Instalar CLI...",
"desktop.menu.reloadWebview": "Recarregar Webview",
"desktop.menu.restart": "Reiniciar",
"desktop.dialog.chooseFolder": "Escolher uma pasta",
"desktop.dialog.chooseFile": "Escolher um arquivo",
"desktop.dialog.saveFile": "Salvar arquivo",
"desktop.updater.checkFailed.title": "Falha ao verificar atualizações",
"desktop.updater.checkFailed.message": "Falha ao verificar atualizações",
"desktop.updater.none.title": "Nenhuma atualização disponível",
"desktop.updater.none.message": "Você já está usando a versão mais recente do OpenCode",
"desktop.updater.downloadFailed.title": "Falha na atualização",
"desktop.updater.downloadFailed.message": "Falha ao baixar a atualização",
"desktop.updater.downloaded.title": "Atualização baixada",
"desktop.updater.downloaded.prompt":
"A versão {{version}} do OpenCode foi baixada. Você gostaria de instalá-la e reiniciar?",
"desktop.updater.installFailed.title": "Falha na atualização",
"desktop.updater.installFailed.message": "Falha ao instalar a atualização",
"desktop.cli.installed.title": "CLI instalada",
"desktop.cli.installed.message": "CLI instalada em {{path}}\n\nReinicie seu terminal para usar o comando 'opencode'.",
"desktop.cli.failed.title": "Falha na instalação",
"desktop.cli.failed.message": "Falha ao instalar a CLI: {{error}}",
}

View File

@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "Provjeri ažuriranja...",
"desktop.menu.installCli": "Instaliraj CLI...",
"desktop.menu.reloadWebview": "Ponovo učitavanje webview-a",
"desktop.menu.restart": "Restartuj",
"desktop.dialog.chooseFolder": "Odaberi folder",
"desktop.dialog.chooseFile": "Odaberi datoteku",
"desktop.dialog.saveFile": "Sačuvaj datoteku",
"desktop.updater.checkFailed.title": "Provjera ažuriranja nije uspjela",
"desktop.updater.checkFailed.message": "Nije moguće provjeriti ažuriranja",
"desktop.updater.none.title": "Nema dostupnog ažuriranja",
"desktop.updater.none.message": "Već koristiš najnoviju verziju OpenCode-a",
"desktop.updater.downloadFailed.title": "Ažuriranje nije uspjelo",
"desktop.updater.downloadFailed.message": "Neuspjelo preuzimanje ažuriranja",
"desktop.updater.downloaded.title": "Ažuriranje preuzeto",
"desktop.updater.downloaded.prompt":
"Verzija {{version}} OpenCode-a je preuzeta. Želiš li da je instaliraš i ponovo pokreneš aplikaciju?",
"desktop.updater.installFailed.title": "Ažuriranje nije uspjelo",
"desktop.updater.installFailed.message": "Neuspjela instalacija ažuriranja",
"desktop.cli.installed.title": "CLI instaliran",
"desktop.cli.installed.message":
"CLI je instaliran u {{path}}\n\nRestartuj terminal da bi koristio komandu 'opencode'.",
"desktop.cli.failed.title": "Instalacija nije uspjela",
"desktop.cli.failed.message": "Neuspjela instalacija CLI-a: {{error}}",
}

View File

@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "Tjek for opdateringer...",
"desktop.menu.installCli": "Installer CLI...",
"desktop.menu.reloadWebview": "Genindlæs Webview",
"desktop.menu.restart": "Genstart",
"desktop.dialog.chooseFolder": "Vælg en mappe",
"desktop.dialog.chooseFile": "Vælg en fil",
"desktop.dialog.saveFile": "Gem fil",
"desktop.updater.checkFailed.title": "Opdateringstjek mislykkedes",
"desktop.updater.checkFailed.message": "Kunne ikke tjekke for opdateringer",
"desktop.updater.none.title": "Ingen opdatering tilgængelig",
"desktop.updater.none.message": "Du bruger allerede den nyeste version af OpenCode",
"desktop.updater.downloadFailed.title": "Opdatering mislykkedes",
"desktop.updater.downloadFailed.message": "Kunne ikke downloade opdateringen",
"desktop.updater.downloaded.title": "Opdatering downloadet",
"desktop.updater.downloaded.prompt":
"Version {{version}} af OpenCode er blevet downloadet. Vil du installere den og genstarte?",
"desktop.updater.installFailed.title": "Opdatering mislykkedes",
"desktop.updater.installFailed.message": "Kunne ikke installere opdateringen",
"desktop.cli.installed.title": "CLI installeret",
"desktop.cli.installed.message":
"CLI installeret i {{path}}\n\nGenstart din terminal for at bruge 'opencode'-kommandoen.",
"desktop.cli.failed.title": "Installation mislykkedes",
"desktop.cli.failed.message": "Kunne ikke installere CLI: {{error}}",
}

View File

@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "Nach Updates suchen...",
"desktop.menu.installCli": "CLI installieren...",
"desktop.menu.reloadWebview": "Webview neu laden",
"desktop.menu.restart": "Neustart",
"desktop.dialog.chooseFolder": "Ordner auswählen",
"desktop.dialog.chooseFile": "Datei auswählen",
"desktop.dialog.saveFile": "Datei speichern",
"desktop.updater.checkFailed.title": "Updateprüfung fehlgeschlagen",
"desktop.updater.checkFailed.message": "Updates konnten nicht geprüft werden",
"desktop.updater.none.title": "Kein Update verfügbar",
"desktop.updater.none.message": "Sie verwenden bereits die neueste Version von OpenCode",
"desktop.updater.downloadFailed.title": "Update fehlgeschlagen",
"desktop.updater.downloadFailed.message": "Update konnte nicht heruntergeladen werden",
"desktop.updater.downloaded.title": "Update heruntergeladen",
"desktop.updater.downloaded.prompt":
"Version {{version}} von OpenCode wurde heruntergeladen. Möchten Sie sie installieren und neu starten?",
"desktop.updater.installFailed.title": "Update fehlgeschlagen",
"desktop.updater.installFailed.message": "Update konnte nicht installiert werden",
"desktop.cli.installed.title": "CLI installiert",
"desktop.cli.installed.message":
"CLI wurde in {{path}} installiert\n\nStarten Sie Ihr Terminal neu, um den Befehl 'opencode' zu verwenden.",
"desktop.cli.failed.title": "Installation fehlgeschlagen",
"desktop.cli.failed.message": "CLI konnte nicht installiert werden: {{error}}",
}

View File

@@ -0,0 +1,27 @@
export const dict = {
"desktop.menu.checkForUpdates": "Check for Updates...",
"desktop.menu.installCli": "Install CLI...",
"desktop.menu.reloadWebview": "Reload Webview",
"desktop.menu.restart": "Restart",
"desktop.dialog.chooseFolder": "Choose a folder",
"desktop.dialog.chooseFile": "Choose a file",
"desktop.dialog.saveFile": "Save file",
"desktop.updater.checkFailed.title": "Update Check Failed",
"desktop.updater.checkFailed.message": "Failed to check for updates",
"desktop.updater.none.title": "No Update Available",
"desktop.updater.none.message": "You are already using the latest version of OpenCode",
"desktop.updater.downloadFailed.title": "Update Failed",
"desktop.updater.downloadFailed.message": "Failed to download update",
"desktop.updater.downloaded.title": "Update Downloaded",
"desktop.updater.downloaded.prompt":
"Version {{version}} of OpenCode has been downloaded, would you like to install it and relaunch?",
"desktop.updater.installFailed.title": "Update Failed",
"desktop.updater.installFailed.message": "Failed to install update",
"desktop.cli.installed.title": "CLI Installed",
"desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode' command.",
"desktop.cli.failed.title": "Installation Failed",
"desktop.cli.failed.message": "Failed to install CLI: {{error}}",
}

View File

@@ -0,0 +1,27 @@
export const dict = {
"desktop.menu.checkForUpdates": "Buscar actualizaciones...",
"desktop.menu.installCli": "Instalar CLI...",
"desktop.menu.reloadWebview": "Recargar Webview",
"desktop.menu.restart": "Reiniciar",
"desktop.dialog.chooseFolder": "Elegir una carpeta",
"desktop.dialog.chooseFile": "Elegir un archivo",
"desktop.dialog.saveFile": "Guardar archivo",
"desktop.updater.checkFailed.title": "Comprobación de actualizaciones fallida",
"desktop.updater.checkFailed.message": "No se pudieron buscar actualizaciones",
"desktop.updater.none.title": "No hay actualizaciones disponibles",
"desktop.updater.none.message": "Ya estás usando la versión más reciente de OpenCode",
"desktop.updater.downloadFailed.title": "Actualización fallida",
"desktop.updater.downloadFailed.message": "No se pudo descargar la actualización",
"desktop.updater.downloaded.title": "Actualización descargada",
"desktop.updater.downloaded.prompt":
"Se ha descargado la versión {{version}} de OpenCode. ¿Quieres instalarla y reiniciar?",
"desktop.updater.installFailed.title": "Actualización fallida",
"desktop.updater.installFailed.message": "No se pudo instalar la actualización",
"desktop.cli.installed.title": "CLI instalada",
"desktop.cli.installed.message": "CLI instalada en {{path}}\n\nReinicia tu terminal para usar el comando 'opencode'.",
"desktop.cli.failed.title": "Instalación fallida",
"desktop.cli.failed.message": "No se pudo instalar la CLI: {{error}}",
}

View File

@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "Vérifier les mises à jour...",
"desktop.menu.installCli": "Installer la CLI...",
"desktop.menu.reloadWebview": "Recharger la Webview",
"desktop.menu.restart": "Redémarrer",
"desktop.dialog.chooseFolder": "Choisir un dossier",
"desktop.dialog.chooseFile": "Choisir un fichier",
"desktop.dialog.saveFile": "Enregistrer le fichier",
"desktop.updater.checkFailed.title": "Échec de la vérification des mises à jour",
"desktop.updater.checkFailed.message": "Impossible de vérifier les mises à jour",
"desktop.updater.none.title": "Aucune mise à jour disponible",
"desktop.updater.none.message": "Vous utilisez déjà la dernière version d'OpenCode",
"desktop.updater.downloadFailed.title": "Échec de la mise à jour",
"desktop.updater.downloadFailed.message": "Impossible de télécharger la mise à jour",
"desktop.updater.downloaded.title": "Mise à jour téléchargée",
"desktop.updater.downloaded.prompt":
"La version {{version}} d'OpenCode a été téléchargée. Voulez-vous l'installer et redémarrer ?",
"desktop.updater.installFailed.title": "Échec de la mise à jour",
"desktop.updater.installFailed.message": "Impossible d'installer la mise à jour",
"desktop.cli.installed.title": "CLI installée",
"desktop.cli.installed.message":
"CLI installée dans {{path}}\n\nRedémarrez votre terminal pour utiliser la commande 'opencode'.",
"desktop.cli.failed.title": "Échec de l'installation",
"desktop.cli.failed.message": "Impossible d'installer la CLI : {{error}}",
}

View File

@@ -0,0 +1,194 @@
import * as i18n from "@solid-primitives/i18n"
import { dict as desktopEn } from "./en"
import { dict as desktopZh } from "./zh"
import { dict as desktopZht } from "./zht"
import { dict as desktopKo } from "./ko"
import { dict as desktopDe } from "./de"
import { dict as desktopEs } from "./es"
import { dict as desktopFr } from "./fr"
import { dict as desktopDa } from "./da"
import { dict as desktopJa } from "./ja"
import { dict as desktopPl } from "./pl"
import { dict as desktopRu } from "./ru"
import { dict as desktopUk } from "./uk"
import { dict as desktopAr } from "./ar"
import { dict as desktopNo } from "./no"
import { dict as desktopBr } from "./br"
import { dict as desktopBs } from "./bs"
import { dict as appEn } from "../../../../app/src/i18n/en"
import { dict as appZh } from "../../../../app/src/i18n/zh"
import { dict as appZht } from "../../../../app/src/i18n/zht"
import { dict as appKo } from "../../../../app/src/i18n/ko"
import { dict as appDe } from "../../../../app/src/i18n/de"
import { dict as appEs } from "../../../../app/src/i18n/es"
import { dict as appFr } from "../../../../app/src/i18n/fr"
import { dict as appDa } from "../../../../app/src/i18n/da"
import { dict as appJa } from "../../../../app/src/i18n/ja"
import { dict as appPl } from "../../../../app/src/i18n/pl"
import { dict as appRu } from "../../../../app/src/i18n/ru"
import { dict as appUk } from "../../../../app/src/i18n/uk"
import { dict as appAr } from "../../../../app/src/i18n/ar"
import { dict as appNo } from "../../../../app/src/i18n/no"
import { dict as appBr } from "../../../../app/src/i18n/br"
import { dict as appBs } from "../../../../app/src/i18n/bs"
export type Locale =
| "en"
| "zh"
| "zht"
| "ko"
| "de"
| "es"
| "fr"
| "da"
| "ja"
| "pl"
| "ru"
| "uk"
| "ar"
| "no"
| "br"
| "bs"
type RawDictionary = typeof appEn & typeof desktopEn
type Dictionary = i18n.Flatten<RawDictionary>
const LOCALES: readonly Locale[] = [
"en",
"zh",
"zht",
"ko",
"de",
"es",
"fr",
"da",
"ja",
"pl",
"ru",
"uk",
"bs",
"ar",
"no",
"br",
]
function detectLocale(): Locale {
if (typeof navigator !== "object") return "en"
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
if (language.toLowerCase().startsWith("en")) return "en"
if (language.toLowerCase().startsWith("zh")) {
if (language.toLowerCase().includes("hant")) return "zht"
return "zh"
}
if (language.toLowerCase().startsWith("ko")) return "ko"
if (language.toLowerCase().startsWith("de")) return "de"
if (language.toLowerCase().startsWith("es")) return "es"
if (language.toLowerCase().startsWith("fr")) return "fr"
if (language.toLowerCase().startsWith("da")) return "da"
if (language.toLowerCase().startsWith("ja")) return "ja"
if (language.toLowerCase().startsWith("pl")) return "pl"
if (language.toLowerCase().startsWith("ru")) return "ru"
if (language.toLowerCase().startsWith("uk")) return "uk"
if (language.toLowerCase().startsWith("ar")) return "ar"
if (
language.toLowerCase().startsWith("no") ||
language.toLowerCase().startsWith("nb") ||
language.toLowerCase().startsWith("nn")
)
return "no"
if (language.toLowerCase().startsWith("pt")) return "br"
if (language.toLowerCase().startsWith("bs")) return "bs"
}
return "en"
}
function parseLocale(value: unknown): Locale | null {
if (!value) return null
if (typeof value !== "string") return null
if ((LOCALES as readonly string[]).includes(value)) return value as Locale
return null
}
function parseRecord(value: unknown) {
if (!value || typeof value !== "object") return null
if (Array.isArray(value)) return null
return value as Record<string, unknown>
}
function parseStored(value: unknown) {
if (typeof value !== "string") return value
try {
return JSON.parse(value) as unknown
} catch {
return value
}
}
function pickLocale(value: unknown): Locale | null {
const direct = parseLocale(value)
if (direct) return direct
const record = parseRecord(value)
if (!record) return null
return parseLocale(record.locale)
}
const base = i18n.flatten({ ...appEn, ...desktopEn })
function build(locale: Locale): Dictionary {
if (locale === "en") return base
if (locale === "zh") return { ...base, ...i18n.flatten(appZh), ...i18n.flatten(desktopZh) }
if (locale === "zht") return { ...base, ...i18n.flatten(appZht), ...i18n.flatten(desktopZht) }
if (locale === "de") return { ...base, ...i18n.flatten(appDe), ...i18n.flatten(desktopDe) }
if (locale === "es") return { ...base, ...i18n.flatten(appEs), ...i18n.flatten(desktopEs) }
if (locale === "fr") return { ...base, ...i18n.flatten(appFr), ...i18n.flatten(desktopFr) }
if (locale === "da") return { ...base, ...i18n.flatten(appDa), ...i18n.flatten(desktopDa) }
if (locale === "ja") return { ...base, ...i18n.flatten(appJa), ...i18n.flatten(desktopJa) }
if (locale === "pl") return { ...base, ...i18n.flatten(appPl), ...i18n.flatten(desktopPl) }
if (locale === "ru") return { ...base, ...i18n.flatten(appRu), ...i18n.flatten(desktopRu) }
if (locale === "uk") return { ...base, ...i18n.flatten(appUk), ...i18n.flatten(desktopUk) }
if (locale === "ar") return { ...base, ...i18n.flatten(appAr), ...i18n.flatten(desktopAr) }
if (locale === "no") return { ...base, ...i18n.flatten(appNo), ...i18n.flatten(desktopNo) }
if (locale === "br") return { ...base, ...i18n.flatten(appBr), ...i18n.flatten(desktopBr) }
if (locale === "bs") return { ...base, ...i18n.flatten(appBs), ...i18n.flatten(desktopBs) }
return { ...base, ...i18n.flatten(appKo), ...i18n.flatten(desktopKo) }
}
const state = {
locale: detectLocale(),
dict: base as Dictionary,
init: undefined as Promise<Locale> | undefined,
}
state.dict = build(state.locale)
const translate = i18n.translator(() => state.dict, i18n.resolveTemplate)
export function t(key: keyof Dictionary, params?: Record<string, string | number>) {
return translate(key, params)
}
export function initI18n(): Promise<Locale> {
const cached = state.init
if (cached) return cached
const promise = (async () => {
const raw = await window.api.storeGet("opencode.global.dat", "language").catch(() => null)
const value = parseStored(raw)
const next = pickLocale(value) ?? state.locale
state.locale = next
state.dict = build(next)
return next
})().catch(() => state.locale)
state.init = promise
return promise
}

View File

@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "アップデートを確認...",
"desktop.menu.installCli": "CLI をインストール...",
"desktop.menu.reloadWebview": "Webview を再読み込み",
"desktop.menu.restart": "再起動",
"desktop.dialog.chooseFolder": "フォルダーを選択",
"desktop.dialog.chooseFile": "ファイルを選択",
"desktop.dialog.saveFile": "ファイルを保存",
"desktop.updater.checkFailed.title": "アップデートの確認に失敗しました",
"desktop.updater.checkFailed.message": "アップデートを確認できませんでした",
"desktop.updater.none.title": "利用可能なアップデートはありません",
"desktop.updater.none.message": "すでに最新バージョンの OpenCode を使用しています",
"desktop.updater.downloadFailed.title": "アップデートに失敗しました",
"desktop.updater.downloadFailed.message": "アップデートをダウンロードできませんでした",
"desktop.updater.downloaded.title": "アップデートをダウンロードしました",
"desktop.updater.downloaded.prompt":
"OpenCode のバージョン {{version}} がダウンロードされました。インストールして再起動しますか?",
"desktop.updater.installFailed.title": "アップデートに失敗しました",
"desktop.updater.installFailed.message": "アップデートをインストールできませんでした",
"desktop.cli.installed.title": "CLI をインストールしました",
"desktop.cli.installed.message":
"CLI を {{path}} にインストールしました\n\nターミナルを再起動して 'opencode' コマンドを使用してください。",
"desktop.cli.failed.title": "インストールに失敗しました",
"desktop.cli.failed.message": "CLI のインストールに失敗しました: {{error}}",
}

View File

@@ -0,0 +1,27 @@
export const dict = {
"desktop.menu.checkForUpdates": "업데이트 확인...",
"desktop.menu.installCli": "CLI 설치...",
"desktop.menu.reloadWebview": "Webview 새로고침",
"desktop.menu.restart": "다시 시작",
"desktop.dialog.chooseFolder": "폴더 선택",
"desktop.dialog.chooseFile": "파일 선택",
"desktop.dialog.saveFile": "파일 저장",
"desktop.updater.checkFailed.title": "업데이트 확인 실패",
"desktop.updater.checkFailed.message": "업데이트를 확인하지 못했습니다",
"desktop.updater.none.title": "사용 가능한 업데이트 없음",
"desktop.updater.none.message": "이미 최신 버전의 OpenCode를 사용하고 있습니다",
"desktop.updater.downloadFailed.title": "업데이트 실패",
"desktop.updater.downloadFailed.message": "업데이트를 다운로드하지 못했습니다",
"desktop.updater.downloaded.title": "업데이트 다운로드 완료",
"desktop.updater.downloaded.prompt": "OpenCode {{version}} 버전을 다운로드했습니다. 설치하고 다시 실행할까요?",
"desktop.updater.installFailed.title": "업데이트 실패",
"desktop.updater.installFailed.message": "업데이트를 설치하지 못했습니다",
"desktop.cli.installed.title": "CLI 설치됨",
"desktop.cli.installed.message":
"CLI가 {{path}}에 설치되었습니다\n\n터미널을 다시 시작하여 'opencode' 명령을 사용하세요.",
"desktop.cli.failed.title": "설치 실패",
"desktop.cli.failed.message": "CLI 설치 실패: {{error}}",
}

View File

@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "Se etter oppdateringer...",
"desktop.menu.installCli": "Installer CLI...",
"desktop.menu.reloadWebview": "Last inn Webview på nytt",
"desktop.menu.restart": "Start på nytt",
"desktop.dialog.chooseFolder": "Velg en mappe",
"desktop.dialog.chooseFile": "Velg en fil",
"desktop.dialog.saveFile": "Lagre fil",
"desktop.updater.checkFailed.title": "Oppdateringssjekk mislyktes",
"desktop.updater.checkFailed.message": "Kunne ikke se etter oppdateringer",
"desktop.updater.none.title": "Ingen oppdatering tilgjengelig",
"desktop.updater.none.message": "Du bruker allerede den nyeste versjonen av OpenCode",
"desktop.updater.downloadFailed.title": "Oppdatering mislyktes",
"desktop.updater.downloadFailed.message": "Kunne ikke laste ned oppdateringen",
"desktop.updater.downloaded.title": "Oppdatering lastet ned",
"desktop.updater.downloaded.prompt":
"Versjon {{version}} av OpenCode er lastet ned. Vil du installere den og starte på nytt?",
"desktop.updater.installFailed.title": "Oppdatering mislyktes",
"desktop.updater.installFailed.message": "Kunne ikke installere oppdateringen",
"desktop.cli.installed.title": "CLI installert",
"desktop.cli.installed.message":
"CLI installert til {{path}}\n\nStart terminalen på nytt for å bruke 'opencode'-kommandoen.",
"desktop.cli.failed.title": "Installasjon mislyktes",
"desktop.cli.failed.message": "Kunne ikke installere CLI: {{error}}",
}

View File

@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "Sprawdź aktualizacje...",
"desktop.menu.installCli": "Zainstaluj CLI...",
"desktop.menu.reloadWebview": "Przeładuj Webview",
"desktop.menu.restart": "Restartuj",
"desktop.dialog.chooseFolder": "Wybierz folder",
"desktop.dialog.chooseFile": "Wybierz plik",
"desktop.dialog.saveFile": "Zapisz plik",
"desktop.updater.checkFailed.title": "Nie udało się sprawdzić aktualizacji",
"desktop.updater.checkFailed.message": "Nie udało się sprawdzić aktualizacji",
"desktop.updater.none.title": "Brak dostępnych aktualizacji",
"desktop.updater.none.message": "Korzystasz już z najnowszej wersji OpenCode",
"desktop.updater.downloadFailed.title": "Aktualizacja nie powiodła się",
"desktop.updater.downloadFailed.message": "Nie udało się pobrać aktualizacji",
"desktop.updater.downloaded.title": "Aktualizacja pobrana",
"desktop.updater.downloaded.prompt":
"Pobrano wersję {{version}} OpenCode. Czy chcesz ją zainstalować i uruchomić ponownie?",
"desktop.updater.installFailed.title": "Aktualizacja nie powiodła się",
"desktop.updater.installFailed.message": "Nie udało się zainstalować aktualizacji",
"desktop.cli.installed.title": "CLI zainstalowane",
"desktop.cli.installed.message":
"CLI zainstalowane w {{path}}\n\nUruchom ponownie terminal, aby użyć polecenia 'opencode'.",
"desktop.cli.failed.title": "Instalacja nie powiodła się",
"desktop.cli.failed.message": "Nie udało się zainstalować CLI: {{error}}",
}

View File

@@ -0,0 +1,27 @@
export const dict = {
"desktop.menu.checkForUpdates": "Проверить обновления...",
"desktop.menu.installCli": "Установить CLI...",
"desktop.menu.reloadWebview": "Перезагрузить Webview",
"desktop.menu.restart": "Перезапустить",
"desktop.dialog.chooseFolder": "Выберите папку",
"desktop.dialog.chooseFile": "Выберите файл",
"desktop.dialog.saveFile": "Сохранить файл",
"desktop.updater.checkFailed.title": "Не удалось проверить обновления",
"desktop.updater.checkFailed.message": "Не удалось проверить обновления",
"desktop.updater.none.title": "Обновлений нет",
"desktop.updater.none.message": "Вы уже используете последнюю версию OpenCode",
"desktop.updater.downloadFailed.title": "Обновление не удалось",
"desktop.updater.downloadFailed.message": "Не удалось скачать обновление",
"desktop.updater.downloaded.title": "Обновление загружено",
"desktop.updater.downloaded.prompt": "Версия OpenCode {{version}} загружена. Хотите установить и перезапустить?",
"desktop.updater.installFailed.title": "Обновление не удалось",
"desktop.updater.installFailed.message": "Не удалось установить обновление",
"desktop.cli.installed.title": "CLI установлен",
"desktop.cli.installed.message":
"CLI установлен в {{path}}\n\nПерезапустите терминал, чтобы использовать команду 'opencode'.",
"desktop.cli.failed.title": "Ошибка установки",
"desktop.cli.failed.message": "Не удалось установить CLI: {{error}}",
}

View File

@@ -0,0 +1,28 @@
export const dict = {
"desktop.menu.checkForUpdates": "Перевірити оновлення...",
"desktop.menu.installCli": "Встановити CLI...",
"desktop.menu.reloadWebview": "Перезавантажити Webview",
"desktop.menu.restart": "Перезапустити",
"desktop.dialog.chooseFolder": "Виберіть теку",
"desktop.dialog.chooseFile": "Виберіть файл",
"desktop.dialog.saveFile": "Зберегти файл",
"desktop.updater.checkFailed.title": "Не вдалося перевірити оновлення",
"desktop.updater.checkFailed.message": "Не вдалося перевірити наявність оновлень",
"desktop.updater.none.title": "Немає доступних оновлень",
"desktop.updater.none.message": "Ви вже використовуєте найновішу версію OpenCode",
"desktop.updater.downloadFailed.title": "Помилка оновлення",
"desktop.updater.downloadFailed.message": "Не вдалося завантажити оновлення",
"desktop.updater.downloaded.title": "Оновлення завантажено",
"desktop.updater.downloaded.prompt":
"Версію {{version}} OpenCode завантажено. Бажаєте встановити її та перезапустити?",
"desktop.updater.installFailed.title": "Помилка оновлення",
"desktop.updater.installFailed.message": "Не вдалося встановити оновлення",
"desktop.cli.installed.title": "CLI встановлено",
"desktop.cli.installed.message":
"CLI встановлено до {{path}}\n\nПерезапустіть термінал, щоб використовувати команду 'opencode'.",
"desktop.cli.failed.title": "Не вдалося встановити",
"desktop.cli.failed.message": "Не вдалося встановити CLI: {{error}}",
}

View File

@@ -0,0 +1,26 @@
export const dict = {
"desktop.menu.checkForUpdates": "检查更新...",
"desktop.menu.installCli": "安装 CLI...",
"desktop.menu.reloadWebview": "重新加载 Webview",
"desktop.menu.restart": "重启",
"desktop.dialog.chooseFolder": "选择文件夹",
"desktop.dialog.chooseFile": "选择文件",
"desktop.dialog.saveFile": "保存文件",
"desktop.updater.checkFailed.title": "检查更新失败",
"desktop.updater.checkFailed.message": "无法检查更新",
"desktop.updater.none.title": "没有可用更新",
"desktop.updater.none.message": "你已经在使用最新版本的 OpenCode",
"desktop.updater.downloadFailed.title": "更新失败",
"desktop.updater.downloadFailed.message": "无法下载更新",
"desktop.updater.downloaded.title": "更新已下载",
"desktop.updater.downloaded.prompt": "已下载 OpenCode {{version}} 版本,是否安装并重启?",
"desktop.updater.installFailed.title": "更新失败",
"desktop.updater.installFailed.message": "无法安装更新",
"desktop.cli.installed.title": "CLI 已安装",
"desktop.cli.installed.message": "CLI 已安装到 {{path}}\n\n重启终端以使用 'opencode' 命令。",
"desktop.cli.failed.title": "安装失败",
"desktop.cli.failed.message": "无法安装 CLI: {{error}}",
}

View File

@@ -0,0 +1,26 @@
export const dict = {
"desktop.menu.checkForUpdates": "檢查更新...",
"desktop.menu.installCli": "安裝 CLI...",
"desktop.menu.reloadWebview": "重新載入 Webview",
"desktop.menu.restart": "重新啟動",
"desktop.dialog.chooseFolder": "選擇資料夾",
"desktop.dialog.chooseFile": "選擇檔案",
"desktop.dialog.saveFile": "儲存檔案",
"desktop.updater.checkFailed.title": "檢查更新失敗",
"desktop.updater.checkFailed.message": "無法檢查更新",
"desktop.updater.none.title": "沒有可用更新",
"desktop.updater.none.message": "你已在使用最新版的 OpenCode",
"desktop.updater.downloadFailed.title": "更新失敗",
"desktop.updater.downloadFailed.message": "無法下載更新",
"desktop.updater.downloaded.title": "更新已下載",
"desktop.updater.downloaded.prompt": "已下載 OpenCode {{version}} 版本,是否安裝並重新啟動?",
"desktop.updater.installFailed.title": "更新失敗",
"desktop.updater.installFailed.message": "無法安裝更新",
"desktop.cli.installed.title": "CLI 已安裝",
"desktop.cli.installed.message": "CLI 已安裝到 {{path}}\n\n重新啟動終端機以使用 'opencode' 命令。",
"desktop.cli.failed.title": "安裝失敗",
"desktop.cli.failed.message": "無法安裝 CLI: {{error}}",
}

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en" style="background-color: var(--background-base)">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenCode</title>
<link rel="icon" type="image/png" href="./favicon-96x96-v3.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="./favicon-v3.svg" />
<link rel="shortcut icon" href="./favicon-v3.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon-v3.png" />
<meta name="theme-color" content="#F8F7F7" />
<meta property="og:image" content="./social-share.png" />
<meta property="twitter:image" content="./social-share.png" />
<script id="oc-theme-preload-script" src="./oc-theme-preload.js"></script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh"></div>
<script src="./index.tsx" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,386 @@
// @refresh reload
import {
ACCEPTED_FILE_EXTENSIONS,
AppBaseProviders,
AppInterface,
handleNotificationClick,
loadLocaleDict,
normalizeLocale,
type Locale,
type Platform,
PlatformProvider,
ServerConnection,
useCommand,
useWslServers,
} from "@opencode-ai/app"
import type { UpdaterState } from "@opencode-ai/app/updater"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage"
import { MemoryRouter } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
import { initializationData, initializationReady } from "./initialization"
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
import { availableStartupServer, readyWslConnections } from "./wsl/connections"
import "./styles.css"
import { Splash } from "@opencode-ai/ui/logo"
import { useTheme } from "@opencode-ai/ui/theme/context"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(t("error.dev.rootNotFound"))
}
if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop@${pkg.version}`,
initialScope: {
tags: {
platform: "desktop",
},
},
integrations: (integrations) => {
return integrations.filter(
(i) =>
i.name !== "Breadcrumbs" &&
!(
import.meta.env.OPENCODE_CHANNEL === "prod" &&
(i.name === "GlobalHandlers" || i.name === "BrowserApiErrors")
),
)
},
})
}
void initI18n()
const [updaterState, setUpdaterState] = createSignal<UpdaterState>({ status: "disabled" })
void window.api.updater.subscribe(setUpdaterState)
const deepLinkEvent = "opencode:deep-link"
const emitDeepLinks = (urls: string[]) => {
if (urls.length === 0) return
window.__OPENCODE__ ??= {}
const pending = window.__OPENCODE__.deepLinks ?? []
window.__OPENCODE__.deepLinks = [...pending, ...urls]
window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } }))
}
const listenForDeepLinks = () => {
void window.api.consumeInitialDeepLinks().then((urls) => emitDeepLinks(urls))
return window.api.onDeepLink((urls) => emitDeepLinks(urls))
}
const createPlatform = (): Platform => {
const os = (() => {
const ua = navigator.userAgent
if (ua.includes("Mac")) return "macos"
if (ua.includes("Windows")) return "windows"
if (ua.includes("Linux")) return "linux"
return undefined
})()
const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => {
switch (action) {
case "view.resetZoom":
resetZoom()
return
case "view.zoomIn":
zoomIn()
return
case "view.zoomOut":
zoomOut()
return
}
return window.api.runDesktopMenuAction(action)
}
const storage = (() => {
const cache = new Map<string, AsyncStorage>()
const createStorage = (name: string) => {
const api: AsyncStorage = {
getItem: (key: string) => window.api.storeGet(name, key),
setItem: (key: string, value: string) => window.api.storeSet(name, key, value),
removeItem: (key: string) => window.api.storeDelete(name, key),
clear: () => window.api.storeClear(name),
key: async (index: number) => (await window.api.storeKeys(name))[index],
getLength: () => window.api.storeLength(name),
get length() {
return api.getLength()
},
}
return api
}
return (name = "default.dat") => {
const cached = cache.get(name)
if (cached) return cached
const api = createStorage(name)
cache.set(name, api)
return api
}
})()
const wslServersApi = os === "windows" ? window.api.wslServers : undefined
return {
platform: "desktop",
os,
version: pkg.version,
async openDirectoryPickerDialog(opts) {
return window.api.openDirectoryPicker({
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFolder"),
})
},
async openAttachmentPickerDialog(opts, onFile) {
const result = await window.api.openFilePicker({
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"),
defaultPath: opts?.defaultPath,
extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
})
if (!result) return
try {
for (const file of result.files) {
await onFile(new File([await window.api.readPickedFile(result.token, file.path)], file.name))
}
} finally {
await window.api.releasePickedFiles(result.token)
}
},
async saveFilePickerDialog(opts) {
return window.api.saveFilePicker({
title: opts?.title ?? t("desktop.dialog.saveFile"),
defaultPath: opts?.defaultPath,
})
},
openLink(url: string) {
window.api.openLink(url)
},
async openPath(path: string, app?: string) {
if (os === "windows") {
const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
return window.api.openPath(path, resolvedApp ?? undefined)
}
return window.api.openPath(path, app)
},
back() {
window.history.back()
},
forward() {
window.history.forward()
},
storage,
updater: {
state: updaterState,
check: () => window.api.updater.check(),
install: () => window.api.updater.install(),
},
exportDebugLogs: () => window.api.exportDebugLogs(),
recordFatalRendererError: (error) => window.api.recordFatalRendererError(error),
restart: async () => {
await window.api.killSidecar().catch(() => undefined)
window.api.relaunch()
},
notify: async (title, description, href) => {
const focused = await window.api.getWindowFocused().catch(() => document.hasFocus())
if (focused) return
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
})
notification.onclick = () => {
void window.api.showWindow()
void window.api.setWindowFocus()
handleNotificationClick(href)
notification.close()
}
},
fetch: (input, init) => {
if (input instanceof Request) return fetch(input)
return fetch(input, init)
},
getDefaultServer: async () => {
const url = await window.api.getDefaultServerUrl().catch(() => null)
if (!url) return null
return ServerConnection.Key.make(url)
},
setDefaultServer: async (url: string | null) => {
await window.api.setDefaultServerUrl(url)
},
wslServers: wslServersApi,
getDisplayBackend: async () => {
return window.api.getDisplayBackend().catch(() => null)
},
setDisplayBackend: async (backend) => {
await window.api.setDisplayBackend(backend)
},
parseMarkdown: (markdown: string) => window.api.parseMarkdownCommand(markdown),
webviewZoom,
getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(),
setPinchZoomEnabled,
runDesktopMenuAction,
checkAppExists: async (appName: string) => {
return window.api.checkAppExists(appName)
},
async readClipboardImage() {
const image = await window.api.readClipboardImage().catch(() => null)
if (!image) return null
const blob = new Blob([image.buffer], { type: "image/png" })
return new File([blob], `pasted-image-${Date.now()}.png`, {
type: "image/png",
})
},
}
}
let menuTrigger = null as null | ((id: string) => void)
window.api.onMenuCommand((id) => {
menuTrigger?.(id)
})
listenForDeepLinks()
render(() => {
const platform = createPlatform()
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
const raw = current ?? legacy
if (!raw) return
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
if (!locale) return
const next = normalizeLocale(locale)
if (next !== "en") await loadLocaleDict(next)
return next satisfies Locale
}
const [windowCount] = createResource(() => window.api.getWindowCount())
// Fetch sidecar credentials (available immediately, before health check)
const [sidecar] = createResource(() => window.api.awaitInitialization())
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(loadLocale)
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
if (link?.href) {
e.preventDefault()
platform.openLink(link.href)
}
}
function Inner() {
const cmd = useCommand()
menuTrigger = (id) => cmd.trigger(id)
const theme = useTheme()
createEffect(() => {
theme.themeId()
theme.mode()
const bg = getComputedStyle(document.documentElement).getPropertyValue("--background-base").trim()
if (bg) {
void window.api.setBackgroundColor(bg)
}
})
return null
}
function App() {
const wslServers = useWslServers()
const splash = (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
)
const ready = createMemo(
() => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading,
)
const servers = createMemo(() => {
const data = initializationData(sidecar)
const list: ServerConnection.Any[] = []
if (data) {
list.push({
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
},
})
}
list.push(...readyWslConnections(wslServers.data))
return list
})
const effectiveDefaultServer = createMemo(() =>
ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)),
)
return (
<Show when={ready()} fallback={splash}>
<Show when={effectiveDefaultServer()} keyed>
{(key) => (
<AppInterface defaultServer={key} servers={servers()} router={MemoryRouter}>
<Inner />
</AppInterface>
)}
</Show>
</Show>
)
}
onMount(() => {
document.addEventListener("click", handleClick)
onCleanup(() => {
document.removeEventListener("click", handleClick)
})
})
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale={locale.latest}>
<Show when={true}>{(_) => <App />}</Show>
</AppBaseProviders>
</PlatformProvider>
)
}, root!)

View File

@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import { initializationData, initializationReady } from "./initialization"
describe("desktop renderer initialization", () => {
test("throws the original initialization error before rendering server providers", () => {
const error = new Error("sidecar startup failed")
try {
initializationData(Object.assign(() => undefined, { error }))
throw new Error("expected initialization to fail")
} catch (failure) {
expect(failure).toBe(error)
expect((failure as Error & { localServerStartup?: boolean }).localServerStartup).toBe(true)
}
})
test("removes Electron's remote invocation wrapper from startup errors", () => {
const error = new Error(
"Error invoking remote method 'await-initialization': Error: Cannot migrate session_message projections",
)
try {
initializationData(Object.assign(() => undefined, { error }))
throw new Error("expected initialization to fail")
} catch (failure) {
expect(failure).toBe(error)
expect((failure as Error).message).toBe("Cannot migrate session_message projections")
}
})
test("returns initialized sidecar data", () => {
const sidecar = { url: "http://127.0.0.1:1234", username: "opencode", password: "secret" }
expect(initializationData(Object.assign(() => sidecar, { error: undefined }))).toBe(sidecar)
})
test("does not discard falsy initialization errors", () => {
let caught: unknown
try {
initializationData(Object.assign(() => undefined, { error: "" }))
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Error)
if (!(caught instanceof Error)) return
expect(caught.message).toBe("")
expect((caught as Error & { localServerStartup?: boolean }).localServerStartup).toBe(true)
})
test("checks initialization errors before rendering server providers", () => {
const error = new Error("sidecar startup failed")
expect(() => initializationReady(Object.assign(() => undefined, { error, loading: false }))).toThrow(error)
})
test("waits for pending initialization without reading it", () => {
let reads = 0
expect(
initializationReady(
Object.assign(
() => {
reads++
return undefined
},
{ error: undefined, loading: true },
),
),
).toBe(false)
expect(reads).toBe(0)
})
})

View File

@@ -0,0 +1,22 @@
export function initializationData<A>(state: (() => A | undefined) & { error: unknown }) {
if (state.error !== undefined) throw markLocalServerStartup(state.error)
return state()
}
function markLocalServerStartup(error: unknown) {
const failure = error instanceof Error ? error : new Error(String(error))
const prefix = "Error invoking remote method 'await-initialization': Error: "
if (failure.message.startsWith(prefix)) {
const previous = failure.message
failure.message = failure.message.slice(prefix.length)
if (failure.stack) failure.stack = failure.stack.replace(`Error: ${previous}`, `Error: ${failure.message}`)
}
Object.defineProperty(failure, "localServerStartup", { value: true })
return failure
}
export function initializationReady<A>(state: (() => A | undefined) & { error: unknown; loading: boolean }) {
if (state.loading) return false
initializationData(state)
return true
}

View File

View File

@@ -0,0 +1,138 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { createSignal } from "solid-js"
const OS_NAME = (() => {
if (navigator.userAgent.includes("Mac")) return "macos"
if (navigator.userAgent.includes("Windows")) return "windows"
if (navigator.userAgent.includes("Linux")) return "linux"
return "unknown"
})()
const [webviewZoom, setWebviewZoom] = createSignal(1)
let requestedZoom = 1
let pinchZoomEnabled = false
let wheelPinch = undefined as
| {
active: boolean
startZoom: number
totalDelta: number
timeout: ReturnType<typeof setTimeout> | undefined
}
| undefined
const MAX_ZOOM_LEVEL = 10
const MIN_ZOOM_LEVEL = 0.2
const WHEEL_PINCH_THRESHOLD = 20
const WHEEL_PINCH_STEP = 0.2
const WHEEL_PINCH_END_DELAY = 160
const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
const applyZoom = (next: number) => {
requestedZoom = next
void window.api
.setZoomFactor(next)
.then(() => {
if (requestedZoom !== next) return
setWebviewZoom(next)
})
.catch(() => {
if (requestedZoom !== next) return
requestedZoom = webviewZoom()
})
}
window.api.onZoomFactorChanged((factor) => {
requestedZoom = clamp(factor)
setWebviewZoom(requestedZoom)
})
void window.api.getPinchZoomEnabled().then((enabled) => {
pinchZoomEnabled = enabled
})
window.api.onPinchZoomEnabledChanged((enabled) => {
pinchZoomEnabled = enabled
resetWheelPinch()
})
const setPinchZoomEnabled = (enabled: boolean) => {
pinchZoomEnabled = enabled
resetWheelPinch()
return window.api.setPinchZoomEnabled(enabled)
}
const resetZoom = () => applyZoom(1)
const zoomIn = () => applyZoom(clamp(requestedZoom + 0.2))
const zoomOut = () => applyZoom(clamp(requestedZoom - 0.2))
const resetWheelPinch = () => {
clearTimeout(wheelPinch?.timeout)
wheelPinch = undefined
}
const normalizeWheelDelta = (event: WheelEvent) => {
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return event.deltaY * 16
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return event.deltaY * window.innerHeight
return event.deltaY
}
const updateWheelPinch = (event: WheelEvent) => {
wheelPinch ??= {
active: false,
startZoom: requestedZoom,
totalDelta: 0,
timeout: undefined,
}
clearTimeout(wheelPinch.timeout)
wheelPinch.timeout = setTimeout(resetWheelPinch, WHEEL_PINCH_END_DELAY)
wheelPinch.totalDelta += normalizeWheelDelta(event)
if (!wheelPinch.active && Math.abs(wheelPinch.totalDelta) < WHEEL_PINCH_THRESHOLD) return
if (!wheelPinch.active) {
wheelPinch.active = true
wheelPinch.startZoom = requestedZoom
wheelPinch.totalDelta = 0
return
}
wheelPinch.active = true
applyZoom(clamp(wheelPinch.startZoom - (wheelPinch.totalDelta / WHEEL_PINCH_THRESHOLD) * WHEEL_PINCH_STEP))
}
window.addEventListener(
"wheel",
(event) => {
if (!pinchZoomEnabled) return
if (!event.ctrlKey) return
event.preventDefault()
updateWheelPinch(event)
},
{ passive: false },
)
window.addEventListener("keydown", (event) => {
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
if (event.key === "-") {
event.preventDefault()
zoomOut()
return
}
if (event.key === "=" || event.key === "+") {
event.preventDefault()
zoomIn()
return
}
if (event.key === "0") {
event.preventDefault()
resetZoom()
}
})
export { webviewZoom, resetZoom, setPinchZoomEnabled, zoomIn, zoomOut }

View File

@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import type { WslServersState } from "@opencode-ai/app/wsl/types"
import { availableStartupServer, readyWslConnections } from "./connections"
const state = (kind: "starting" | "ready" | "failed" | "stopped"): WslServersState => ({
runtime: null,
installed: [],
online: [],
distroProbes: {},
opencodeChecks: {},
pendingRestart: false,
job: null,
servers: [
{
config: { id: "wsl:Debian", distro: "Debian" },
runtime: runtime(kind),
},
],
})
function runtime(kind: "starting" | "ready" | "failed" | "stopped") {
if (kind === "ready") return { kind, url: "http://127.0.0.1:4096", username: "opencode", password: "secret" }
if (kind === "failed") return { kind, message: "boom" }
return { kind }
}
describe("WSL desktop connections", () => {
test("publishes a WSL server only after it reports ready", () => {
expect(readyWslConnections(state("starting"))).toEqual([])
expect(readyWslConnections(state("failed"))).toEqual([])
expect(readyWslConnections(state("stopped"))).toEqual([])
expect(readyWslConnections(state("ready"))).toEqual([
expect.objectContaining({ displayName: "Debian", label: "WSL" }),
])
})
test("does not block desktop startup on a configured WSL default", () => {
const key = "wsl:Debian"
expect(availableStartupServer(key, undefined)).toBe("sidecar")
expect(availableStartupServer(key, state("starting"))).toBe("sidecar")
expect(availableStartupServer(key, state("ready"))).toBe(key)
})
})

View File

@@ -0,0 +1,28 @@
import type { WslServersState } from "@opencode-ai/app/wsl/types"
export function readyWslConnections(state?: WslServersState) {
return (state?.servers ?? []).flatMap((item) => {
if (item.runtime.kind !== "ready") return []
return [
{
displayName: item.config.distro,
label: "WSL",
type: "sidecar" as const,
variant: "wsl" as const,
distro: item.config.distro,
http: {
url: item.runtime.url,
username: item.runtime.username ?? undefined,
password: item.runtime.password ?? undefined,
},
},
]
})
}
export function availableStartupServer(defaultServer: string | null | undefined, state?: WslServersState) {
const key = defaultServer ?? "sidecar"
if (!key.startsWith("wsl:")) return key
if (state?.servers.some((item) => item.config.id === key && item.runtime.kind === "ready")) return key
return "sidecar"
}