chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -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)}`, { cause: 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
}

View File

@@ -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"

View File

@@ -0,0 +1,29 @@
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 namespace Log {
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
}
export namespace Database {
export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path
export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
}
export namespace JsonMigration {
export type Progress = import("../../../opencode/dist/types/src/node").JsonMigration.Progress
export const run: typeof import("../../../opencode/dist/types/src/node").JsonMigration.run
}
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
}

View File

@@ -0,0 +1,452 @@
import { randomUUID } from "node:crypto"
import { EventEmitter } from "node:events"
import { existsSync } from "node:fs"
import { createServer } from "node:net"
import { homedir } from "node:os"
import { join } from "node:path"
import type { Event } from "electron"
import { app, BrowserWindow, dialog } from "electron"
import pkg from "electron-updater"
import contextMenu from "electron-context-menu"
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 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 appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
app.setAppUserModelId(appId)
app.setPath("userData", join(app.getPath("appData"), appId))
const { autoUpdater } = pkg
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
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 { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import {
createLoadingWindow,
createMainWindow,
registerRendererProtocol,
setBackgroundColor,
setDockIcon,
} from "./windows"
import { drizzle } from "drizzle-orm/node-sqlite/driver"
import type { Server } from "virtual:opencode-server"
const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null
let server: Server.Listener | null = null
const 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()
})
app.on("will-quit", () => {
killSidecar()
})
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
killSidecar()
app.exit(0)
})
}
void app.whenReady().then(async () => {
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()
setupAutoUpdater()
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 initialize() {
const needsMigration = !sqliteFileExists()
const sqliteDone = needsMigration ? defer<void>() : undefined
let overlay: BrowserWindow | null = null
const port = await getSidecarPort()
const hostname = "127.0.0.1"
const url = `http://${hostname}:${port}`
const password = randomUUID()
const loadingTask = (async () => {
logger.log("sidecar connection started", { url })
initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" })
if (overlay) sendSqliteMigrationProgress(overlay, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
if (progress.type === "Done") sqliteDone?.resolve()
})
if (needsMigration) {
const { Database, JsonMigration } = await import("virtual:opencode-server")
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
progress: (event: { current: number; total: number }) => {
const percent = Math.round(event.current / event.total) * 100
initEmitter.emit("sqlite", { type: "InProgress", value: percent })
},
})
initEmitter.emit("sqlite", { type: "Done" })
sqliteDone?.resolve()
}
if (needsMigration) {
await sqliteDone?.promise
}
logger.log("spawning sidecar", { url })
const { listener, health } = await spawnLocalServer(hostname, port, password)
server = listener
serverReady.resolve({
url,
username: "opencode",
password,
})
await Promise.race([
health.wait,
delay(30_000).then(() => {
throw new Error("Sidecar health check timed out")
}),
]).catch((error) => {
logger.error("sidecar health check failed", error)
})
logger.log("loading task finished")
})()
if (needsMigration) {
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
if (show) {
overlay = createLoadingWindow()
await delay(1_000)
}
}
await loadingTask
setInitStep({ phase: "done" })
if (overlay) {
await loadingComplete.promise
}
mainWindow = createMainWindow()
wireMenu()
overlay?.close()
}
function wireMenu() {
if (!mainWindow) return
createMenu({
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
checkForUpdates: () => {
void checkForUpdates(true)
},
reload: () => mainWindow?.reload(),
relaunch: () => {
killSidecar()
app.relaunch()
app.exit(0)
},
})
}
registerIpcHandlers({
killSidecar: () => killSidecar(),
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)
}
},
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
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(),
setBackgroundColor: (color) => setBackgroundColor(color),
})
function killSidecar() {
if (!server) return
server.stop()
server = 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 downloadedUpdateVersion: string | undefined
async function checkUpdate() {
if (!UPDATER_ENABLED) return { updateAvailable: false }
if (downloadedUpdateVersion) {
logger.log("returning cached downloaded update", {
version: downloadedUpdateVersion,
})
return { updateAvailable: true, version: downloadedUpdateVersion }
}
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 (result?.isUpdateAvailable === false || !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 })
downloadedUpdateVersion = version
return { updateAvailable: true, version }
} catch (error) {
logger.error("update check failed", error)
return { updateAvailable: false, failed: true }
}
}
async function installUpdate() {
if (!downloadedUpdateVersion) {
logger.log("install update skipped", {
reason: "no downloaded update ready",
})
return
}
logger.log("installing downloaded update", {
version: downloadedUpdateVersion,
})
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 }
}

View File

@@ -0,0 +1,204 @@
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,
TitlebarTheme,
WindowConfig,
WslConfig,
} from "../preload/types"
import { getStore } from "./store"
import { setTitlebar } from "./windows"
const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
}
type Deps = {
killSidecar: () => void
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
consumeInitialDeepLinks: () => Promise<string[]> | string[]
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
setBackgroundColor: (color: string) => void
}
export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
const send = (step: InitStep) => event.sender.send("init-step", step)
return deps.awaitInitialization(send)
})
ipcMain.handle("get-window-config", () => deps.getWindowConfig())
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-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("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
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] : []), "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; accept?: 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
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-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", () => {
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))
ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
setTitlebar(win, theme)
})
}
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)
}

View File

@@ -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
}
}
}

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,136 @@
import { Menu, shell } from "electron"
import { UPDATER_ENABLED } from "./constants"
import { createMainWindow } from "./windows"
type Deps = {
trigger: (id: string) => 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: "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") },
{
label: "New Window",
accelerator: "Cmd+Shift+N",
click: () => createMainWindow(),
},
{ 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" },
{ role: "reload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
{
label: "Go",
submenu: [
{ label: "Back", accelerator: "Cmd+[", click: () => deps.trigger("common.goBack") },
{ label: "Forward", accelerator: "Cmd+]", click: () => deps.trigger("common.goForward") },
{ type: "separator" },
{
label: "Previous Session",
accelerator: "Option+Up",
click: () => deps.trigger("session.previous"),
},
{
label: "Next Session",
accelerator: "Option+Down",
click: () => deps.trigger("session.next"),
},
{ type: "separator" },
{
label: "Previous Project",
accelerator: "Cmd+Option+Up",
click: () => deps.trigger("project.previous"),
},
{
label: "Next Project",
accelerator: "Cmd+Option+Down",
click: () => deps.trigger("project.next"),
},
],
},
{ role: "windowMenu" },
{
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))
}

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,101 @@
import { app } from "electron"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store"
export type WslConfig = { enabled: boolean }
export type HealthCheck = { wait: Promise<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 getWslConfig(): WslConfig {
const value = getStore().get(WSL_ENABLED_KEY)
return { enabled: typeof value === "boolean" ? value : false }
}
export function setWslConfig(config: WslConfig) {
getStore().set(WSL_ENABLED_KEY, config.enabled)
}
export async function spawnLocalServer(hostname: string, port: number, password: string) {
prepareServerEnv(password)
const { Log, Server } = await import("virtual:opencode-server")
await Log.init({ level: "WARN" })
const listener = await Server.listen({
port,
hostname,
username: "opencode",
password,
cors: ["oc://renderer"],
})
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
}
}
await ready()
})()
return { listener, health: { wait } }
}
function prepareServerEnv(password: string) {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
const env = {
...process.env,
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
XDG_STATE_HOME: app.getPath("userData"),
}
Object.assign(process.env, env)
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
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
}
}

View File

@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { isNushell, mergeShellEnv, parseShellEnv } 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("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,88 @@
import { spawnSync } from "node:child_process"
import { basename } from "node:path"
const TIMEOUT = 5_000
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
export function getUserShell() {
return process.env.SHELL || "/bin/sh"
}
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) {
if (isNushell(shell)) {
console.log(`[server] Skipping shell env probe for nushell: ${shell}`)
return null
}
const interactive = probe(shell, "-il")
if (interactive.type === "Loaded") {
console.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
return interactive.value
}
if (interactive.type === "Timeout") {
console.warn(`[server] Interactive shell env probe timed out: ${shell}`)
return null
}
const login = probe(shell, "-l")
if (login.type === "Loaded") {
console.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
return login.value
}
console.warn(`[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,17 @@
import Store from "electron-store"
import { SETTINGS_STORE } from "./constants"
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-electron\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, fileExtension: "", accessPropertiesByDotNotation: false })
cache.set(name, next)
return next
}

View File

@@ -0,0 +1,206 @@
import windowState from "electron-window-state"
import { app, BrowserWindow, 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"
const root = dirname(fileURLToPath(import.meta.url))
const rendererRoot = join(root, "../renderer")
const rendererProtocol = "oc"
const rendererHost = "renderer"
protocol.registerSchemesAsPrivileged([
{
scheme: rendererProtocol,
privileges: {
secure: true,
standard: true,
supportFetchAPI: true,
},
},
])
let backgroundColor: string | undefined
export function setBackgroundColor(color: string) {
backgroundColor = 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 overlay(theme: Partial<TitlebarTheme> = {}) {
const mode = theme.mode ?? tone()
return {
color: "#00000000",
symbolColor: mode === "dark" ? "white" : "black",
height: 40,
}
}
export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
if (process.platform !== "win32") return
win.setTitleBarOverlay(overlay(theme))
}
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,
title: "OpenCode",
icon: iconPath(),
backgroundColor,
...(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,
},
})
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
upsertKeyValue(responseHeaders, "Access-Control-Allow-Origin", ["*"])
upsertKeyValue(responseHeaders, "Access-Control-Allow-Headers", ["*"])
callback({ responseHeaders })
})
state.manage(win)
loadWindow(win, "index.html")
wireZoom(win)
win.once("ready-to-show", () => {
win.show()
})
return win
}
export function createLoadingWindow() {
const mode = tone()
const win = new BrowserWindow({
width: 640,
height: 480,
resizable: false,
center: true,
show: true,
icon: iconPath(),
backgroundColor,
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
...(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,
},
})
loadWindow(win, "loading.html")
return win
}
export function registerRendererProtocol() {
if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, (request) => {
const url = new URL(request.url)
if (url.host !== rendererHost) {
return new Response("Not found", { status: 404 })
}
const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = relative(rendererRoot, file)
if (rel.startsWith("..") || isAbsolute(rel)) {
return new Response("Not found", { status: 404 })
}
return net.fetch(pathToFileURL(file).toString())
})
}
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 wireZoom(win: BrowserWindow) {
win.webContents.setZoomFactor(1)
win.webContents.on("zoom-changed", () => {
win.webContents.setZoomFactor(1)
})
}
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
}