summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop-electron/src/main
diff options
context:
space:
mode:
authorBrendan Allan <[email protected]>2026-03-04 15:12:34 +0800
committerGitHub <[email protected]>2026-03-04 15:12:34 +0800
commit5cf235fa6cf7b4c890c68f8ff68a96fcae992abf (patch)
tree25fdfd8ce95ad048fb097822995dcf060e8d6d8b /packages/desktop-electron/src/main
parente4f0825c56300286ec0aa82b1006e4006a17e1e1 (diff)
downloadopencode-5cf235fa6cf7b4c890c68f8ff68a96fcae992abf.tar.gz
opencode-5cf235fa6cf7b4c890c68f8ff68a96fcae992abf.zip
desktop: add electron version (#15663)
Diffstat (limited to 'packages/desktop-electron/src/main')
-rw-r--r--packages/desktop-electron/src/main/apps.ts148
-rw-r--r--packages/desktop-electron/src/main/cli.ts279
-rw-r--r--packages/desktop-electron/src/main/constants.ts10
-rw-r--r--packages/desktop-electron/src/main/env.d.ts7
-rw-r--r--packages/desktop-electron/src/main/index.ts449
-rw-r--r--packages/desktop-electron/src/main/ipc.ts176
-rw-r--r--packages/desktop-electron/src/main/logging.ts40
-rw-r--r--packages/desktop-electron/src/main/markdown.ts16
-rw-r--r--packages/desktop-electron/src/main/menu.ts116
-rw-r--r--packages/desktop-electron/src/main/migrate.ts91
-rw-r--r--packages/desktop-electron/src/main/server.ts129
-rw-r--r--packages/desktop-electron/src/main/store.ts15
-rw-r--r--packages/desktop-electron/src/main/windows.ts135
13 files changed, 1611 insertions, 0 deletions
diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts
new file mode 100644
index 000000000..2b4603789
--- /dev/null
+++ b/packages/desktop-electron/src/main/apps.ts
@@ -0,0 +1,148 @@
+import { execFileSync } from "node:child_process"
+import { existsSync, readFileSync, readdirSync } from "node:fs"
+import { dirname, extname, join } from "node:path"
+
+export function checkAppExists(appName: string): boolean {
+ if (process.platform === "win32") return true
+ if (process.platform === "linux") return true
+ return checkMacosApp(appName)
+}
+
+export function resolveAppPath(appName: string): string | null {
+ if (process.platform !== "win32") return appName
+ return resolveWindowsAppPath(appName)
+}
+
+export function wslPath(path: string, mode: "windows" | "linux" | null): string {
+ if (process.platform !== "win32") return path
+
+ const flag = mode === "windows" ? "-w" : "-u"
+ try {
+ if (path.startsWith("~")) {
+ const suffix = path.slice(1)
+ const cmd = `wslpath ${flag} \"$HOME${suffix.replace(/\"/g, '\\"')}\"`
+ const output = execFileSync("wsl", ["-e", "sh", "-lc", cmd])
+ return output.toString().trim()
+ }
+
+ const output = execFileSync("wsl", ["-e", "wslpath", flag, path])
+ return output.toString().trim()
+ } catch (error) {
+ throw new Error(`Failed to run wslpath: ${String(error)}`)
+ }
+}
+
+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`)
+
+ if (locations.some((location) => existsSync(location))) return true
+
+ try {
+ execFileSync("which", [appName])
+ return true
+ } catch {
+ return false
+ }
+}
+
+function resolveWindowsAppPath(appName: string): string | null {
+ let output: string
+ try {
+ output = execFileSync("where", [appName]).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 = (path: string) => {
+ const content = readFileSync(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 (existsSync(resolved)) return resolved
+ }
+
+ if (existsSync(token)) return token
+ }
+
+ return null
+ }
+
+ for (const path of paths) {
+ if (hasExt(path, "cmd") || hasExt(path, "bat")) {
+ const resolved = resolveCmd(path)
+ if (resolved) return resolved
+ }
+
+ if (!extname(path)) {
+ const cmd = `${path}.cmd`
+ if (existsSync(cmd)) {
+ const resolved = resolveCmd(cmd)
+ if (resolved) return resolved
+ }
+
+ const bat = `${path}.bat`
+ if (existsSync(bat)) {
+ const resolved = 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 readdirSync(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
+}
diff --git a/packages/desktop-electron/src/main/cli.ts b/packages/desktop-electron/src/main/cli.ts
new file mode 100644
index 000000000..e338d3913
--- /dev/null
+++ b/packages/desktop-electron/src/main/cli.ts
@@ -0,0 +1,279 @@
+import { execFileSync, spawn } from "node:child_process"
+import { EventEmitter } from "node:events"
+import { chmodSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { dirname, join } from "node:path"
+import readline from "node:readline"
+import { fileURLToPath } from "node:url"
+import { app } from "electron"
+import treeKill from "tree-kill"
+
+import { WSL_ENABLED_KEY } from "./constants"
+import { store } from "./store"
+
+const CLI_INSTALL_DIR = ".opencode/bin"
+const CLI_BINARY_NAME = "opencode"
+
+export type ServerConfig = {
+ hostname?: string
+ port?: number
+}
+
+export type Config = {
+ server?: ServerConfig
+}
+
+export type TerminatedPayload = { code: number | null; signal: number | null }
+
+export type CommandEvent =
+ | { type: "stdout"; value: string }
+ | { type: "stderr"; value: string }
+ | { type: "error"; value: string }
+ | { type: "terminated"; value: TerminatedPayload }
+ | { type: "sqlite"; value: SqliteMigrationProgress }
+
+export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
+
+export type CommandChild = {
+ kill: () => void
+}
+
+const root = dirname(fileURLToPath(import.meta.url))
+
+export function getSidecarPath() {
+ const suffix = process.platform === "win32" ? ".exe" : ""
+ const path = app.isPackaged
+ ? join(process.resourcesPath, `opencode-cli${suffix}`)
+ : join(root, "../../resources", `opencode-cli${suffix}`)
+ console.log(`[cli] Sidecar path resolved: ${path} (isPackaged: ${app.isPackaged})`)
+ return path
+}
+
+export async function getConfig(): Promise<Config | null> {
+ const { events } = spawnCommand("debug config", {})
+ let output = ""
+
+ await new Promise<void>((resolve) => {
+ events.on("stdout", (line: string) => {
+ output += line
+ })
+ events.on("stderr", (line: string) => {
+ output += line
+ })
+ events.on("terminated", () => resolve())
+ events.on("error", () => resolve())
+ })
+
+ try {
+ return JSON.parse(output) as Config
+ } catch {
+ return null
+ }
+}
+
+export async function installCli(): Promise<string> {
+ if (process.platform === "win32") {
+ throw new Error("CLI installation is only supported on macOS & Linux")
+ }
+
+ const sidecar = getSidecarPath()
+ const scriptPath = join(app.getAppPath(), "install")
+ const script = readFileSync(scriptPath, "utf8")
+ const tempScript = join(tmpdir(), "opencode-install.sh")
+
+ writeFileSync(tempScript, script, "utf8")
+ chmodSync(tempScript, 0o755)
+
+ const cmd = spawn(tempScript, ["--binary", sidecar], { stdio: "pipe" })
+ return await new Promise<string>((resolve, reject) => {
+ cmd.on("exit", (code: number | null) => {
+ try {
+ unlinkSync(tempScript)
+ } catch {}
+ if (code === 0) {
+ const installPath = getCliInstallPath()
+ if (installPath) return resolve(installPath)
+ return reject(new Error("Could not determine install path"))
+ }
+ reject(new Error("Install script failed"))
+ })
+ })
+}
+
+export function syncCli() {
+ if (!app.isPackaged) return
+ const installPath = getCliInstallPath()
+ if (!installPath) return
+
+ let version = ""
+ try {
+ version = execFileSync(installPath, ["--version"]).toString().trim()
+ } catch {
+ return
+ }
+
+ const cli = parseVersion(version)
+ const appVersion = parseVersion(app.getVersion())
+ if (!cli || !appVersion) return
+ if (compareVersions(cli, appVersion) >= 0) return
+ void installCli().catch(() => undefined)
+}
+
+export function serve(hostname: string, port: number, password: string) {
+ const args = `--print-logs --log-level WARN serve --hostname ${hostname} --port ${port}`
+ const env = {
+ OPENCODE_SERVER_USERNAME: "opencode",
+ OPENCODE_SERVER_PASSWORD: password,
+ }
+
+ return spawnCommand(args, env)
+}
+
+export function spawnCommand(args: string, extraEnv: Record<string, string>) {
+ console.log(`[cli] Spawning command with args: ${args}`)
+ const base = Object.fromEntries(
+ Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
+ )
+ const envs = {
+ ...base,
+ OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
+ OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
+ OPENCODE_CLIENT: "desktop",
+ XDG_STATE_HOME: app.getPath("userData"),
+ ...extraEnv,
+ }
+
+ const { cmd, cmdArgs } = buildCommand(args, envs)
+ console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
+ const child = spawn(cmd, cmdArgs, {
+ env: envs,
+ detached: true,
+ windowsHide: true,
+ stdio: ["ignore", "pipe", "pipe"],
+ })
+ console.log(`[cli] Spawned process with PID: ${child.pid}`)
+
+ const events = new EventEmitter()
+ const exit = new Promise<TerminatedPayload>((resolve) => {
+ child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
+ console.log(`[cli] Process exited with code: ${code}, signal: ${signal}`)
+ resolve({ code: code ?? null, signal: null })
+ })
+ child.on("error", (error: Error) => {
+ console.error(`[cli] Process error: ${error.message}`)
+ events.emit("error", error.message)
+ })
+ })
+
+ const stdout = child.stdout
+ const stderr = child.stderr
+
+ if (stdout) {
+ readline.createInterface({ input: stdout }).on("line", (line: string) => {
+ if (handleSqliteProgress(events, line)) return
+ events.emit("stdout", `${line}\n`)
+ })
+ }
+
+ if (stderr) {
+ readline.createInterface({ input: stderr }).on("line", (line: string) => {
+ if (handleSqliteProgress(events, line)) return
+ events.emit("stderr", `${line}\n`)
+ })
+ }
+
+ exit.then((payload) => {
+ events.emit("terminated", payload)
+ })
+
+ const kill = () => {
+ if (!child.pid) return
+ treeKill(child.pid)
+ }
+
+ return { events, child: { kill }, exit }
+}
+
+function handleSqliteProgress(events: EventEmitter, line: string) {
+ const stripped = line.startsWith("sqlite-migration:") ? line.slice("sqlite-migration:".length).trim() : null
+ if (!stripped) return false
+ if (stripped === "done") {
+ events.emit("sqlite", { type: "Done" })
+ return true
+ }
+ const value = Number.parseInt(stripped, 10)
+ if (!Number.isNaN(value)) {
+ events.emit("sqlite", { type: "InProgress", value })
+ return true
+ }
+ return false
+}
+
+function buildCommand(args: string, env: Record<string, string>) {
+ if (process.platform === "win32" && isWslEnabled()) {
+ console.log(`[cli] Using WSL mode`)
+ const version = app.getVersion()
+ const script = [
+ "set -e",
+ 'BIN="$HOME/.opencode/bin/opencode"',
+ 'if [ ! -x "$BIN" ]; then',
+ ` curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)} --no-modify-path`,
+ "fi",
+ `${envPrefix(env)} exec "$BIN" ${args}`,
+ ].join("\n")
+
+ return { cmd: "wsl", cmdArgs: ["-e", "bash", "-lc", script] }
+ }
+
+ if (process.platform === "win32") {
+ const sidecar = getSidecarPath()
+ console.log(`[cli] Windows direct mode, sidecar: ${sidecar}`)
+ return { cmd: sidecar, cmdArgs: args.split(" ") }
+ }
+
+ const sidecar = getSidecarPath()
+ const shell = process.env.SHELL || "/bin/sh"
+ const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
+ console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`)
+ return { cmd: shell, cmdArgs: ["-l", "-c", line] }
+}
+
+function envPrefix(env: Record<string, string>) {
+ const entries = Object.entries(env).map(([key, value]) => `${key}=${shellEscape(value)}`)
+ return entries.join(" ")
+}
+
+function shellEscape(input: string) {
+ if (!input) return "''"
+ return `'${input.replace(/'/g, `'"'"'`)}'`
+}
+
+function getCliInstallPath() {
+ const home = process.env.HOME
+ if (!home) return null
+ return join(home, CLI_INSTALL_DIR, CLI_BINARY_NAME)
+}
+
+function isWslEnabled() {
+ return store.get(WSL_ENABLED_KEY) === true
+}
+
+function parseVersion(value: string) {
+ const parts = value
+ .replace(/^v/, "")
+ .split(".")
+ .map((part) => Number.parseInt(part, 10))
+ if (parts.some((part) => Number.isNaN(part))) return null
+ return parts
+}
+
+function compareVersions(a: number[], b: number[]) {
+ const len = Math.max(a.length, b.length)
+ for (let i = 0; i < len; i += 1) {
+ const left = a[i] ?? 0
+ const right = b[i] ?? 0
+ if (left > right) return 1
+ if (left < right) return -1
+ }
+ return 0
+}
diff --git a/packages/desktop-electron/src/main/constants.ts b/packages/desktop-electron/src/main/constants.ts
new file mode 100644
index 000000000..1e21661c1
--- /dev/null
+++ b/packages/desktop-electron/src/main/constants.ts
@@ -0,0 +1,10 @@
+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 SETTINGS_STORE = "opencode.settings"
+export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
+export const WSL_ENABLED_KEY = "wslEnabled"
+export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
diff --git a/packages/desktop-electron/src/main/env.d.ts b/packages/desktop-electron/src/main/env.d.ts
new file mode 100644
index 000000000..0ee0c551d
--- /dev/null
+++ b/packages/desktop-electron/src/main/env.d.ts
@@ -0,0 +1,7 @@
+interface ImportMetaEnv {
+ readonly OPENCODE_CHANNEL: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts
new file mode 100644
index 000000000..03c1e128e
--- /dev/null
+++ b/packages/desktop-electron/src/main/index.ts
@@ -0,0 +1,449 @@
+import { app, BrowserWindow, dialog } from "electron"
+import type { Event } from "electron"
+import pkg from "electron-updater"
+import { randomUUID } from "node:crypto"
+import { EventEmitter } from "node:events"
+import { existsSync } from "node:fs"
+import { homedir } from "node:os"
+import { join } from "node:path"
+import { createServer } from "node:net"
+
+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",
+}
+app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
+app.setPath("userData", join(app.getPath("appData"), app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"))
+const { autoUpdater } = pkg
+
+import { checkAppExists, resolveAppPath, wslPath } from "./apps"
+import { installCli, syncCli } from "./cli"
+import { CHANNEL, UPDATER_ENABLED } from "./constants"
+import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
+import { initLogging } from "./logging"
+import { parseMarkdown } from "./markdown"
+import { createMenu } from "./menu"
+import {
+ checkHealth,
+ checkHealthOrAskRetry,
+ getDefaultServerUrl,
+ getSavedServerUrl,
+ getWslConfig,
+ setDefaultServerUrl,
+ setWslConfig,
+ spawnLocalServer,
+} from "./server"
+import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows"
+
+import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
+import type { CommandChild } from "./cli"
+
+type ServerConnection =
+ | { variant: "existing"; url: string }
+ | {
+ variant: "cli"
+ url: string
+ password: null | string
+ health: {
+ wait: Promise<void>
+ }
+ events: any
+ }
+
+const initEmitter = new EventEmitter()
+let initStep: InitStep = { phase: "server_waiting" }
+
+let mainWindow: BrowserWindow | null = null
+let loadingWindow: BrowserWindow | null = null
+let sidecar: CommandChild | null = null
+let loadingComplete = defer<void>()
+
+const pendingDeepLinks: string[] = []
+
+const serverReady = defer<ServerReadyData>()
+const logger = initLogging()
+
+logger.log("app starting", { version: app.getVersion(), packaged: app.isPackaged })
+
+setupApp()
+
+function setupApp() {
+ ensureLoopbackNoProxy()
+ app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
+
+ if (!app.requestSingleInstanceLock()) {
+ app.quit()
+ return
+ }
+
+ 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)
+ }
+ focusMainWindow()
+ })
+
+ 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", () => {
+ killSidecar()
+ })
+
+ void app.whenReady().then(async () => {
+ // migrate()
+ app.setAsDefaultProtocolClient("opencode")
+ setDockIcon()
+ setupAutoUpdater()
+ syncCli()
+ await initialize()
+ })
+}
+
+function emitDeepLinks(urls: string[]) {
+ if (urls.length === 0) return
+ pendingDeepLinks.push(...urls)
+ if (mainWindow) sendDeepLinks(mainWindow, urls)
+}
+
+function focusMainWindow() {
+ if (!mainWindow) return
+ mainWindow.show()
+ mainWindow.focus()
+}
+
+function setInitStep(step: InitStep) {
+ initStep = step
+ logger.log("init step", { step })
+ initEmitter.emit("step", step)
+}
+
+async function setupServerConnection(): Promise<ServerConnection> {
+ const customUrl = await getSavedServerUrl()
+
+ if (customUrl && (await checkHealthOrAskRetry(customUrl))) {
+ serverReady.resolve({ url: customUrl, password: null })
+ return { variant: "existing", url: customUrl }
+ }
+
+ const port = await getSidecarPort()
+ const hostname = "127.0.0.1"
+ const localUrl = `http://${hostname}:${port}`
+
+ if (await checkHealth(localUrl)) {
+ serverReady.resolve({ url: localUrl, password: null })
+ return { variant: "existing", url: localUrl }
+ }
+
+ const password = randomUUID()
+ const { child, health, events } = spawnLocalServer(hostname, port, password)
+ sidecar = child
+
+ return {
+ variant: "cli",
+ url: localUrl,
+ password,
+ health,
+ events,
+ }
+}
+
+async function initialize() {
+ const needsMigration = !sqliteFileExists()
+ const sqliteDone = needsMigration ? defer<void>() : undefined
+
+ const loadingTask = (async () => {
+ logger.log("setting up server connection")
+ const serverConnection = await setupServerConnection()
+ logger.log("server connection ready", { variant: serverConnection.variant, url: serverConnection.url })
+
+ const cliHealthCheck = (() => {
+ if (serverConnection.variant == "cli") {
+ return async () => {
+ const { events, health } = serverConnection
+ events.on("sqlite", (progress: SqliteMigrationProgress) => {
+ setInitStep({ phase: "sqlite_waiting" })
+ if (loadingWindow) sendSqliteMigrationProgress(loadingWindow, progress)
+ if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
+ if (progress.type === "Done") sqliteDone?.resolve()
+ })
+ await health.wait
+ serverReady.resolve({ url: serverConnection.url, password: serverConnection.password })
+ }
+ } else {
+ serverReady.resolve({ url: serverConnection.url, password: null })
+ return null
+ }
+ })()
+
+ logger.log("server connection started")
+
+ if (cliHealthCheck) {
+ if (needsMigration) await sqliteDone?.promise
+ cliHealthCheck?.()
+ }
+
+ logger.log("loading task finished")
+ })()
+
+ const globals = {
+ updaterEnabled: UPDATER_ENABLED,
+ wsl: getWslConfig().enabled,
+ deepLinks: pendingDeepLinks,
+ }
+
+ const loadingWindow = await (async () => {
+ if (needsMigration /** TOOD: 1 second timeout */) {
+ // showLoading = await Promise.race([init.then(() => false).catch(() => false), delay(1000).then(() => true)])
+ const loadingWindow = createLoadingWindow(globals)
+ await delay(1000)
+ return loadingWindow
+ } else {
+ logger.log("showing main window without loading window")
+ mainWindow = createMainWindow(globals)
+ wireMenu()
+ }
+ })()
+
+ await loadingTask
+ setInitStep({ phase: "done" })
+
+ if (loadingWindow) {
+ await loadingComplete.promise
+ }
+
+ if (!mainWindow) {
+ mainWindow = createMainWindow(globals)
+ wireMenu()
+ }
+
+ loadingWindow?.close()
+}
+
+function wireMenu() {
+ if (!mainWindow) return
+ createMenu({
+ trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
+ installCli: () => {
+ void installCli()
+ },
+ checkForUpdates: () => {
+ void checkForUpdates(true)
+ },
+ reload: () => mainWindow?.reload(),
+ relaunch: () => {
+ killSidecar()
+ app.relaunch()
+ app.exit(0)
+ },
+ })
+}
+
+registerIpcHandlers({
+ killSidecar: () => killSidecar(),
+ installCli: async () => installCli(),
+ awaitInitialization: async (sendStep) => {
+ sendStep(initStep)
+ const listener = (step: InitStep) => sendStep(step)
+ initEmitter.on("step", listener)
+ try {
+ logger.log("awaiting server ready")
+ const res = await serverReady.promise
+ logger.log("server ready", { url: res.url })
+ return res
+ } finally {
+ initEmitter.off("step", listener)
+ }
+ },
+ getDefaultServerUrl: () => getDefaultServerUrl(),
+ setDefaultServerUrl: (url) => setDefaultServerUrl(url),
+ getWslConfig: () => Promise.resolve(getWslConfig()),
+ setWslConfig: (config: WslConfig) => setWslConfig(config),
+ getDisplayBackend: async () => null,
+ setDisplayBackend: async () => undefined,
+ parseMarkdown: async (markdown) => parseMarkdown(markdown),
+ checkAppExists: async (appName) => checkAppExists(appName),
+ wslPath: async (path, mode) => wslPath(path, mode),
+ resolveAppPath: async (appName) => resolveAppPath(appName),
+ loadingWindowComplete: () => loadingComplete.resolve(),
+ runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
+ checkUpdate: async () => checkUpdate(),
+ installUpdate: async () => installUpdate(),
+})
+
+function killSidecar() {
+ if (!sidecar) return
+ sidecar.kill()
+ sidecar = null
+}
+
+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")
+}
+
+async function getSidecarPort() {
+ const fromEnv = process.env.OPENCODE_PORT
+ if (fromEnv) {
+ const parsed = Number.parseInt(fromEnv, 10)
+ if (!Number.isNaN(parsed)) return parsed
+ }
+
+ return await 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
+ }
+ const port = address.port
+ server.close(() => resolve(port))
+ })
+ })
+}
+
+function sqliteFileExists() {
+ const xdg = process.env.XDG_DATA_HOME
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
+ return existsSync(join(base, "opencode", "opencode.db"))
+}
+
+function setupAutoUpdater() {
+ if (!UPDATER_ENABLED) return
+ autoUpdater.logger = logger
+ autoUpdater.channel = "latest"
+ autoUpdater.allowPrerelease = false
+ autoUpdater.allowDowngrade = true
+ autoUpdater.autoDownload = false
+ autoUpdater.autoInstallOnAppQuit = true
+ logger.log("auto updater configured", {
+ channel: autoUpdater.channel,
+ allowPrerelease: autoUpdater.allowPrerelease,
+ allowDowngrade: autoUpdater.allowDowngrade,
+ currentVersion: app.getVersion(),
+ })
+}
+
+let updateReady = false
+
+async function checkUpdate() {
+ if (!UPDATER_ENABLED) return { updateAvailable: false }
+ updateReady = false
+ logger.log("checking for updates", {
+ currentVersion: app.getVersion(),
+ channel: autoUpdater.channel,
+ allowPrerelease: autoUpdater.allowPrerelease,
+ allowDowngrade: autoUpdater.allowDowngrade,
+ })
+ try {
+ const result = await autoUpdater.checkForUpdates()
+ const updateInfo = result?.updateInfo
+ logger.log("update metadata fetched", {
+ releaseVersion: updateInfo?.version ?? null,
+ releaseDate: updateInfo?.releaseDate ?? null,
+ releaseName: updateInfo?.releaseName ?? null,
+ files: updateInfo?.files?.map((file) => file.url) ?? [],
+ })
+ const version = result?.updateInfo?.version
+ if (!version) {
+ logger.log("no update available", { reason: "provider returned no newer version" })
+ return { updateAvailable: false }
+ }
+ logger.log("update available", { version })
+ await autoUpdater.downloadUpdate()
+ logger.log("update download completed", { version })
+ updateReady = true
+ return { updateAvailable: true, version }
+ } catch (error) {
+ logger.error("update check failed", error)
+ return { updateAvailable: false, failed: true }
+ }
+}
+
+async function installUpdate() {
+ if (!updateReady) return
+ killSidecar()
+ autoUpdater.quitAndInstall()
+}
+
+async function checkForUpdates(alertOnFail: boolean) {
+ if (!UPDATER_ENABLED) return
+ logger.log("checkForUpdates invoked", { alertOnFail })
+ const result = await checkUpdate()
+ if (!result.updateAvailable) {
+ if (result.failed) {
+ logger.log("no update decision", { reason: "update check failed" })
+ if (!alertOnFail) return
+ await dialog.showMessageBox({
+ type: "error",
+ message: "Update check failed.",
+ title: "Update Error",
+ })
+ return
+ }
+
+ logger.log("no update decision", { reason: "already up to date" })
+ if (!alertOnFail) return
+ await dialog.showMessageBox({
+ type: "info",
+ message: "You're up to date.",
+ title: "No Updates",
+ })
+ return
+ }
+
+ const response = await dialog.showMessageBox({
+ type: "info",
+ message: `Update ${result.version ?? ""} downloaded. Restart now?`,
+ title: "Update Ready",
+ buttons: ["Restart", "Later"],
+ defaultId: 0,
+ cancelId: 1,
+ })
+ logger.log("update prompt response", {
+ version: result.version ?? null,
+ restartNow: response.response === 0,
+ })
+ if (response.response === 0) {
+ await installUpdate()
+ }
+}
+
+function delay(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+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 }
+}
diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts
new file mode 100644
index 000000000..bbb5379bb
--- /dev/null
+++ b/packages/desktop-electron/src/main/ipc.ts
@@ -0,0 +1,176 @@
+import { execFile } from "node:child_process"
+import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
+import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
+
+import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
+import { getStore } from "./store"
+
+type Deps = {
+ killSidecar: () => void
+ installCli: () => Promise<string>
+ awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
+ getDefaultServerUrl: () => Promise<string | null> | string | null
+ setDefaultServerUrl: (url: string | null) => Promise<void> | void
+ getWslConfig: () => Promise<WslConfig>
+ setWslConfig: (config: WslConfig) => 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
+ wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
+ resolveAppPath: (appName: string) => Promise<string | null>
+ loadingWindowComplete: () => void
+ runUpdater: (alertOnFail: boolean) => Promise<void> | void
+ checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
+ installUpdate: () => Promise<void> | void
+}
+
+export function registerIpcHandlers(deps: Deps) {
+ ipcMain.handle("kill-sidecar", () => deps.killSidecar())
+ ipcMain.handle("install-cli", () => deps.installCli())
+ ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
+ const send = (step: InitStep) => event.sender.send("init-step", step)
+ return deps.awaitInitialization(send)
+ })
+ 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-wsl-config", () => deps.getWslConfig())
+ ipcMain.handle("set-wsl-config", (_event: IpcMainInvokeEvent, config: WslConfig) => deps.setWslConfig(config))
+ 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("wsl-path", (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null) =>
+ deps.wslPath(path, mode),
+ )
+ ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
+ ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete())
+ ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
+ ipcMain.handle("check-update", () => deps.checkUpdate())
+ ipcMain.handle("install-update", () => deps.installUpdate())
+ ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
+ const store = getStore(name)
+ const value = store.get(key)
+ if (value === undefined || value === null) return null
+ return typeof value === "string" ? value : JSON.stringify(value)
+ })
+ 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] : [])],
+ 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 }) => {
+ const result = await dialog.showOpenDialog({
+ properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
+ title: opts?.title ?? "Choose a file",
+ defaultPath: opts?.defaultPath,
+ })
+ if (result.canceled) return null
+ return opts?.multiple ? result.filePaths : result.filePaths[0]
+ },
+ )
+
+ 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-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", () => {
+ app.relaunch()
+ app.exit(0)
+ })
+
+ ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
+ ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor))
+}
+
+export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) {
+ win.webContents.send("sqlite-migration-progress", progress)
+}
+
+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)
+}
diff --git a/packages/desktop-electron/src/main/logging.ts b/packages/desktop-electron/src/main/logging.ts
new file mode 100644
index 000000000..d315b2d34
--- /dev/null
+++ b/packages/desktop-electron/src/main/logging.ts
@@ -0,0 +1,40 @@
+import log from "electron-log/main.js"
+import { readFileSync, readdirSync, statSync, unlinkSync } from "node:fs"
+import { dirname, join } from "node:path"
+
+const MAX_LOG_AGE_DAYS = 7
+const TAIL_LINES = 1000
+
+export function initLogging() {
+ log.transports.file.maxSize = 5 * 1024 * 1024
+ cleanup()
+ return log
+}
+
+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 cleanup() {
+ const path = log.transports.file.getFile().path
+ const dir = dirname(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.isFile()) continue
+ if (info.mtimeMs < cutoff) unlinkSync(file)
+ } catch {
+ continue
+ }
+ }
+}
diff --git a/packages/desktop-electron/src/main/markdown.ts b/packages/desktop-electron/src/main/markdown.ts
new file mode 100644
index 000000000..b956f4876
--- /dev/null
+++ b/packages/desktop-electron/src/main/markdown.ts
@@ -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,
+ })
+}
diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts
new file mode 100644
index 000000000..53707ba7f
--- /dev/null
+++ b/packages/desktop-electron/src/main/menu.ts
@@ -0,0 +1,116 @@
+import { BrowserWindow, Menu, shell } from "electron"
+
+import { UPDATER_ENABLED } from "./constants"
+
+type Deps = {
+ trigger: (id: string) => void
+ installCli: () => void
+ checkForUpdates: () => void
+ reload: () => void
+ relaunch: () => void
+}
+
+export function createMenu(deps: Deps) {
+ if (process.platform !== "darwin") return
+
+ const template: Electron.MenuItemConstructorOptions[] = [
+ {
+ label: "OpenCode",
+ submenu: [
+ { role: "about" },
+ {
+ label: "Check for Updates...",
+ enabled: UPDATER_ENABLED,
+ click: () => deps.checkForUpdates(),
+ },
+ {
+ label: "Install CLI...",
+ click: () => deps.installCli(),
+ },
+ {
+ label: "Reload Webview",
+ click: () => deps.reload(),
+ },
+ {
+ label: "Restart",
+ click: () => deps.relaunch(),
+ },
+ { type: "separator" },
+ { role: "hide" },
+ { role: "hideOthers" },
+ { role: "unhide" },
+ { type: "separator" },
+ { role: "quit" },
+ ],
+ },
+ {
+ label: "File",
+ submenu: [
+ { label: "New Session", accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") },
+ { label: "Open Project...", accelerator: "Cmd+O", click: () => deps.trigger("project.open") },
+ { type: "separator" },
+ { role: "close" },
+ ],
+ },
+ {
+ label: "Edit",
+ submenu: [
+ { role: "undo" },
+ { role: "redo" },
+ { type: "separator" },
+ { role: "cut" },
+ { role: "copy" },
+ { role: "paste" },
+ { role: "selectAll" },
+ ],
+ },
+ {
+ label: "View",
+ submenu: [
+ { label: "Toggle Sidebar", accelerator: "Cmd+B", click: () => deps.trigger("sidebar.toggle") },
+ { label: "Toggle Terminal", accelerator: "Ctrl+`", click: () => deps.trigger("terminal.toggle") },
+ { label: "Toggle File Tree", click: () => deps.trigger("fileTree.toggle") },
+ { type: "separator" },
+ { label: "Back", click: () => deps.trigger("common.goBack") },
+ { label: "Forward", click: () => deps.trigger("common.goForward") },
+ { type: "separator" },
+ {
+ label: "Previous Session",
+ accelerator: "Option+ArrowUp",
+ click: () => deps.trigger("session.previous"),
+ },
+ {
+ label: "Next Session",
+ accelerator: "Option+ArrowDown",
+ click: () => deps.trigger("session.next"),
+ },
+ { type: "separator" },
+ {
+ label: "Toggle Developer Tools",
+ accelerator: "Alt+Cmd+I",
+ click: () => BrowserWindow.getFocusedWindow()?.webContents.toggleDevTools(),
+ },
+ ],
+ },
+ {
+ label: "Help",
+ submenu: [
+ { label: "OpenCode Documentation", click: () => shell.openExternal("https://opencode.ai/docs") },
+ { label: "Support Forum", click: () => shell.openExternal("https://discord.com/invite/opencode") },
+ { type: "separator" },
+ { type: "separator" },
+ {
+ label: "Share Feedback",
+ click: () =>
+ shell.openExternal("https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml"),
+ },
+ {
+ label: "Report a Bug",
+ click: () => shell.openExternal("https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml"),
+ },
+ ],
+ },
+ ]
+
+ Menu.setApplicationMenu(Menu.buildFromTemplate(template))
+}
diff --git a/packages/desktop-electron/src/main/migrate.ts b/packages/desktop-electron/src/main/migrate.ts
new file mode 100644
index 000000000..bad1349ee
--- /dev/null
+++ b/packages/desktop-electron/src/main/migrate.ts
@@ -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, store } 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 (store.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")
+ store.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")
+ store.set(TAURI_MIGRATED_KEY, true)
+}
diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts
new file mode 100644
index 000000000..92018e72e
--- /dev/null
+++ b/packages/desktop-electron/src/main/server.ts
@@ -0,0 +1,129 @@
+import { dialog } from "electron"
+
+import { getConfig, serve, type CommandChild, type Config } from "./cli"
+import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
+import { store } from "./store"
+
+export type WslConfig = { enabled: boolean }
+
+export type HealthCheck = { wait: Promise<void> }
+
+export function getDefaultServerUrl(): string | null {
+ const value = store.get(DEFAULT_SERVER_URL_KEY)
+ return typeof value === "string" ? value : null
+}
+
+export function setDefaultServerUrl(url: string | null) {
+ if (url) {
+ store.set(DEFAULT_SERVER_URL_KEY, url)
+ return
+ }
+
+ store.delete(DEFAULT_SERVER_URL_KEY)
+}
+
+export function getWslConfig(): WslConfig {
+ const value = store.get(WSL_ENABLED_KEY)
+ return { enabled: typeof value === "boolean" ? value : false }
+}
+
+export function setWslConfig(config: WslConfig) {
+ store.set(WSL_ENABLED_KEY, config.enabled)
+}
+
+export async function getSavedServerUrl(): Promise<string | null> {
+ const direct = getDefaultServerUrl()
+ if (direct) return direct
+
+ const config = await getConfig().catch(() => null)
+ if (!config) return null
+ return getServerUrlFromConfig(config)
+}
+
+export function spawnLocalServer(hostname: string, port: number, password: string) {
+ const { child, exit, events } = serve(hostname, port, password)
+
+ const wait = (async () => {
+ const url = `http://${hostname}:${port}`
+
+ const ready = async () => {
+ while (true) {
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ if (await checkHealth(url, password)) return
+ }
+ }
+
+ const terminated = async () => {
+ const payload = await exit
+ throw new Error(
+ `Sidecar terminated before becoming healthy (code=${payload.code ?? "unknown"} signal=${
+ payload.signal ?? "unknown"
+ })`,
+ )
+ }
+
+ await Promise.race([ready(), terminated()])
+ })()
+
+ return { child, health: { wait }, events }
+}
+
+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
+ }
+}
+
+export async function checkHealthOrAskRetry(url: string): Promise<boolean> {
+ while (true) {
+ if (await checkHealth(url)) return true
+
+ const result = await dialog.showMessageBox({
+ type: "warning",
+ message: `Could not connect to configured server:\n${url}\n\nWould you like to retry or start a local server instead?`,
+ title: "Connection Failed",
+ buttons: ["Retry", "Start Local"],
+ defaultId: 0,
+ cancelId: 1,
+ })
+
+ if (result.response === 0) continue
+ return false
+ }
+}
+
+export function normalizeHostnameForUrl(hostname: string) {
+ if (hostname === "0.0.0.0") return "127.0.0.1"
+ if (hostname === "::") return "[::1]"
+ if (hostname.includes(":") && !hostname.startsWith("[")) return `[${hostname}]`
+ return hostname
+}
+
+export function getServerUrlFromConfig(config: Config) {
+ const server = config.server
+ if (!server?.port) return null
+ const host = server.hostname ? normalizeHostnameForUrl(server.hostname) : "127.0.0.1"
+ return `http://${host}:${server.port}`
+}
+
+export type { CommandChild }
diff --git a/packages/desktop-electron/src/main/store.ts b/packages/desktop-electron/src/main/store.ts
new file mode 100644
index 000000000..fa1c5682e
--- /dev/null
+++ b/packages/desktop-electron/src/main/store.ts
@@ -0,0 +1,15 @@
+import Store from "electron-store"
+
+import { SETTINGS_STORE } from "./constants"
+
+const cache = new Map<string, Store>()
+
+export function getStore(name = SETTINGS_STORE) {
+ const cached = cache.get(name)
+ if (cached) return cached
+ const next = new Store({ name })
+ cache.set(name, next)
+ return next
+}
+
+export const store = getStore(SETTINGS_STORE)
diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts
new file mode 100644
index 000000000..9178457f8
--- /dev/null
+++ b/packages/desktop-electron/src/main/windows.ts
@@ -0,0 +1,135 @@
+import windowState from "electron-window-state"
+import { app, BrowserWindow, nativeImage } from "electron"
+import { dirname, join } from "node:path"
+import { fileURLToPath } from "node:url"
+
+type Globals = {
+ updaterEnabled: boolean
+ wsl: boolean
+ deepLinks?: string[]
+}
+
+const root = dirname(fileURLToPath(import.meta.url))
+
+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}`)
+}
+
+export function setDockIcon() {
+ if (process.platform !== "darwin") return
+ app.dock?.setIcon(nativeImage.createFromPath(join(iconsDir(), "[email protected]")))
+}
+
+export function createMainWindow(globals: Globals) {
+ const state = windowState({
+ defaultWidth: 1280,
+ defaultHeight: 800,
+ })
+
+ const win = new BrowserWindow({
+ x: state.x,
+ y: state.y,
+ width: state.width,
+ height: state.height,
+ show: true,
+ title: "OpenCode",
+ icon: iconPath(),
+ ...(process.platform === "darwin"
+ ? {
+ titleBarStyle: "hidden" as const,
+ trafficLightPosition: { x: 12, y: 14 },
+ }
+ : {}),
+ ...(process.platform === "win32"
+ ? {
+ frame: false,
+ titleBarStyle: "hidden" as const,
+ titleBarOverlay: {
+ color: "transparent",
+ symbolColor: "#999",
+ height: 40,
+ },
+ }
+ : {}),
+ webPreferences: {
+ preload: join(root, "../preload/index.mjs"),
+ sandbox: false,
+ },
+ })
+
+ state.manage(win)
+ loadWindow(win, "index.html")
+ wireZoom(win)
+ injectGlobals(win, globals)
+
+ return win
+}
+
+export function createLoadingWindow(globals: Globals) {
+ const win = new BrowserWindow({
+ width: 640,
+ height: 480,
+ resizable: false,
+ center: true,
+ show: true,
+ icon: iconPath(),
+ ...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
+ ...(process.platform === "win32"
+ ? {
+ frame: false,
+ titleBarStyle: "hidden" as const,
+ titleBarOverlay: {
+ color: "transparent",
+ symbolColor: "#999",
+ height: 40,
+ },
+ }
+ : {}),
+ webPreferences: {
+ preload: join(root, "../preload/index.mjs"),
+ sandbox: false,
+ },
+ })
+
+ loadWindow(win, "loading.html")
+ injectGlobals(win, globals)
+
+ return win
+}
+
+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.loadFile(join(root, `../renderer/${html}`))
+}
+
+function injectGlobals(win: BrowserWindow, globals: Globals) {
+ win.webContents.on("dom-ready", () => {
+ const deepLinks = globals.deepLinks ?? []
+ const data = {
+ updaterEnabled: globals.updaterEnabled,
+ wsl: globals.wsl,
+ deepLinks: Array.isArray(deepLinks) ? deepLinks.splice(0) : deepLinks,
+ }
+ void win.webContents.executeJavaScript(
+ `window.__OPENCODE__ = Object.assign(window.__OPENCODE__ ?? {}, ${JSON.stringify(data)})`,
+ )
+ })
+}
+
+function wireZoom(win: BrowserWindow) {
+ win.webContents.setZoomFactor(1)
+ win.webContents.on("zoom-changed", () => {
+ win.webContents.setZoomFactor(1)
+ })
+}