feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture
Forked from OpenCode v1.17.4 with multi-agent system: - 5 agents: aircoding, scheduler, worker, architect, reviewer - Deterministic DAG scheduling engine (coordinator_tick) - Tool whitelists as hard enforcement - AirCoding validation plugin - V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md - Design documents in docs/
This commit is contained in:
136
packages/desktop/src/main/apps.ts
Normal file
136
packages/desktop/src/main/apps.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import { access, readFile, readdir } from "node:fs/promises"
|
||||
import { dirname, extname, join } from "node:path"
|
||||
import util from "node:util"
|
||||
|
||||
const execFilePromise = util.promisify(execFile)
|
||||
|
||||
const exists = (path: string) =>
|
||||
access(path)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
export function checkAppExists(appName: string) {
|
||||
if (process.platform === "win32") return true
|
||||
if (process.platform === "linux") return true
|
||||
return checkMacosApp(appName)
|
||||
}
|
||||
|
||||
export function resolveAppPath(appName: string) {
|
||||
if (process.platform !== "win32") return appName
|
||||
return resolveWindowsAppPath(appName)
|
||||
}
|
||||
|
||||
async function checkMacosApp(appName: string) {
|
||||
const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`]
|
||||
|
||||
const home = process.env.HOME
|
||||
if (home) locations.push(`${home}/Applications/${appName}.app`)
|
||||
|
||||
for (const location of locations) {
|
||||
if (await exists(location)) return true
|
||||
}
|
||||
|
||||
return execFilePromise("which", [appName])
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
async function resolveWindowsAppPath(appName: string): Promise<string | null> {
|
||||
let output: string
|
||||
try {
|
||||
output = await execFilePromise("where", [appName]).then((r) => r.stdout.toString())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const paths = output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
const hasExt = (path: string, ext: string) => extname(path).toLowerCase() === `.${ext}`
|
||||
|
||||
const exe = paths.find((path) => hasExt(path, "exe"))
|
||||
if (exe) return exe
|
||||
|
||||
const resolveCmd = async (path: string) => {
|
||||
const content = await readFile(path, "utf8")
|
||||
for (const token of content.split('"').map((value: string) => value.trim())) {
|
||||
const lower = token.toLowerCase()
|
||||
if (!lower.includes(".exe")) continue
|
||||
|
||||
const index = lower.indexOf("%~dp0")
|
||||
if (index >= 0) {
|
||||
const base = dirname(path)
|
||||
const suffix = token.slice(index + 5)
|
||||
const resolved = suffix
|
||||
.replace(/\//g, "\\")
|
||||
.split("\\")
|
||||
.filter((part: string) => part && part !== ".")
|
||||
.reduce((current: string, part: string) => {
|
||||
if (part === "..") return dirname(current)
|
||||
return join(current, part)
|
||||
}, base)
|
||||
|
||||
if (await exists(resolved)) return resolved
|
||||
}
|
||||
|
||||
if (await exists(token)) return token
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
if (hasExt(path, "cmd") || hasExt(path, "bat")) {
|
||||
const resolved = await resolveCmd(path)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
if (!extname(path)) {
|
||||
const cmd = `${path}.cmd`
|
||||
if (await exists(cmd)) {
|
||||
const resolved = await resolveCmd(cmd)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
const bat = `${path}.bat`
|
||||
if (await exists(bat)) {
|
||||
const resolved = await resolveCmd(bat)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const key = appName
|
||||
.split("")
|
||||
.filter((value: string) => /[a-z0-9]/i.test(value))
|
||||
.map((value: string) => value.toLowerCase())
|
||||
.join("")
|
||||
|
||||
if (key) {
|
||||
for (const path of paths) {
|
||||
const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))]
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
for (const entry of await readdir(dir)) {
|
||||
const candidate = join(dir, entry)
|
||||
if (!hasExt(candidate, "exe")) continue
|
||||
const stem = entry.replace(/\.exe$/i, "")
|
||||
const name = stem
|
||||
.split("")
|
||||
.filter((value: string) => /[a-z0-9]/i.test(value))
|
||||
.map((value: string) => value.toLowerCase())
|
||||
.join("")
|
||||
if (name.includes(key) || key.includes(name)) return candidate
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths[0] ?? null
|
||||
}
|
||||
87
packages/desktop/src/main/attachment-picker.test.ts
Normal file
87
packages/desktop/src/main/attachment-picker.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
assertAttachmentBudget,
|
||||
createPickedFileAuthorizations,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
readAttachment,
|
||||
} from "./attachment-picker"
|
||||
|
||||
describe("assertAttachmentBudget", () => {
|
||||
test("accepts selections within the media ingest limit", () => {
|
||||
expect(() =>
|
||||
assertAttachmentBudget([{ size: MAX_ATTACHMENT_BYTES / 2 }, { size: MAX_ATTACHMENT_BYTES / 2 }]),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
test("rejects the selection before files are read when its total exceeds the limit", () => {
|
||||
expect(() => assertAttachmentBudget([{ size: MAX_ATTACHMENT_BYTES }, { size: 1 }])).toThrow("20 MB limit")
|
||||
})
|
||||
|
||||
test("reads an approved file through a bounded buffer", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-attachment-"))
|
||||
const file = join(directory, "example.txt")
|
||||
try {
|
||||
await writeFile(file, "lorem ipsum")
|
||||
expect(new TextDecoder().decode(await readAttachment(file))).toBe("lorem ipsum")
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects an oversized file before allocating its contents", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-attachment-"))
|
||||
const file = join(directory, "oversized.txt")
|
||||
try {
|
||||
await writeFile(file, "")
|
||||
await truncate(file, MAX_ATTACHMENT_BYTES + 1)
|
||||
await expect(readAttachment(file)).rejects.toThrow("20 MB limit")
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("picked file authorizations", () => {
|
||||
const read = async (path: string) => new TextEncoder().encode(path).buffer
|
||||
|
||||
test("keeps concurrent picker selections isolated", async () => {
|
||||
const authorizations = createPickedFileAuthorizations(read)
|
||||
const first = authorizations.add(1, ["a.txt", "b.txt"])
|
||||
const second = authorizations.add(1, ["c.txt"])
|
||||
|
||||
expect(new TextDecoder().decode(await authorizations.read(1, first, "a.txt"))).toBe("a.txt")
|
||||
expect(new TextDecoder().decode(await authorizations.read(1, second, "c.txt"))).toBe("c.txt")
|
||||
expect(new TextDecoder().decode(await authorizations.read(1, first, "b.txt"))).toBe("b.txt")
|
||||
})
|
||||
|
||||
test("releases unread files for one picker without affecting another", async () => {
|
||||
const authorizations = createPickedFileAuthorizations(read)
|
||||
const first = authorizations.add(1, ["a.txt"])
|
||||
const second = authorizations.add(1, ["b.txt"])
|
||||
authorizations.release(1, first)
|
||||
|
||||
await expect(authorizations.read(1, first, "a.txt")).rejects.toThrow("not selected")
|
||||
expect(new TextDecoder().decode(await authorizations.read(1, second, "b.txt"))).toBe("b.txt")
|
||||
})
|
||||
|
||||
test("keeps picker tokens scoped to their renderer", async () => {
|
||||
const authorizations = createPickedFileAuthorizations(read)
|
||||
const token = authorizations.add(1, ["a.txt"])
|
||||
|
||||
await expect(authorizations.read(2, token, "a.txt")).rejects.toThrow("not selected")
|
||||
})
|
||||
|
||||
test("charges actual reads against the selection budget", async () => {
|
||||
const authorizations = createPickedFileAuthorizations(async (_path, maxBytes) => {
|
||||
if (6 > maxBytes) throw new Error("budget exceeded")
|
||||
return new ArrayBuffer(6)
|
||||
}, 10)
|
||||
const token = authorizations.add(1, ["a.txt", "b.txt"])
|
||||
|
||||
await authorizations.read(1, token, "a.txt")
|
||||
await expect(authorizations.read(1, token, "b.txt")).rejects.toThrow("budget exceeded")
|
||||
})
|
||||
})
|
||||
56
packages/desktop/src/main/attachment-picker.ts
Normal file
56
packages/desktop/src/main/attachment-picker.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { open } from "node:fs/promises"
|
||||
|
||||
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
export function createPickedFileAuthorizations(
|
||||
read: (path: string, maxBytes: number) => Promise<ArrayBuffer> = readAttachment,
|
||||
budget = MAX_ATTACHMENT_BYTES,
|
||||
) {
|
||||
const selections = new Map<string, { sender: number; paths: Set<string>; remaining: number }>()
|
||||
|
||||
return {
|
||||
add(sender: number, paths: string[]) {
|
||||
const token = randomUUID()
|
||||
selections.set(token, { sender, paths: new Set(paths), remaining: budget })
|
||||
return token
|
||||
},
|
||||
async read(sender: number, token: string, path: string) {
|
||||
const selection = selections.get(token)
|
||||
if (selection?.sender !== sender || !selection.paths.delete(path))
|
||||
throw new Error("File was not selected by the picker")
|
||||
const bytes = await read(path, selection.remaining)
|
||||
selection.remaining -= bytes.byteLength
|
||||
if (selection.paths.size === 0) selections.delete(token)
|
||||
return bytes
|
||||
},
|
||||
release(sender: number, token: string) {
|
||||
if (selections.get(token)?.sender === sender) selections.delete(token)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAttachmentBudget(files: { size: number }[]) {
|
||||
const total = files.reduce((sum, file) => sum + file.size, 0)
|
||||
if (total <= MAX_ATTACHMENT_BYTES) return
|
||||
throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`)
|
||||
}
|
||||
|
||||
export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) {
|
||||
const file = await open(filePath, "r")
|
||||
try {
|
||||
const info = await file.stat()
|
||||
if (info.size > maxBytes)
|
||||
throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`)
|
||||
const bytes = Buffer.allocUnsafe(info.size)
|
||||
let offset = 0
|
||||
while (offset < info.size) {
|
||||
const result = await file.read(bytes, offset, info.size - offset, offset)
|
||||
if (result.bytesRead === 0) break
|
||||
offset += result.bytesRead
|
||||
}
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + offset) as ArrayBuffer
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
7
packages/desktop/src/main/constants.ts
Normal file
7
packages/desktop/src/main/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { app } from "electron"
|
||||
|
||||
type Channel = "dev" | "beta" | "prod"
|
||||
const raw = import.meta.env.OPENCODE_CHANNEL
|
||||
export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
|
||||
|
||||
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
|
||||
84
packages/desktop/src/main/desktop-menu-actions.ts
Normal file
84
packages/desktop/src/main/desktop-menu-actions.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import { createMainWindow, updateTitlebar } from "./windows"
|
||||
|
||||
export type DesktopMenuActionHandlers = Partial<{
|
||||
checkForUpdates: () => void
|
||||
relaunch: () => void
|
||||
}>
|
||||
|
||||
export function runDesktopMenuAction(
|
||||
win: BrowserWindow | null,
|
||||
action: DesktopMenuAction,
|
||||
handlers: DesktopMenuActionHandlers = {},
|
||||
) {
|
||||
switch (action) {
|
||||
case "app.checkForUpdates":
|
||||
handlers.checkForUpdates?.()
|
||||
return
|
||||
case "app.relaunch":
|
||||
handlers.relaunch?.()
|
||||
return
|
||||
case "window.new":
|
||||
createMainWindow()
|
||||
return
|
||||
case "window.close":
|
||||
win?.close()
|
||||
return
|
||||
case "window.minimize":
|
||||
win?.minimize()
|
||||
return
|
||||
case "window.toggleMaximize":
|
||||
if (win?.isMaximized()) {
|
||||
win.unmaximize()
|
||||
return
|
||||
}
|
||||
win?.maximize()
|
||||
return
|
||||
case "view.reload":
|
||||
win?.reload()
|
||||
return
|
||||
case "view.toggleDevTools":
|
||||
win?.webContents.toggleDevTools()
|
||||
return
|
||||
case "view.resetZoom":
|
||||
setZoom(win, 1)
|
||||
return
|
||||
case "view.zoomIn":
|
||||
setZoom(win, (win?.webContents.getZoomFactor() ?? 1) + 0.2)
|
||||
return
|
||||
case "view.zoomOut":
|
||||
setZoom(win, (win?.webContents.getZoomFactor() ?? 1) - 0.2)
|
||||
return
|
||||
case "view.toggleFullscreen":
|
||||
win?.setFullScreen(!win.isFullScreen())
|
||||
return
|
||||
case "edit.undo":
|
||||
win?.webContents.undo()
|
||||
return
|
||||
case "edit.redo":
|
||||
win?.webContents.redo()
|
||||
return
|
||||
case "edit.cut":
|
||||
win?.webContents.cut()
|
||||
return
|
||||
case "edit.copy":
|
||||
win?.webContents.copy()
|
||||
return
|
||||
case "edit.paste":
|
||||
win?.webContents.paste()
|
||||
return
|
||||
case "edit.delete":
|
||||
win?.webContents.delete()
|
||||
return
|
||||
case "edit.selectAll":
|
||||
win?.webContents.selectAll()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function setZoom(win: BrowserWindow | null, value: number) {
|
||||
if (!win) return
|
||||
win.webContents.setZoomFactor(Math.min(Math.max(value, 0.2), 10))
|
||||
updateTitlebar(win)
|
||||
}
|
||||
19
packages/desktop/src/main/env.d.ts
vendored
Normal file
19
packages/desktop/src/main/env.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly OPENCODE_CHANNEL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare module "virtual:opencode-server" {
|
||||
export namespace Server {
|
||||
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
|
||||
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
|
||||
}
|
||||
export namespace Config {
|
||||
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
|
||||
export type Info = import("../../../opencode/dist/types/src/node").Config.Info
|
||||
}
|
||||
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
|
||||
}
|
||||
37
packages/desktop/src/main/index.test.ts
Normal file
37
packages/desktop/src/main/index.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
|
||||
import { forwardInitializationFailure } from "./initialization"
|
||||
|
||||
describe("desktop initialization", () => {
|
||||
const failure = new Error("sidecar startup failed")
|
||||
const expectFailure = (exit: Exit.Exit<unknown, unknown>) => {
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isSuccess(exit)) return
|
||||
expect(Cause.squash(exit.cause)).toBe(failure)
|
||||
}
|
||||
|
||||
test("forwards loading task failures before renderer initialization", () => {
|
||||
const exit = Effect.runSync(
|
||||
Effect.gen(function* () {
|
||||
const initialization = yield* Deferred.make<never, unknown>()
|
||||
yield* forwardInitializationFailure(initialization)(Effect.die(failure)).pipe(Effect.exit)
|
||||
return yield* Deferred.await(initialization).pipe(Effect.exit)
|
||||
}),
|
||||
)
|
||||
|
||||
expectFailure(exit)
|
||||
})
|
||||
|
||||
test("forwards loading task failures while renderer initialization waits", () => {
|
||||
const exit = Effect.runSync(
|
||||
Effect.gen(function* () {
|
||||
const initialization = yield* Deferred.make<never, unknown>()
|
||||
const waiting = yield* Deferred.await(initialization).pipe(Effect.exit, Effect.forkChild)
|
||||
yield* forwardInitializationFailure(initialization)(Effect.die(failure)).pipe(Effect.exit)
|
||||
return yield* Fiber.join(waiting)
|
||||
}),
|
||||
)
|
||||
|
||||
expectFailure(exit)
|
||||
})
|
||||
})
|
||||
367
packages/desktop/src/main/index.ts
Normal file
367
packages/desktop/src/main/index.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdirSync, rmSync } from "node:fs"
|
||||
import * as http from "node:http"
|
||||
import { createServer } from "node:net"
|
||||
import { homedir, tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { getCACertificates, setDefaultCACertificates } from "node:tls"
|
||||
import type { Event } from "electron"
|
||||
import { app, BrowserWindow } from "electron"
|
||||
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import contextMenu from "electron-context-menu"
|
||||
|
||||
import type { ServerReadyData } from "../preload/types"
|
||||
import { checkAppExists, resolveAppPath } from "./apps"
|
||||
import { CHANNEL } from "./constants"
|
||||
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
|
||||
import { forwardInitializationFailure } from "./initialization"
|
||||
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
|
||||
import { parseMarkdown } from "./markdown"
|
||||
import { createMenu } from "./menu"
|
||||
import {
|
||||
getDefaultServerUrl,
|
||||
preferAppEnv,
|
||||
setDefaultServerUrl,
|
||||
spawnLocalServer,
|
||||
type SidecarListener,
|
||||
} from "./server"
|
||||
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
|
||||
import {
|
||||
createMainWindow,
|
||||
registerRendererProtocol,
|
||||
setRelaunchHandler,
|
||||
setBackgroundColor,
|
||||
setDockIcon,
|
||||
} from "./windows"
|
||||
import { createWslServersController } from "./wsl/servers"
|
||||
import { registerWslIpcHandlers } from "./wsl/ipc"
|
||||
import { spawnWslSidecar } from "./wsl/sidecar"
|
||||
import { migrate } from "./migrate"
|
||||
|
||||
const APP_NAMES: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
beta: "OpenCode Beta",
|
||||
prod: "OpenCode",
|
||||
}
|
||||
const APP_IDS: Record<string, string> = {
|
||||
dev: "ai.opencode.desktop.dev",
|
||||
beta: "ai.opencode.desktop.beta",
|
||||
prod: "ai.opencode.desktop",
|
||||
}
|
||||
const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
|
||||
|
||||
let logger: ReturnType<typeof initLogging>
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let server: SidecarListener | null = null
|
||||
|
||||
const pendingDeepLinks: string[] = []
|
||||
|
||||
function useEnvProxy() {
|
||||
try {
|
||||
// Electron 41.2 runs Node 24.14.1; latest @types/node@24 is 24.12.2.
|
||||
;(http as any).setGlobalProxyFromEnv()
|
||||
} catch (error) {
|
||||
logger.warn("failed to load proxy environment", error)
|
||||
}
|
||||
}
|
||||
|
||||
function emitDeepLinks(urls: string[]) {
|
||||
if (urls.length === 0) return
|
||||
pendingDeepLinks.push(...urls)
|
||||
if (mainWindow) sendDeepLinks(mainWindow, urls)
|
||||
}
|
||||
|
||||
async function killSidecar() {
|
||||
if (!server) return
|
||||
const current = server
|
||||
server = null
|
||||
await current.stop()
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
const loopback = ["127.0.0.1", "localhost", "::1"]
|
||||
const upsert = (key: string) => {
|
||||
const items = (process.env[key] ?? "")
|
||||
.split(",")
|
||||
.map((value: string) => value.trim())
|
||||
.filter((value: string) => Boolean(value))
|
||||
|
||||
for (const host of loopback) {
|
||||
if (items.some((value: string) => value.toLowerCase() === host)) continue
|
||||
items.push(host)
|
||||
}
|
||||
|
||||
process.env[key] = items.join(",")
|
||||
}
|
||||
|
||||
upsert("NO_PROXY")
|
||||
upsert("no_proxy")
|
||||
}
|
||||
|
||||
const main = Effect.gen(function* () {
|
||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||
|
||||
// on macOS apps run in `/` which can cause issues with ripgrep
|
||||
try {
|
||||
process.chdir(homedir())
|
||||
} catch {}
|
||||
|
||||
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||
|
||||
const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
|
||||
const onboardingTestRoot = ((): string | undefined => {
|
||||
if (!TEST_ONBOARDING) return
|
||||
|
||||
const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
|
||||
mkdirSync(join(root, dir), { recursive: true }),
|
||||
)
|
||||
process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.XDG_DATA_HOME = join(root, "data")
|
||||
process.env.XDG_CONFIG_HOME = join(root, "config")
|
||||
process.env.XDG_CACHE_HOME = join(root, "cache")
|
||||
process.env.XDG_STATE_HOME = join(root, "state")
|
||||
return root
|
||||
})()
|
||||
app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
|
||||
app.setAppUserModelId(appId)
|
||||
app.setPath(
|
||||
"userData",
|
||||
onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId),
|
||||
)
|
||||
if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
|
||||
logger = initLogging()
|
||||
initCrashReporter()
|
||||
|
||||
const wslServers = createWslServersController(
|
||||
app.getVersion(),
|
||||
async (distro) => {
|
||||
logger.log("spawning wsl sidecar", { distro })
|
||||
return spawnWslSidecar(distro, {
|
||||
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
|
||||
})
|
||||
},
|
||||
{
|
||||
logger: {
|
||||
log: (message, meta) => logger.log(message, meta),
|
||||
error: (message, meta) => logger.error(message, meta),
|
||||
},
|
||||
},
|
||||
)
|
||||
const stopSidecars = async () => {
|
||||
await killSidecar()
|
||||
wslServers.stopAll()
|
||||
}
|
||||
const relaunch = () => {
|
||||
void stopSidecars().finally(() => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
|
||||
} catch (error) {
|
||||
logger.warn("failed to load system certificates", error)
|
||||
}
|
||||
|
||||
logger.log("app starting", {
|
||||
version: app.getVersion(),
|
||||
packaged: app.isPackaged,
|
||||
onboardingTest: Boolean(onboardingTestRoot),
|
||||
})
|
||||
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
const features = app.commandLine.getSwitchValue("enable-features")
|
||||
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
|
||||
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
preferAppEnv(app.getPath("userData"))
|
||||
|
||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
||||
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
|
||||
if (urls.length) {
|
||||
logger.log("deep link received via second-instance", { urls })
|
||||
emitDeepLinks(urls)
|
||||
}
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
app.on("open-url", (event: Event, url: string) => {
|
||||
event.preventDefault()
|
||||
logger.log("deep link received via open-url", { url })
|
||||
emitDeepLinks([url])
|
||||
})
|
||||
|
||||
app.on("before-quit", () => {
|
||||
void stopSidecars()
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
void stopSidecars()
|
||||
})
|
||||
|
||||
app.on("child-process-gone", (_event, details) => {
|
||||
writeLog("utility", "child process gone", { details }, "error")
|
||||
})
|
||||
|
||||
app.on("render-process-gone", (_event, webContents, details) => {
|
||||
writeLog("window", "app render process gone", { url: webContents.getURL(), details }, "error")
|
||||
})
|
||||
|
||||
setRelaunchHandler(() => {
|
||||
relaunch()
|
||||
})
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
void stopSidecars().finally(() => app.exit(0))
|
||||
})
|
||||
}
|
||||
|
||||
const serverReady = Deferred.makeUnsafe<ServerReadyData, unknown>()
|
||||
|
||||
yield* Effect.promise(() => app.whenReady())
|
||||
|
||||
if (!TEST_ONBOARDING) migrate()
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
const updater = setupAutoUpdater(stopSidecars)
|
||||
registerIpcHandlers({
|
||||
killSidecar: () => killSidecar(),
|
||||
relaunch,
|
||||
awaitInitialization: Effect.fnUntraced(
|
||||
function* () {
|
||||
logger.log("awaiting server ready")
|
||||
const res = yield* Deferred.await(serverReady)
|
||||
logger.log("server ready", { url: res.url })
|
||||
return res
|
||||
},
|
||||
(e) => Effect.runPromise(e),
|
||||
),
|
||||
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
||||
getDefaultServerUrl: () => getDefaultServerUrl(),
|
||||
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
||||
getDisplayBackend: async () => null,
|
||||
setDisplayBackend: async () => undefined,
|
||||
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
||||
checkAppExists: (appName) => checkAppExists(appName),
|
||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||
updater,
|
||||
showUpdater: () => showUpdaterDialog(updater, true),
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
exportDebugLogs: () => exportDebugLogs(),
|
||||
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
|
||||
})
|
||||
registerWslIpcHandlers(wslServers)
|
||||
void updater.start()
|
||||
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
|
||||
updateTimer.unref()
|
||||
app.once("will-quit", () => clearInterval(updateTimer))
|
||||
yield* Effect.promise(() => startNetLog()).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
logger.warn("failed to start net log", error)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const port = yield* Effect.gen(function* () {
|
||||
const fromEnv = process.env.OPENCODE_PORT
|
||||
if (fromEnv) {
|
||||
const parsed = Number.parseInt(fromEnv, 10)
|
||||
if (!Number.isNaN(parsed)) return parsed
|
||||
}
|
||||
|
||||
const res = yield* Deferred.make<number, unknown>()
|
||||
const server = createServer()
|
||||
server.on("error", (e) => Deferred.failSync(res, () => e))
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
if (typeof address !== "object" || !address) {
|
||||
server.close()
|
||||
Deferred.failSync(res, () => new Error("Failed to get port"))
|
||||
return
|
||||
}
|
||||
const port = address.port
|
||||
server.close(() => Effect.runSync(Deferred.succeed(res, port)))
|
||||
})
|
||||
|
||||
return yield* Deferred.await(res)
|
||||
})
|
||||
const hostname = "127.0.0.1"
|
||||
const url = `http://${hostname}:${port}`
|
||||
const password = randomUUID()
|
||||
|
||||
const loadingTask = yield* Effect.gen(function* () {
|
||||
logger.log("sidecar connection started", { url })
|
||||
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
|
||||
logger.log("spawning sidecar", { url })
|
||||
const { listener, health } = yield* Effect.promise(() =>
|
||||
spawnLocalServer(hostname, port, password, {
|
||||
userDataPath: app.getPath("userData"),
|
||||
onStdout: (message) => writeLog("server", "stdout", { message }),
|
||||
onStderr: (message) => writeLog("server", "stderr", { message }, "warn"),
|
||||
onExit: (code) => writeLog("utility", "sidecar exited", { code }, "warn"),
|
||||
}),
|
||||
)
|
||||
server = listener
|
||||
yield* Deferred.succeed(serverReady, {
|
||||
url,
|
||||
username: "opencode",
|
||||
password,
|
||||
})
|
||||
|
||||
if (process.platform === "win32") {
|
||||
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => health.wait).pipe(
|
||||
Effect.timeout("30 seconds"),
|
||||
Effect.catch((e) =>
|
||||
Effect.sync(() => {
|
||||
logger.error("sidecar health check failed", e.toString())
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
logger.log("loading task finished")
|
||||
}).pipe(forwardInitializationFailure(serverReady), Effect.forkChild)
|
||||
|
||||
yield* Fiber.await(loadingTask)
|
||||
|
||||
mainWindow = createMainWindow()
|
||||
if (mainWindow) {
|
||||
createMenu({
|
||||
trigger: (id) => {
|
||||
const win = BrowserWindow.getFocusedWindow() ?? mainWindow
|
||||
if (win) sendMenuCommand(win, id)
|
||||
},
|
||||
checkForUpdates: () => {
|
||||
void showUpdaterDialog(updater, true)
|
||||
},
|
||||
relaunch: () => {
|
||||
relaunch()
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
Effect.runFork(main)
|
||||
6
packages/desktop/src/main/initialization.ts
Normal file
6
packages/desktop/src/main/initialization.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Deferred, Effect } from "effect"
|
||||
|
||||
export function forwardInitializationFailure<A>(initialization: Deferred.Deferred<A, unknown>) {
|
||||
return <B, E, R>(effect: Effect.Effect<B, E, R>) =>
|
||||
effect.pipe(Effect.tapCause((cause) => Deferred.failCause(initialization, cause)))
|
||||
}
|
||||
242
packages/desktop/src/main/ipc.ts
Normal file
242
packages/desktop/src/main/ipc.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import { stat } from "node:fs/promises"
|
||||
import { basename } from "node:path"
|
||||
import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
|
||||
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
|
||||
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
|
||||
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
|
||||
import { getStore } from "./store"
|
||||
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
|
||||
import type { UpdaterController } from "./updater-controller"
|
||||
import { createUpdaterSubscriptions } from "./updater-subscriptions"
|
||||
|
||||
const pickerFilters = (ext?: string[]) => {
|
||||
if (!ext || ext.length === 0) return undefined
|
||||
return [{ name: "Files", extensions: ext }]
|
||||
}
|
||||
|
||||
const pickedFiles = createPickedFileAuthorizations()
|
||||
|
||||
type Deps = {
|
||||
killSidecar: () => Promise<void> | void
|
||||
relaunch: () => void
|
||||
awaitInitialization: () => Promise<ServerReadyData>
|
||||
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
||||
getDefaultServerUrl: () => Promise<string | null> | string | null
|
||||
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
||||
getDisplayBackend: () => Promise<string | null>
|
||||
setDisplayBackend: (backend: string | null) => Promise<void> | void
|
||||
parseMarkdown: (markdown: string) => Promise<string> | string
|
||||
checkAppExists: (appName: string) => Promise<boolean> | boolean
|
||||
resolveAppPath: (appName: string) => Promise<string | null>
|
||||
updater: UpdaterController
|
||||
showUpdater: () => Promise<void> | void
|
||||
setBackgroundColor: (color: string) => void
|
||||
exportDebugLogs: () => Promise<string>
|
||||
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: Deps) {
|
||||
const updaterSubscriptions = createUpdaterSubscriptions()
|
||||
app.once("will-quit", updaterSubscriptions.clear)
|
||||
|
||||
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
||||
ipcMain.handle("await-initialization", () => deps.awaitInitialization())
|
||||
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
|
||||
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
|
||||
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
|
||||
deps.setDefaultServerUrl(url),
|
||||
)
|
||||
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
|
||||
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
|
||||
deps.setDisplayBackend(backend),
|
||||
)
|
||||
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
|
||||
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
|
||||
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
|
||||
ipcMain.handle("updater-subscribe", (event) => {
|
||||
const id = event.sender.id
|
||||
updaterSubscriptions.set(
|
||||
id,
|
||||
deps.updater.subscribe((state) => {
|
||||
if (event.sender.isDestroyed()) return updaterSubscriptions.delete(id)
|
||||
event.sender.send("updater-state", state)
|
||||
}),
|
||||
)
|
||||
event.sender.once("destroyed", () => updaterSubscriptions.delete(id))
|
||||
})
|
||||
ipcMain.handle("updater-unsubscribe", (event) => updaterSubscriptions.delete(event.sender.id))
|
||||
ipcMain.handle("updater-check", () => deps.updater.check())
|
||||
ipcMain.handle("updater-install", () => deps.updater.install())
|
||||
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
|
||||
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
|
||||
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
|
||||
deps.recordFatalRendererError(error),
|
||||
)
|
||||
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
|
||||
try {
|
||||
const store = getStore(name)
|
||||
const value = store.get(key)
|
||||
if (value === undefined || value === null) return null
|
||||
return typeof value === "string" ? value : JSON.stringify(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => {
|
||||
getStore(name).set(key, value)
|
||||
})
|
||||
ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
|
||||
getStore(name).delete(key)
|
||||
})
|
||||
ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
|
||||
getStore(name).clear()
|
||||
})
|
||||
ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => {
|
||||
const store = getStore(name)
|
||||
return Object.keys(store.store)
|
||||
})
|
||||
ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => {
|
||||
const store = getStore(name)
|
||||
return Object.keys(store.store).length
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"open-directory-picker",
|
||||
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
|
||||
title: opts?.title ?? "Choose a folder",
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return opts?.multiple ? result.filePaths : result.filePaths[0]
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
"open-file-picker",
|
||||
async (
|
||||
event: IpcMainInvokeEvent,
|
||||
opts?: { multiple?: boolean; title?: string; defaultPath?: string; extensions?: string[] },
|
||||
) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
||||
title: opts?.title ?? "Choose a file",
|
||||
defaultPath: opts?.defaultPath,
|
||||
filters: pickerFilters(opts?.extensions),
|
||||
})
|
||||
if (result.canceled) return null
|
||||
const files = await Promise.all(
|
||||
result.filePaths.map(async (filePath) => ({
|
||||
path: filePath,
|
||||
name: basename(filePath),
|
||||
size: (await stat(filePath)).size,
|
||||
})),
|
||||
)
|
||||
assertAttachmentBudget(files)
|
||||
const token = pickedFiles.add(event.sender.id, result.filePaths)
|
||||
return { token, files }
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle("read-picked-file", async (event: IpcMainInvokeEvent, token: string, filePath: string) => {
|
||||
return pickedFiles.read(event.sender.id, token, filePath)
|
||||
})
|
||||
|
||||
ipcMain.handle("release-picked-files", (event: IpcMainInvokeEvent, token: string) => {
|
||||
pickedFiles.release(event.sender.id, token)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"save-file-picker",
|
||||
async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: opts?.title ?? "Save file",
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return result.filePath ?? null
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
|
||||
void shell.openExternal(url)
|
||||
})
|
||||
|
||||
ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
|
||||
if (!app) return shell.openPath(path)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const [cmd, args] =
|
||||
process.platform === "darwin" ? (["open", ["-a", app, path]] as const) : ([app, [path]] as const)
|
||||
execFile(cmd, args, (err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle("read-clipboard-image", () => {
|
||||
const image = clipboard.readImage()
|
||||
if (image.isEmpty()) return null
|
||||
const buffer = image.toPNG().buffer
|
||||
const size = image.getSize()
|
||||
return { buffer, width: size.width, height: size.height }
|
||||
})
|
||||
|
||||
ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => {
|
||||
new Notification({ title, body }).show()
|
||||
})
|
||||
|
||||
ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
|
||||
|
||||
ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
return win?.isFocused() ?? false
|
||||
})
|
||||
|
||||
ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
win?.focus()
|
||||
})
|
||||
|
||||
ipcMain.handle("show-window", (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
win?.show()
|
||||
})
|
||||
|
||||
ipcMain.on("relaunch", () => {
|
||||
deps.relaunch()
|
||||
})
|
||||
|
||||
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
|
||||
ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => {
|
||||
event.sender.setZoomFactor(factor)
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
updateTitlebar(win)
|
||||
})
|
||||
ipcMain.handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled())
|
||||
ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => {
|
||||
setPinchZoomEnabled(enabled)
|
||||
})
|
||||
ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
setTitlebar(win, theme)
|
||||
})
|
||||
ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => {
|
||||
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, {
|
||||
checkForUpdates: () => void deps.showUpdater(),
|
||||
relaunch: deps.relaunch,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function sendMenuCommand(win: BrowserWindow, id: string) {
|
||||
win.webContents.send("menu-command", id)
|
||||
}
|
||||
|
||||
export function sendDeepLinks(win: BrowserWindow, urls: string[]) {
|
||||
win.webContents.send("deep-link", urls)
|
||||
}
|
||||
205
packages/desktop/src/main/logging.ts
Normal file
205
packages/desktop/src/main/logging.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { MainLogger } from "electron-log"
|
||||
import log from "electron-log/main.js"
|
||||
import { app, crashReporter, netLog, shell } from "electron"
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"
|
||||
import { ZipWriter, BlobWriter, BlobReader } from "@zip.js/zip.js"
|
||||
import { dirname, join } from "node:path"
|
||||
import { homedir } from "node:os"
|
||||
|
||||
const MAX_LOG_AGE_DAYS = 7
|
||||
const TAIL_LINES = 1000
|
||||
const EXPORT_WINDOW = 24 * 60 * 60 * 1000
|
||||
const MAX_EXPORT_FILE_SIZE = 50 * 1024 * 1024
|
||||
const NET_LOG_SIZE = 20 * 1024 * 1024
|
||||
|
||||
let root = ""
|
||||
let run = ""
|
||||
let netLogPath: string | undefined
|
||||
|
||||
let logger: MainLogger
|
||||
export const getLogger = () => logger
|
||||
|
||||
export function initLogging() {
|
||||
initRunDirectory()
|
||||
log.transports.file.maxSize = 5 * 1024 * 1024
|
||||
log.transports.file.resolvePathFn = (_vars, message) =>
|
||||
join(
|
||||
run,
|
||||
`${safeLogName(message?.scope ?? (message?.variables?.processType === "renderer" ? "renderer" : "main"))}.log`,
|
||||
)
|
||||
log.initialize({ preload: false, spyRendererConsole: true })
|
||||
initConsoleTransport()
|
||||
cleanup()
|
||||
return (logger = log)
|
||||
}
|
||||
|
||||
export function initCrashReporter() {
|
||||
const dir = join(app.getPath("userData"), "Crashpad")
|
||||
mkdirSync(dir, { recursive: true })
|
||||
app.setPath("crashDumps", dir)
|
||||
crashReporter.start({ uploadToServer: false, compress: true })
|
||||
write("crash", "crash reporter started", { path: dir })
|
||||
}
|
||||
|
||||
export async function startNetLog() {
|
||||
if (netLog.currentlyLogging) return
|
||||
netLogPath = join(run, "network.netlog")
|
||||
await netLog.startLogging(netLogPath, { captureMode: "default", maxFileSize: NET_LOG_SIZE })
|
||||
write("network", "net log started", { path: netLogPath })
|
||||
}
|
||||
|
||||
export async function exportDebugLogs() {
|
||||
const restartNetLog = netLog.currentlyLogging
|
||||
if (restartNetLog) {
|
||||
await netLog.stopLogging().catch((error) => write("network", "failed to stop net log", { error }))
|
||||
}
|
||||
|
||||
const output = join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`)
|
||||
try {
|
||||
write("main", "exporting debug logs", { output })
|
||||
await writeZip(output, [
|
||||
{ name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(), null, 2)) },
|
||||
...collect(root, "desktop"),
|
||||
...serverLogRoots().flatMap((dir, i) => collect(dir, `server-${i + 1}`)),
|
||||
...collect(app.getPath("crashDumps"), "crashpad"),
|
||||
])
|
||||
shell.showItemInFolder(output)
|
||||
return output
|
||||
} finally {
|
||||
if (restartNetLog) {
|
||||
await startNetLog().catch((error) => write("network", "failed to restart net log", { error }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function write(
|
||||
name: string,
|
||||
message: string,
|
||||
extra?: Record<string, unknown>,
|
||||
level: "info" | "warn" | "error" = "info",
|
||||
) {
|
||||
if (!run) return
|
||||
const scoped = log.scope(safeLogName(name))
|
||||
if (extra !== undefined) {
|
||||
scoped[level](message, extra)
|
||||
return
|
||||
}
|
||||
scoped[level](message)
|
||||
}
|
||||
|
||||
export function tail(): string {
|
||||
try {
|
||||
const path = log.transports.file.getFile().path
|
||||
const contents = readFileSync(path, "utf8")
|
||||
const lines = contents.split("\n")
|
||||
return lines.slice(Math.max(0, lines.length - TAIL_LINES)).join("\n")
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function initRunDirectory() {
|
||||
root = join(app.getPath("userData"), "logs")
|
||||
run = join(root, stamp())
|
||||
mkdirSync(run, { recursive: true })
|
||||
}
|
||||
|
||||
function stamp() {
|
||||
return new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, "")
|
||||
.replace(/\.\d+Z$/, "")
|
||||
}
|
||||
|
||||
function safeLogName(name: string) {
|
||||
return name.replace(/[^a-z0-9_.-]/gi, "_") || "main"
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
const dir = root || dirname(log.transports.file.getFile().path)
|
||||
const cutoff = Date.now() - MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000
|
||||
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const file = join(dir, entry)
|
||||
try {
|
||||
const info = statSync(file)
|
||||
if (info.mtimeMs < cutoff) rmSync(file, { recursive: true, force: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function manifest() {
|
||||
return {
|
||||
generated: new Date().toISOString(),
|
||||
version: app.getVersion(),
|
||||
name: app.getName(),
|
||||
packaged: app.isPackaged,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
versions: process.versions,
|
||||
uptime: process.uptime(),
|
||||
userData: app.getPath("userData"),
|
||||
logs: root,
|
||||
currentRun: run,
|
||||
crashDumps: app.getPath("crashDumps"),
|
||||
serverLogs: serverLogRoots(),
|
||||
netLog: netLogPath,
|
||||
}
|
||||
}
|
||||
|
||||
function serverLogRoots() {
|
||||
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
|
||||
return [...new Set([join(xdgData, "opencode", "log"), join(app.getPath("userData"), "opencode", "log")])]
|
||||
}
|
||||
|
||||
type Entry = { name: string; path?: string; data?: Buffer }
|
||||
|
||||
function collect(dir: string, prefix: string): Entry[] {
|
||||
if (!existsSync(dir)) return []
|
||||
const cutoff = Date.now() - EXPORT_WINDOW
|
||||
const result: Entry[] = []
|
||||
const walk = (current: string) => {
|
||||
for (const entry of readdirSync(current)) {
|
||||
const file = join(current, entry)
|
||||
const info = statSync(file)
|
||||
if (info.isDirectory()) {
|
||||
walk(file)
|
||||
continue
|
||||
}
|
||||
if (info.mtimeMs < cutoff) continue
|
||||
if (info.size > MAX_EXPORT_FILE_SIZE) continue
|
||||
if (file.endsWith(".heapsnapshot")) continue
|
||||
result.push({ name: join(prefix, file.slice(dir.length + 1)).replace(/\\/g, "/"), path: file })
|
||||
}
|
||||
}
|
||||
walk(dir)
|
||||
return result
|
||||
}
|
||||
|
||||
async function writeZip(output: string, entries: Entry[]) {
|
||||
const writer = new ZipWriter(new BlobWriter("application/zip"))
|
||||
for (const entry of entries) {
|
||||
const data = entry.data ?? readFileSync(entry.path!)
|
||||
await writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(data)])))
|
||||
}
|
||||
const zip = await writer.close()
|
||||
writeFileSync(output, Buffer.from(await zip.arrayBuffer()))
|
||||
}
|
||||
|
||||
function initConsoleTransport() {
|
||||
const write = log.transports.console.writeFn.bind(log.transports.console)
|
||||
log.transports.console.writeFn = (options) => {
|
||||
try {
|
||||
write(options)
|
||||
} catch (err) {
|
||||
if (!isBrokenPipe(err)) throw err
|
||||
log.transports.console.level = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isBrokenPipe(err: unknown) {
|
||||
return typeof err === "object" && err !== null && "code" in err && err.code === "EPIPE"
|
||||
}
|
||||
16
packages/desktop/src/main/markdown.ts
Normal file
16
packages/desktop/src/main/markdown.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
67
packages/desktop/src/main/menu.ts
Normal file
67
packages/desktop/src/main/menu.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { BrowserWindow, Menu, shell } from "electron"
|
||||
import type { MenuItemConstructorOptions } from "electron"
|
||||
import {
|
||||
DESKTOP_MENU,
|
||||
desktopMenuVisible,
|
||||
type DesktopMenuEntry,
|
||||
type DesktopMenuRole,
|
||||
} from "@opencode-ai/app/desktop-menu"
|
||||
|
||||
import { UPDATER_ENABLED } from "./constants"
|
||||
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||
|
||||
type Deps = {
|
||||
trigger: (id: string) => void
|
||||
checkForUpdates: () => void
|
||||
relaunch: () => void
|
||||
}
|
||||
|
||||
export function createMenu(deps: Deps) {
|
||||
if (process.platform !== "darwin") return
|
||||
|
||||
const template = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "macos")).map((menu) => {
|
||||
if (menu.role) return { role: nativeRole(menu.role) }
|
||||
return {
|
||||
label: menu.label,
|
||||
submenu: menu.items
|
||||
?.filter((entry) => desktopMenuVisible(entry, "macos"))
|
||||
.map((entry) => nativeItem(entry, deps)),
|
||||
}
|
||||
})
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||
}
|
||||
|
||||
function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
|
||||
if (entry.type === "separator") return { type: "separator" }
|
||||
if (entry.role) return { role: nativeRole(entry.role) }
|
||||
|
||||
const item: MenuItemConstructorOptions = {
|
||||
label: entry.label,
|
||||
accelerator: entry.accelerator?.macos,
|
||||
enabled: entry.enabled === "updater" ? UPDATER_ENABLED : undefined,
|
||||
}
|
||||
|
||||
if (entry.command) {
|
||||
const command = entry.command
|
||||
item.click = () => deps.trigger(command)
|
||||
}
|
||||
if (entry.action) {
|
||||
const action = entry.action
|
||||
item.click = () =>
|
||||
runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, {
|
||||
checkForUpdates: deps.checkForUpdates,
|
||||
relaunch: deps.relaunch,
|
||||
})
|
||||
}
|
||||
if (entry.href) {
|
||||
const href = entry.href
|
||||
item.click = () => shell.openExternal(href)
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
function nativeRole(role: DesktopMenuRole) {
|
||||
return role as NonNullable<MenuItemConstructorOptions["role"]>
|
||||
}
|
||||
91
packages/desktop/src/main/migrate.ts
Normal file
91
packages/desktop/src/main/migrate.ts
Normal 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)
|
||||
}
|
||||
237
packages/desktop/src/main/server.ts
Normal file
237
packages/desktop/src/main/server.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { app, utilityProcess } from "electron"
|
||||
import type { Details } from "electron"
|
||||
import { getLogger } from "./logging"
|
||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||
import { getStore } from "./store"
|
||||
import { DEFAULT_SERVER_URL_KEY } from "./store-keys"
|
||||
|
||||
export type HealthCheck = { wait: Promise<void> }
|
||||
|
||||
type SidecarMessage =
|
||||
| { type: "ready" }
|
||||
| { type: "stopped" }
|
||||
| { type: "error"; error: { message: string; stack?: string } }
|
||||
|
||||
export type SidecarListener = { stop: () => Promise<void> }
|
||||
|
||||
const SIDECAR_SERVICE_NAME = "opencode server"
|
||||
const SIDECAR_START_STALL_TIMEOUT = 60_000
|
||||
const SIDECAR_STOP_TIMEOUT = 6_000
|
||||
|
||||
type SpawnLocalServerOptions = {
|
||||
userDataPath: string
|
||||
onStdout?: (message: string) => void
|
||||
onStderr?: (message: string) => void
|
||||
onExit?: (code: number) => void
|
||||
}
|
||||
|
||||
export function getDefaultServerUrl(): string | null {
|
||||
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
|
||||
return typeof value === "string" ? value : null
|
||||
}
|
||||
|
||||
export function setDefaultServerUrl(url: string | null) {
|
||||
if (url) {
|
||||
getStore().set(DEFAULT_SERVER_URL_KEY, url)
|
||||
return
|
||||
}
|
||||
|
||||
getStore().delete(DEFAULT_SERVER_URL_KEY)
|
||||
}
|
||||
|
||||
export function preferAppEnv(userDataPath: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
Object.assign(process.env, {
|
||||
...(shell ? loadShellEnv(shell, getLogger()) : null),
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
|
||||
})
|
||||
}
|
||||
|
||||
export async function spawnLocalServer(
|
||||
hostname: string,
|
||||
port: number,
|
||||
password: string,
|
||||
options: SpawnLocalServerOptions,
|
||||
) {
|
||||
const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js")
|
||||
const child = utilityProcess.fork(sidecar, [], {
|
||||
cwd: process.cwd(),
|
||||
env: createSidecarEnv(),
|
||||
serviceName: SIDECAR_SERVICE_NAME,
|
||||
stdio: "pipe",
|
||||
})
|
||||
let exited = false
|
||||
const exit = defer<number>()
|
||||
|
||||
const onProcessGone = (_event: unknown, details: Details) => {
|
||||
if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return
|
||||
options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`)
|
||||
}
|
||||
|
||||
app.on("child-process-gone", onProcessGone)
|
||||
child.once("exit", (code) => {
|
||||
exited = true
|
||||
app.off("child-process-gone", onProcessGone)
|
||||
options.onExit?.(code)
|
||||
exit.resolve(code)
|
||||
})
|
||||
child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`))
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd()))
|
||||
child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd()))
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let done = false
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
const fail = (error: Error) => {
|
||||
if (done) return
|
||||
done = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
const refreshTimeout = () => {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`))
|
||||
}, SIDECAR_START_STALL_TIMEOUT)
|
||||
}
|
||||
|
||||
const onMessage = (message: SidecarMessage) => {
|
||||
if (message.type === "ready") {
|
||||
if (done) return
|
||||
done = true
|
||||
cleanup()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
if (message.type === "error") {
|
||||
fail(Object.assign(new Error(message.error.message), { stack: message.error.stack }))
|
||||
}
|
||||
}
|
||||
const onExit = (code: number) => {
|
||||
fail(new Error(`Sidecar exited before ready with code ${code}`))
|
||||
}
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
child.off("message", onMessage)
|
||||
child.off("exit", onExit)
|
||||
}
|
||||
|
||||
child.on("message", onMessage)
|
||||
child.on("exit", onExit)
|
||||
refreshTimeout()
|
||||
child.postMessage({
|
||||
type: "start",
|
||||
hostname,
|
||||
port,
|
||||
password,
|
||||
userDataPath: options.userDataPath,
|
||||
})
|
||||
}).catch((error) => {
|
||||
if (!exited) child.kill()
|
||||
throw error
|
||||
})
|
||||
|
||||
const wait = (async () => {
|
||||
const url = `http://${hostname}:${port}`
|
||||
let healthy = false
|
||||
const gone = exit.promise.then((code) => {
|
||||
if (healthy) return
|
||||
throw new Error(`Sidecar exited before health check passed with code ${code}`)
|
||||
})
|
||||
|
||||
const ready = async () => {
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
if (await checkHealth(url, password)) {
|
||||
healthy = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.race([ready(), gone])
|
||||
})()
|
||||
|
||||
let stopping: Promise<void> | undefined
|
||||
|
||||
return {
|
||||
listener: {
|
||||
stop: () => {
|
||||
if (stopping) return stopping
|
||||
if (exited) return Promise.resolve()
|
||||
child.postMessage({ type: "stop" })
|
||||
stopping = Promise.race([
|
||||
exit.promise.then(() => undefined),
|
||||
delay(SIDECAR_STOP_TIMEOUT).then(() => {
|
||||
if (!exited) child.kill()
|
||||
}),
|
||||
])
|
||||
return stopping
|
||||
},
|
||||
},
|
||||
health: { wait },
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||
let healthUrl: URL
|
||||
try {
|
||||
healthUrl = new URL("/global/health", url)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
const headers = new Headers()
|
||||
if (password) {
|
||||
const auth = Buffer.from(`opencode:${password}`).toString("base64")
|
||||
headers.set("authorization", `Basic ${auth}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(healthUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function createSidecarEnv(): Record<string, string> {
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),
|
||||
)
|
||||
delete env.DEBUG
|
||||
if (process.platform === "linux") delete env.LD_PRELOAD
|
||||
if (!app.isPackaged) env.OPENCODE_DISABLE_CHANNEL_DB = "1"
|
||||
return env
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function serializeError(error: unknown) {
|
||||
if (error instanceof Error) return { message: error.message, stack: error.stack }
|
||||
return { message: String(error) }
|
||||
}
|
||||
|
||||
function defer<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
50
packages/desktop/src/main/shell-env.test.ts
Normal file
50
packages/desktop/src/main/shell-env.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { isNushell, mergeShellEnv, parseShellEnv, resolveUserShell } from "./shell-env"
|
||||
|
||||
describe("shell env", () => {
|
||||
test("parseShellEnv supports null-delimited pairs", () => {
|
||||
const env = parseShellEnv(Buffer.from("PATH=/usr/bin:/bin\0FOO=bar=baz\0\0"))
|
||||
|
||||
expect(env.PATH).toBe("/usr/bin:/bin")
|
||||
expect(env.FOO).toBe("bar=baz")
|
||||
})
|
||||
|
||||
test("parseShellEnv ignores invalid entries", () => {
|
||||
const env = parseShellEnv(Buffer.from("INVALID\0=empty\0OK=1\0"))
|
||||
|
||||
expect(Object.keys(env).length).toBe(1)
|
||||
expect(env.OK).toBe("1")
|
||||
})
|
||||
|
||||
test("mergeShellEnv keeps explicit overrides", () => {
|
||||
const env = mergeShellEnv(
|
||||
{
|
||||
PATH: "/shell/path",
|
||||
HOME: "/tmp/home",
|
||||
},
|
||||
{
|
||||
PATH: "/desktop/path",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
},
|
||||
)
|
||||
|
||||
expect(env.PATH).toBe("/desktop/path")
|
||||
expect(env.HOME).toBe("/tmp/home")
|
||||
expect(env.OPENCODE_CLIENT).toBe("desktop")
|
||||
})
|
||||
|
||||
test("resolveUserShell falls back to the login shell before /bin/sh", () => {
|
||||
expect(resolveUserShell("/custom/env-shell", "/bin/zsh")).toBe("/custom/env-shell")
|
||||
expect(resolveUserShell(undefined, "/bin/zsh")).toBe("/bin/zsh")
|
||||
expect(resolveUserShell(undefined, "unknown")).toBe("/bin/sh")
|
||||
expect(resolveUserShell(undefined, undefined)).toBe("/bin/sh")
|
||||
})
|
||||
|
||||
test("isNushell handles path and binary name", () => {
|
||||
expect(isNushell("nu")).toBe(true)
|
||||
expect(isNushell("/opt/homebrew/bin/nu")).toBe(true)
|
||||
expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true)
|
||||
expect(isNushell("/bin/zsh")).toBe(false)
|
||||
})
|
||||
})
|
||||
101
packages/desktop/src/main/shell-env.ts
Normal file
101
packages/desktop/src/main/shell-env.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { userInfo } from "node:os"
|
||||
import { basename } from "node:path"
|
||||
|
||||
const TIMEOUT = 5_000
|
||||
|
||||
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
|
||||
type ShellEnvLogger = {
|
||||
log: (message: string) => void
|
||||
}
|
||||
|
||||
export function resolveUserShell(envShell: string | undefined, loginShell: string | null | undefined) {
|
||||
const resolvedLoginShell = loginShell && loginShell !== "unknown" ? loginShell : undefined
|
||||
return envShell || resolvedLoginShell || "/bin/sh"
|
||||
}
|
||||
|
||||
export function getUserShell() {
|
||||
try {
|
||||
return resolveUserShell(process.env.SHELL, userInfo().shell)
|
||||
} catch {
|
||||
return resolveUserShell(process.env.SHELL, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export function parseShellEnv(out: Buffer) {
|
||||
const env: Record<string, string> = {}
|
||||
for (const line of out.toString("utf8").split("\0")) {
|
||||
if (!line) continue
|
||||
const ix = line.indexOf("=")
|
||||
if (ix <= 0) continue
|
||||
env[line.slice(0, ix)] = line.slice(ix + 1)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
function probe(shell: string, mode: "-il" | "-l"): Probe {
|
||||
const out = spawnSync(shell, [mode, "-c", "env -0"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: TIMEOUT,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
const err = out.error as NodeJS.ErrnoException | undefined
|
||||
if (err) {
|
||||
if (err.code === "ETIMEDOUT") return { type: "Timeout" }
|
||||
console.log(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
if (out.status !== 0) {
|
||||
console.log(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
const env = parseShellEnv(out.stdout)
|
||||
if (Object.keys(env).length === 0) {
|
||||
console.log(`[server] Shell env probe returned empty env for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
return { type: "Loaded", value: env }
|
||||
}
|
||||
|
||||
export function isNushell(shell: string) {
|
||||
const name = basename(shell).toLowerCase()
|
||||
const raw = shell.toLowerCase()
|
||||
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
|
||||
}
|
||||
|
||||
export function loadShellEnv(shell: string, logger: ShellEnvLogger) {
|
||||
if (isNushell(shell)) {
|
||||
logger.log(`[server] Skipping shell env probe for nushell: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const interactive = probe(shell, "-il")
|
||||
if (interactive.type === "Loaded") {
|
||||
logger.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
||||
return interactive.value
|
||||
}
|
||||
if (interactive.type === "Timeout") {
|
||||
logger.log(`[server] Interactive shell env probe timed out: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const login = probe(shell, "-l")
|
||||
if (login.type === "Loaded") {
|
||||
logger.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
||||
return login.value
|
||||
}
|
||||
|
||||
logger.log(`[server] Falling back to app environment: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
|
||||
return {
|
||||
...shell,
|
||||
...env,
|
||||
}
|
||||
}
|
||||
157
packages/desktop/src/main/sidecar.ts
Normal file
157
packages/desktop/src/main/sidecar.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as http from "node:http"
|
||||
import * as tls from "node:tls"
|
||||
|
||||
type NodeHttpWithEnvProxy = typeof http & {
|
||||
setGlobalProxyFromEnv: () => void
|
||||
}
|
||||
|
||||
type NodeTlsWithSystemCertificates = typeof tls & {
|
||||
getCACertificates: (type: "default" | "system") => string[]
|
||||
setDefaultCACertificates: (certificates: string[]) => void
|
||||
}
|
||||
|
||||
type StartCommand = {
|
||||
type: "start"
|
||||
hostname: string
|
||||
port: number
|
||||
password: string
|
||||
userDataPath: string
|
||||
}
|
||||
|
||||
type StopCommand = { type: "stop" }
|
||||
type SidecarCommand = StartCommand | StopCommand
|
||||
|
||||
type SidecarMessage =
|
||||
| { type: "ready" }
|
||||
| { type: "stopped" }
|
||||
| { type: "error"; error: { message: string; stack?: string } }
|
||||
|
||||
type ParentPort = {
|
||||
postMessage(message: SidecarMessage): void
|
||||
on(event: "message", listener: (event: { data: unknown }) => void): void
|
||||
}
|
||||
|
||||
type Listener = {
|
||||
stop(close?: boolean): void | Promise<void>
|
||||
}
|
||||
|
||||
const parentPort = getParentPort()
|
||||
let listener: Listener | undefined
|
||||
|
||||
parentPort.on("message", (event) => {
|
||||
const command = parseCommand(event.data)
|
||||
if (!command) return
|
||||
if (command.type === "stop") {
|
||||
void stop()
|
||||
return
|
||||
}
|
||||
void start(command)
|
||||
})
|
||||
|
||||
async function start(command: StartCommand) {
|
||||
try {
|
||||
prepareSidecarEnv(command.password, command.userDataPath)
|
||||
ensureLoopbackNoProxy()
|
||||
useSystemCertificates()
|
||||
useEnvProxy()
|
||||
const { Server } = await import("virtual:opencode-server")
|
||||
|
||||
listener = await Server.listen({
|
||||
port: command.port,
|
||||
hostname: command.hostname,
|
||||
username: "opencode",
|
||||
password: command.password,
|
||||
cors: ["oc://renderer"],
|
||||
})
|
||||
parentPort.postMessage({ type: "ready" })
|
||||
} catch (error) {
|
||||
parentPort.postMessage({ type: "error", error: serializeError(error) })
|
||||
setImmediate(() => process.exit(1))
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
try {
|
||||
await listener?.stop()
|
||||
} finally {
|
||||
listener = undefined
|
||||
parentPort.postMessage({ type: "stopped" })
|
||||
setImmediate(() => process.exit(0))
|
||||
}
|
||||
}
|
||||
|
||||
function prepareSidecarEnv(password: string, userDataPath: string) {
|
||||
Object.assign(process.env, {
|
||||
OPENCODE_SERVER_USERNAME: "opencode",
|
||||
OPENCODE_SERVER_PASSWORD: password,
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
|
||||
})
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
const loopback = ["127.0.0.1", "localhost", "::1"]
|
||||
const upsert = (key: string) => {
|
||||
const items = (process.env[key] ?? "")
|
||||
.split(",")
|
||||
.map((value: string) => value.trim())
|
||||
.filter((value: string) => Boolean(value))
|
||||
|
||||
for (const host of loopback) {
|
||||
if (items.some((value: string) => value.toLowerCase() === host)) continue
|
||||
items.push(host)
|
||||
}
|
||||
|
||||
process.env[key] = items.join(",")
|
||||
}
|
||||
|
||||
upsert("NO_PROXY")
|
||||
upsert("no_proxy")
|
||||
}
|
||||
|
||||
function useSystemCertificates() {
|
||||
try {
|
||||
const nodeTls = tls as NodeTlsWithSystemCertificates
|
||||
nodeTls.setDefaultCACertificates([
|
||||
...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]),
|
||||
])
|
||||
} catch (error) {
|
||||
console.warn("failed to load system certificates", error)
|
||||
}
|
||||
}
|
||||
|
||||
function useEnvProxy() {
|
||||
try {
|
||||
;(http as NodeHttpWithEnvProxy).setGlobalProxyFromEnv()
|
||||
} catch (error) {
|
||||
console.warn("failed to load proxy environment", error)
|
||||
}
|
||||
}
|
||||
|
||||
function parseCommand(value: unknown): SidecarCommand | undefined {
|
||||
if (!value || typeof value !== "object") return
|
||||
const command = value as Partial<StartCommand | StopCommand>
|
||||
if (command.type === "stop") return { type: "stop" }
|
||||
if (command.type !== "start") return
|
||||
if (typeof command.hostname !== "string") return
|
||||
if (typeof command.port !== "number") return
|
||||
if (typeof command.password !== "string") return
|
||||
if (typeof command.userDataPath !== "string") return
|
||||
return {
|
||||
type: "start",
|
||||
hostname: command.hostname,
|
||||
port: command.port,
|
||||
password: command.password,
|
||||
userDataPath: command.userDataPath,
|
||||
}
|
||||
}
|
||||
|
||||
function serializeError(error: unknown) {
|
||||
if (error instanceof Error) return { message: error.message, stack: error.stack }
|
||||
return { message: String(error) }
|
||||
}
|
||||
|
||||
function getParentPort() {
|
||||
const port = process.parentPort as ParentPort | undefined
|
||||
if (!port) throw new Error("Sidecar parent port unavailable")
|
||||
return port
|
||||
}
|
||||
4
packages/desktop/src/main/store-keys.ts
Normal file
4
packages/desktop/src/main/store-keys.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const SETTINGS_STORE = "opencode.settings"
|
||||
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
|
||||
export const WSL_SERVERS_KEY = "wslServers"
|
||||
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
||||
23
packages/desktop/src/main/store.ts
Normal file
23
packages/desktop/src/main/store.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import Store from "electron-store"
|
||||
import electron from "electron"
|
||||
|
||||
import { SETTINGS_STORE } from "./store-keys"
|
||||
|
||||
const cache = new Map<string, Store>()
|
||||
|
||||
// We cannot instantiate the electron-store at module load time because
|
||||
// module import hoisting causes this to run before app.setPath("userData", ...)
|
||||
// in index.ts has executed, which would result in files being written to the default directory
|
||||
// (e.g. bad: %APPDATA%\@opencode-ai\desktop\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
|
||||
export function getStore(name = SETTINGS_STORE) {
|
||||
const cached = cache.get(name)
|
||||
if (cached) return cached
|
||||
const next = new Store({
|
||||
name,
|
||||
cwd: electron.app.getPath("userData"),
|
||||
fileExtension: "",
|
||||
accessPropertiesByDotNotation: false,
|
||||
})
|
||||
cache.set(name, next)
|
||||
return next
|
||||
}
|
||||
69
packages/desktop/src/main/unresponsive.ts
Normal file
69
packages/desktop/src/main/unresponsive.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { write as writeLog } from "./logging"
|
||||
|
||||
const sampleInterval = 1000
|
||||
const samplePeriod = 15000
|
||||
|
||||
export function createUnresponsiveSampler(win: BrowserWindow, name: string) {
|
||||
let sampleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let stopTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let sampling = false
|
||||
const samples = new Map<string, number>()
|
||||
|
||||
const active = () => sampling && !win.isDestroyed() && !win.webContents.isDestroyed()
|
||||
const clearTimers = () => {
|
||||
if (sampleTimer) clearTimeout(sampleTimer)
|
||||
if (stopTimer) clearTimeout(stopTimer)
|
||||
sampleTimer = undefined
|
||||
stopTimer = undefined
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
sampleTimer = setTimeout(() => {
|
||||
void collect()
|
||||
}, sampleInterval)
|
||||
}
|
||||
|
||||
const collect = async () => {
|
||||
if (!active()) return
|
||||
const stack = await win.webContents.mainFrame.collectJavaScriptCallStack().catch((error) => {
|
||||
writeLog("window", "failed to collect unresponsive sample", { window: name, error }, "error")
|
||||
return undefined
|
||||
})
|
||||
if (!active()) return
|
||||
if (stack) samples.set(stack, (samples.get(stack) ?? 0) + 1)
|
||||
schedule()
|
||||
}
|
||||
|
||||
const stopAndFlush = () => {
|
||||
const wasSampling = sampling
|
||||
sampling = false
|
||||
clearTimers()
|
||||
if (samples.size === 0) return wasSampling
|
||||
|
||||
const entries = [...samples.entries()].sort((a, b) => b[1] - a[1])
|
||||
const total = entries.reduce((sum, entry) => sum + entry[1], 0)
|
||||
const message = [
|
||||
"renderer unresponsive samples",
|
||||
`Window: ${name}`,
|
||||
`URL: ${win.isDestroyed() ? "<destroyed>" : win.webContents.getURL()}`,
|
||||
...entries.map((entry) => `<${entry[1]}> ${entry[0]}`),
|
||||
`Total Samples: ${total}`,
|
||||
].join("\n")
|
||||
writeLog("window", message, undefined, "error")
|
||||
samples.clear()
|
||||
return wasSampling
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (sampling || win.isDestroyed() || win.webContents.isDestroyed() || win.webContents.isDevToolsOpened()) return
|
||||
sampling = true
|
||||
samples.clear()
|
||||
schedule()
|
||||
stopTimer = setTimeout(stopAndFlush, samplePeriod)
|
||||
}
|
||||
|
||||
win.on("closed", stopAndFlush)
|
||||
|
||||
return { start, stopAndFlush }
|
||||
}
|
||||
111
packages/desktop/src/main/updater-controller.test.ts
Normal file
111
packages/desktop/src/main/updater-controller.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createUpdaterController, type UpdaterBackend, type UpdaterReadyRecord } from "./updater-controller"
|
||||
|
||||
function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) {
|
||||
const calls: string[] = []
|
||||
const backend: UpdaterBackend = {
|
||||
async checkForUpdates() {
|
||||
calls.push("check")
|
||||
return { isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }
|
||||
},
|
||||
async downloadUpdate() {
|
||||
calls.push("download")
|
||||
},
|
||||
quitAndInstall() {
|
||||
calls.push("install")
|
||||
},
|
||||
}
|
||||
let ready = input?.ready
|
||||
const controller = createUpdaterController({
|
||||
enabled: true,
|
||||
currentVersion: input?.currentVersion ?? "1.0.0",
|
||||
backend,
|
||||
persistence: {
|
||||
get: () => ready,
|
||||
set: (value) => {
|
||||
ready = value
|
||||
},
|
||||
clear: () => {
|
||||
ready = undefined
|
||||
},
|
||||
},
|
||||
stop: async () => {
|
||||
calls.push("stop")
|
||||
},
|
||||
})
|
||||
return { controller, calls, getReady: () => ready }
|
||||
}
|
||||
|
||||
describe("updater controller", () => {
|
||||
test("checks, downloads, persists, and publishes one authoritative ready state", async () => {
|
||||
const app = setup()
|
||||
const states: ReturnType<typeof app.controller.getState>[] = []
|
||||
app.controller.subscribe((state) => states.push(state))
|
||||
|
||||
await app.controller.start()
|
||||
|
||||
expect(app.calls).toEqual(["check", "download"])
|
||||
expect(app.getReady()).toEqual({ version: "2.0.0" })
|
||||
expect(states.map((state) => state.status)).toEqual(["idle", "checking", "downloading", "ready"])
|
||||
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
})
|
||||
|
||||
test("revalidates a persisted target through the updater cache on launch", async () => {
|
||||
const app = setup({ ready: { version: "2.0.0" } })
|
||||
|
||||
await app.controller.start()
|
||||
|
||||
expect(app.calls).toEqual(["check", "download"])
|
||||
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
})
|
||||
|
||||
test("clears a target already installed before checking", async () => {
|
||||
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } })
|
||||
|
||||
await app.controller.start()
|
||||
|
||||
expect(app.getReady()).toBeUndefined()
|
||||
expect(app.calls).toEqual(["check"])
|
||||
})
|
||||
|
||||
test("coalesces concurrent checks", async () => {
|
||||
const app = setup()
|
||||
|
||||
await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()])
|
||||
|
||||
expect(app.calls).toEqual(["check", "download"])
|
||||
})
|
||||
|
||||
test("returns to ready when quitAndInstall returns without exiting", async () => {
|
||||
const app = setup()
|
||||
await app.controller.start()
|
||||
|
||||
await app.controller.install()
|
||||
|
||||
expect(app.calls).toEqual(["check", "download", "stop", "install"])
|
||||
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
})
|
||||
|
||||
test("returns to ready when installation cannot start", async () => {
|
||||
const app = setup()
|
||||
await app.controller.start()
|
||||
|
||||
const failed = createUpdaterController({
|
||||
enabled: true,
|
||||
currentVersion: "1.0.0",
|
||||
backend: {
|
||||
checkForUpdates: async () => ({ isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }),
|
||||
downloadUpdate: async () => {},
|
||||
quitAndInstall() {},
|
||||
},
|
||||
persistence: { get: () => undefined, set() {}, clear() {} },
|
||||
stop: async () => {
|
||||
throw new Error("stop failed")
|
||||
},
|
||||
})
|
||||
await failed.start()
|
||||
|
||||
await expect(failed.install()).rejects.toThrow("stop failed")
|
||||
expect(failed.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
})
|
||||
})
|
||||
97
packages/desktop/src/main/updater-controller.ts
Normal file
97
packages/desktop/src/main/updater-controller.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
|
||||
export type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
|
||||
export type UpdaterReadyRecord = { version: string }
|
||||
|
||||
export type UpdaterBackend = {
|
||||
checkForUpdates(): Promise<{ isUpdateAvailable?: boolean; updateInfo?: { version?: string } } | null | undefined>
|
||||
downloadUpdate(): Promise<unknown>
|
||||
quitAndInstall(): void
|
||||
}
|
||||
|
||||
type UpdaterPersistence = {
|
||||
get(): UpdaterReadyRecord | undefined | Promise<UpdaterReadyRecord | undefined>
|
||||
set(value: UpdaterReadyRecord): void | Promise<void>
|
||||
clear(): void | Promise<void>
|
||||
}
|
||||
|
||||
export function createUpdaterController(input: {
|
||||
enabled: boolean
|
||||
currentVersion: string
|
||||
backend: UpdaterBackend
|
||||
persistence: UpdaterPersistence
|
||||
stop: () => Promise<void>
|
||||
log?: (message: string, data?: object) => void
|
||||
}) {
|
||||
let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" }
|
||||
let pending: Promise<UpdaterState> | undefined
|
||||
const listeners = new Set<(state: UpdaterState) => void>()
|
||||
|
||||
const transition = (next: UpdaterState) => {
|
||||
input.log?.("updater state changed", { from: state.status, to: next.status })
|
||||
state = next
|
||||
listeners.forEach((listener) => listener(state))
|
||||
return state
|
||||
}
|
||||
|
||||
const check = () => {
|
||||
if (!input.enabled) return Promise.resolve(state)
|
||||
if (state.status === "ready") return Promise.resolve(state)
|
||||
if (pending) return pending
|
||||
|
||||
pending = (async () => {
|
||||
transition({ status: "checking" })
|
||||
const result = await input.backend.checkForUpdates()
|
||||
const version = result?.updateInfo?.version
|
||||
if (!result?.isUpdateAvailable || !version || version === input.currentVersion) {
|
||||
await input.persistence.clear()
|
||||
return transition({ status: "up-to-date" })
|
||||
}
|
||||
|
||||
transition({ status: "downloading", version })
|
||||
await input.backend.downloadUpdate()
|
||||
await input.persistence.set({ version })
|
||||
return transition({ status: "ready", version })
|
||||
})()
|
||||
.catch((error) =>
|
||||
transition({ status: "error", message: error instanceof Error ? error.message : String(error) }),
|
||||
)
|
||||
.finally(() => {
|
||||
pending = undefined
|
||||
})
|
||||
return pending
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe(listener: (state: UpdaterState) => void) {
|
||||
listeners.add(listener)
|
||||
listener(state)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
async start() {
|
||||
const ready = await input.persistence.get()
|
||||
if (ready?.version === input.currentVersion) await input.persistence.clear()
|
||||
return check()
|
||||
},
|
||||
check,
|
||||
async install() {
|
||||
if (state.status !== "ready") throw new Error("Update is not ready to install")
|
||||
const version = state.version
|
||||
transition({ status: "installing", version })
|
||||
await input
|
||||
.stop()
|
||||
.then(() => {
|
||||
input.backend.quitAndInstall()
|
||||
transition({ status: "ready", version })
|
||||
})
|
||||
.catch((error) => {
|
||||
transition({ status: "ready", version })
|
||||
throw error
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type UpdaterController = ReturnType<typeof createUpdaterController>
|
||||
16
packages/desktop/src/main/updater-subscriptions.test.ts
Normal file
16
packages/desktop/src/main/updater-subscriptions.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createUpdaterSubscriptions } from "./updater-subscriptions"
|
||||
|
||||
describe("updater subscriptions", () => {
|
||||
test("replaces the previous renderer subscription on reload", () => {
|
||||
const subscriptions = createUpdaterSubscriptions()
|
||||
const disposed: string[] = []
|
||||
|
||||
subscriptions.set(1, () => disposed.push("first"))
|
||||
subscriptions.set(1, () => disposed.push("second"))
|
||||
|
||||
expect(disposed).toEqual(["first"])
|
||||
subscriptions.delete(1)
|
||||
expect(disposed).toEqual(["first", "second"])
|
||||
})
|
||||
})
|
||||
20
packages/desktop/src/main/updater-subscriptions.ts
Normal file
20
packages/desktop/src/main/updater-subscriptions.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export function createUpdaterSubscriptions() {
|
||||
const subscriptions = new Map<number, () => void>()
|
||||
|
||||
const remove = (id: number) => {
|
||||
subscriptions.get(id)?.()
|
||||
subscriptions.delete(id)
|
||||
}
|
||||
|
||||
return {
|
||||
set(id: number, unsubscribe: () => void) {
|
||||
remove(id)
|
||||
subscriptions.set(id, unsubscribe)
|
||||
},
|
||||
delete: remove,
|
||||
clear() {
|
||||
subscriptions.forEach((unsubscribe) => unsubscribe())
|
||||
subscriptions.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
68
packages/desktop/src/main/updater.ts
Normal file
68
packages/desktop/src/main/updater.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { app, dialog } from "electron"
|
||||
import pkg from "electron-updater"
|
||||
import { UPDATER_ENABLED } from "./constants"
|
||||
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
||||
import { getLogger } from "./logging"
|
||||
import { getStore } from "./store"
|
||||
|
||||
const { autoUpdater } = pkg
|
||||
const key = "ready"
|
||||
|
||||
export function setupAutoUpdater(stop: () => Promise<void>) {
|
||||
const logger = getLogger()
|
||||
autoUpdater.logger = logger
|
||||
autoUpdater.channel = "latest"
|
||||
autoUpdater.allowPrerelease = false
|
||||
autoUpdater.allowDowngrade = true
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
logger.log("auto updater configured", {
|
||||
channel: autoUpdater.channel,
|
||||
allowPrerelease: autoUpdater.allowPrerelease,
|
||||
allowDowngrade: autoUpdater.allowDowngrade,
|
||||
currentVersion: app.getVersion(),
|
||||
})
|
||||
|
||||
const store = getStore("opencode.updater")
|
||||
return createUpdaterController({
|
||||
enabled: UPDATER_ENABLED,
|
||||
currentVersion: app.getVersion(),
|
||||
backend: autoUpdater,
|
||||
persistence: {
|
||||
get() {
|
||||
const value = store.get(key)
|
||||
if (!value || typeof value !== "object" || !("version" in value) || typeof value.version !== "string") return
|
||||
return { version: value.version } satisfies UpdaterReadyRecord
|
||||
},
|
||||
set: (value) => store.set(key, value),
|
||||
clear: () => store.delete(key),
|
||||
},
|
||||
stop,
|
||||
log: (message, data) => logger.log(message, data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoUpdater>, alertOnFail: boolean) {
|
||||
const state = await controller.check()
|
||||
if (state.status === "error") {
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({ type: "error", message: "Update check failed.", title: "Update Error" })
|
||||
return
|
||||
}
|
||||
if (state.status === "up-to-date") {
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({ type: "info", message: "You're up to date.", title: "No Updates" })
|
||||
return
|
||||
}
|
||||
if (state.status !== "ready") return
|
||||
|
||||
const response = await dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: `Update ${state.version} downloaded. Restart now?`,
|
||||
title: "Update Ready",
|
||||
buttons: ["Restart", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
if (response.response === 0) await controller.install()
|
||||
}
|
||||
427
packages/desktop/src/main/windows.ts
Normal file
427
packages/desktop/src/main/windows.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import windowState from "electron-window-state"
|
||||
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||
import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
|
||||
import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
|
||||
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import type { TitlebarTheme } from "../preload/types"
|
||||
import { exportDebugLogs, write as writeLog } from "./logging"
|
||||
import { getStore } from "./store"
|
||||
import { PINCH_ZOOM_ENABLED_KEY } from "./store-keys"
|
||||
import { createUnresponsiveSampler } from "./unresponsive"
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
const rendererRoot = join(root, "../renderer")
|
||||
const rendererProtocol = "oc"
|
||||
const rendererHost = "renderer"
|
||||
const clipboardWritePermission = "clipboard-sanitized-write"
|
||||
const notificationPermission = "notifications"
|
||||
const rendererPermissions = new Set([clipboardWritePermission, notificationPermission])
|
||||
const oc2Theme = oc2ThemeJson as DesktopTheme
|
||||
const oc2Background = {
|
||||
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
|
||||
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
|
||||
}
|
||||
const documentPolicyHeader = "Document-Policy"
|
||||
const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports"
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: rendererProtocol,
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
let backgroundColor: string | undefined
|
||||
let relaunchHandler = () => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
}
|
||||
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
|
||||
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
|
||||
const titlebarHeight = 40
|
||||
const maxZoomLevel = 10
|
||||
const minZoomLevel = 0.2
|
||||
|
||||
export function setRelaunchHandler(handler: () => void) {
|
||||
relaunchHandler = handler
|
||||
}
|
||||
|
||||
export function setBackgroundColor(color: string) {
|
||||
backgroundColor = color
|
||||
BrowserWindow.getAllWindows().forEach((win) => win.setBackgroundColor(color))
|
||||
}
|
||||
|
||||
export function getBackgroundColor(): string | undefined {
|
||||
return backgroundColor
|
||||
}
|
||||
|
||||
function iconsDir() {
|
||||
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
|
||||
}
|
||||
|
||||
function iconPath() {
|
||||
const ext = process.platform === "win32" ? "ico" : "png"
|
||||
return join(iconsDir(), `icon.${ext}`)
|
||||
}
|
||||
|
||||
function tone() {
|
||||
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
|
||||
}
|
||||
|
||||
function defaultBackgroundColor() {
|
||||
return oc2Background[tone()]
|
||||
}
|
||||
|
||||
function overlay(theme: Partial<TitlebarTheme> = {}, zoom = 1) {
|
||||
const mode = theme.mode ?? tone()
|
||||
return {
|
||||
color: "#00000000",
|
||||
symbolColor: mode === "dark" ? "white" : "black",
|
||||
height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)),
|
||||
}
|
||||
}
|
||||
|
||||
export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
|
||||
titlebarThemes.set(win, theme)
|
||||
updateTitlebar(win)
|
||||
}
|
||||
|
||||
export function updateTitlebar(win: BrowserWindow) {
|
||||
if (process.platform !== "win32") return
|
||||
win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor()))
|
||||
}
|
||||
|
||||
export function setPinchZoomEnabled(enabled: boolean) {
|
||||
getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
pinchZoomEnabled.set(win, enabled)
|
||||
win.webContents.send("pinch-zoom-enabled-changed", enabled)
|
||||
if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
|
||||
updateZoom(win)
|
||||
}
|
||||
}
|
||||
|
||||
export function getPinchZoomEnabled() {
|
||||
return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true
|
||||
}
|
||||
|
||||
export function setDockIcon() {
|
||||
if (process.platform !== "darwin") return
|
||||
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
|
||||
if (!icon.isEmpty()) app.dock?.setIcon(icon)
|
||||
}
|
||||
|
||||
export function createMainWindow() {
|
||||
const state = windowState({
|
||||
defaultWidth: 1280,
|
||||
defaultHeight: 800,
|
||||
})
|
||||
|
||||
const mode = tone()
|
||||
const win = new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
title: "OpenCode",
|
||||
icon: iconPath(),
|
||||
backgroundColor: backgroundColor ?? defaultBackgroundColor(),
|
||||
...(process.platform === "darwin"
|
||||
? {
|
||||
titleBarStyle: "hidden" as const,
|
||||
trafficLightPosition: { x: 12, y: 14 },
|
||||
}
|
||||
: {}),
|
||||
...(process.platform === "win32"
|
||||
? {
|
||||
frame: false,
|
||||
titleBarStyle: "hidden" as const,
|
||||
titleBarOverlay: overlay({ mode }),
|
||||
}
|
||||
: {}),
|
||||
webPreferences: {
|
||||
preload: join(root, "../preload/index.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
})
|
||||
|
||||
allowRendererPermissions(win)
|
||||
wireWindowRecovery(win, "main")
|
||||
|
||||
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
const { requestHeaders } = details
|
||||
upsertKeyValue(requestHeaders, "Access-Control-Allow-Origin", ["*"])
|
||||
callback({ requestHeaders })
|
||||
})
|
||||
|
||||
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
const { responseHeaders = {} } = details
|
||||
addRendererHeaders(details.url, responseHeaders)
|
||||
callback({ responseHeaders })
|
||||
})
|
||||
|
||||
state.manage(win)
|
||||
loadWindow(win, "index.html")
|
||||
wireZoom(win)
|
||||
|
||||
win.once("ready-to-show", () => {
|
||||
win.show()
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
export function registerRendererProtocol() {
|
||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||
|
||||
protocol.handle(rendererProtocol, async (request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.host !== rendererHost) {
|
||||
writeLog("protocol", "rejected host", { url: request.url }, "warn")
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
|
||||
const rel = relative(rendererRoot, file)
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) {
|
||||
writeLog("protocol", "rejected path", { url: request.url, file }, "warn")
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await net.fetch(pathToFileURL(file).toString())
|
||||
if (response.status >= 400) {
|
||||
writeLog(
|
||||
"protocol",
|
||||
"fetch failed",
|
||||
{
|
||||
url: request.url,
|
||||
file,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
},
|
||||
"error",
|
||||
)
|
||||
}
|
||||
return addDocumentPolicy(response, file)
|
||||
} catch (error) {
|
||||
writeLog("protocol", "fetch error", { url: request.url, file, error }, "error")
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function loadWindow(win: BrowserWindow, html: string) {
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (devUrl) {
|
||||
const url = new URL(html, devUrl)
|
||||
void win.loadURL(url.toString())
|
||||
return
|
||||
}
|
||||
|
||||
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
|
||||
}
|
||||
|
||||
function wireWindowRecovery(win: BrowserWindow, name: string) {
|
||||
let showing = false
|
||||
const sampler = createUnresponsiveSampler(win, name)
|
||||
|
||||
const handle = async (button: string | undefined, wait: boolean) => {
|
||||
if (button === "Export Logs") {
|
||||
const sampling = sampler.stopAndFlush()
|
||||
await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error }, "error"))
|
||||
if (wait && sampling) sampler.start()
|
||||
return true
|
||||
}
|
||||
if (button === "Relaunch") {
|
||||
sampler.stopAndFlush()
|
||||
relaunchHandler()
|
||||
return false
|
||||
}
|
||||
if (button === "Quit") {
|
||||
sampler.stopAndFlush()
|
||||
app.quit()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const show = async (message: string, detail: string, wait: boolean) => {
|
||||
if (showing || win.isDestroyed()) return
|
||||
showing = true
|
||||
try {
|
||||
while (!win.isDestroyed()) {
|
||||
const buttons = wait ? ["Relaunch", "Export Logs", "Keep Waiting"] : ["Relaunch", "Export Logs", "Quit"]
|
||||
const result = await dialog.showMessageBox(win, {
|
||||
type: "warning",
|
||||
buttons,
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
message,
|
||||
detail,
|
||||
})
|
||||
if (await handle(buttons[result.response], wait)) continue
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
showing = false
|
||||
}
|
||||
}
|
||||
|
||||
const failed = (
|
||||
event: string,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedURL: string,
|
||||
isMainFrame: boolean,
|
||||
) => {
|
||||
writeLog(
|
||||
"window",
|
||||
"renderer load failed",
|
||||
{
|
||||
window: name,
|
||||
event,
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
currentURL: win.webContents.getURL(),
|
||||
isMainFrame,
|
||||
},
|
||||
"error",
|
||||
)
|
||||
|
||||
if (!isMainFrame || errorCode === -3) return
|
||||
void show(
|
||||
"OpenCode failed to load",
|
||||
[`Window: ${name}`, `URL: ${validatedURL}`, `Error: ${errorCode} ${errorDescription}`].join("\n"),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
failed("did-fail-load", errorCode, errorDescription, validatedURL, isMainFrame)
|
||||
})
|
||||
win.webContents.on("did-fail-provisional-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
failed("did-fail-provisional-load", errorCode, errorDescription, validatedURL, isMainFrame)
|
||||
})
|
||||
win.webContents.on("render-process-gone", (_event, details) => {
|
||||
sampler.stopAndFlush()
|
||||
writeLog(
|
||||
"window",
|
||||
"renderer process gone",
|
||||
{ window: name, currentURL: win.webContents.getURL(), details },
|
||||
"error",
|
||||
)
|
||||
void show(
|
||||
"OpenCode window terminated unexpectedly",
|
||||
[`Window: ${name}`, `Reason: ${details.reason}`, `Code: ${details.exitCode ?? "<unknown>"}`].join("\n"),
|
||||
false,
|
||||
)
|
||||
})
|
||||
win.on("unresponsive", () => {
|
||||
writeLog("window", "renderer unresponsive", { window: name, currentURL: win.webContents.getURL() }, "error")
|
||||
sampler.start()
|
||||
void show("OpenCode is not responding", "You can relaunch the app, open the logs, or keep waiting.", true)
|
||||
})
|
||||
win.on("responsive", () => {
|
||||
writeLog("window", "renderer responsive", { window: name, currentURL: win.webContents.getURL() }, "error")
|
||||
sampler.stopAndFlush()
|
||||
})
|
||||
win.webContents.on("console-message", (_event, level, message, line, sourceId) => {
|
||||
if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) {
|
||||
writeLog("pty", "console", { window: name, level, message, line, sourceId })
|
||||
}
|
||||
})
|
||||
win.webContents.on("preload-error", (_event, preloadPath, error) => {
|
||||
writeLog("preload", "preload error", { window: name, preloadPath, error }, "error")
|
||||
})
|
||||
}
|
||||
|
||||
function addDocumentPolicy(response: Response, file: string) {
|
||||
if (!file.toLowerCase().endsWith(".html")) return response
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
|
||||
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
|
||||
}
|
||||
|
||||
function allowRendererPermissions(win: BrowserWindow) {
|
||||
win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||
callback(
|
||||
rendererPermissions.has(permission) &&
|
||||
isTrustedRendererUrl(details.requestingUrl) &&
|
||||
webContents.id === win.webContents.id,
|
||||
)
|
||||
})
|
||||
win.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
|
||||
if (!rendererPermissions.has(permission)) return false
|
||||
if (webContents && webContents.id !== win.webContents.id) return false
|
||||
return isTrustedRendererUrl(details.requestingUrl) || isTrustedRendererUrl(requestingOrigin)
|
||||
})
|
||||
}
|
||||
|
||||
function isTrustedRendererUrl(value?: string) {
|
||||
return isRendererUrl(value)
|
||||
}
|
||||
|
||||
function addRendererHeaders(value: string, headers: Record<string, any>) {
|
||||
upsertKeyValue(headers, "Access-Control-Allow-Origin", ["*"])
|
||||
upsertKeyValue(headers, "Access-Control-Allow-Headers", ["*"])
|
||||
if (isRendererUrl(value, true)) upsertKeyValue(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy])
|
||||
}
|
||||
|
||||
function isRendererUrl(value?: string, html = false) {
|
||||
if (!value || !URL.canParse(value)) return false
|
||||
const url = new URL(value)
|
||||
if (html && !url.pathname.endsWith(".html")) return false
|
||||
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (!devUrl || !URL.canParse(devUrl)) return false
|
||||
return url.origin === new URL(devUrl).origin
|
||||
}
|
||||
|
||||
function wireZoom(win: BrowserWindow) {
|
||||
pinchZoomEnabled.set(win, getPinchZoomEnabled())
|
||||
win.webContents.setZoomFactor(1)
|
||||
win.webContents.on("zoom-changed", (event, zoomDirection) => {
|
||||
event.preventDefault()
|
||||
if (pinchZoomEnabled.get(win)) {
|
||||
win.webContents.setZoomFactor(clampZoom(win.webContents.getZoomFactor() + (zoomDirection === "in" ? 0.2 : -0.2)))
|
||||
updateZoom(win)
|
||||
return
|
||||
}
|
||||
if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
|
||||
updateZoom(win)
|
||||
})
|
||||
}
|
||||
|
||||
function clampZoom(value: number) {
|
||||
return Math.min(Math.max(value, minZoomLevel), maxZoomLevel)
|
||||
}
|
||||
|
||||
function updateZoom(win: BrowserWindow) {
|
||||
updateTitlebar(win)
|
||||
win.webContents.send("zoom-factor-changed", win.webContents.getZoomFactor())
|
||||
}
|
||||
|
||||
function upsertKeyValue(obj: Record<string, any>, keyToChange: string, value: any) {
|
||||
const keyToChangeLower = keyToChange.toLowerCase()
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (key.toLowerCase() === keyToChangeLower) {
|
||||
// Reassign old key
|
||||
obj[key] = value
|
||||
// Done
|
||||
return
|
||||
}
|
||||
}
|
||||
// Insert at end instead
|
||||
obj[keyToChange] = value
|
||||
}
|
||||
107
packages/desktop/src/main/wsl/ipc.ts
Normal file
107
packages/desktop/src/main/wsl/ipc.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { app, ipcMain } from "electron"
|
||||
import type { IpcMainInvokeEvent } from "electron"
|
||||
import type { WslServersController } from "./servers"
|
||||
import { requireWslIpcString } from "./policy"
|
||||
import type { WslServersState } from "../../preload/types"
|
||||
|
||||
export function registerWslIpcHandlers(controller: WslServersController) {
|
||||
if (process.platform !== "win32") {
|
||||
registerUnavailableWslIpcHandlers()
|
||||
return
|
||||
}
|
||||
|
||||
const subscriptions = new Map<number, () => void>()
|
||||
const unsubscribe = (id: number) => {
|
||||
const off = subscriptions.get(id)
|
||||
if (!off) return
|
||||
off()
|
||||
subscriptions.delete(id)
|
||||
}
|
||||
|
||||
app.once("will-quit", () => {
|
||||
subscriptions.forEach((off) => off())
|
||||
subscriptions.clear()
|
||||
})
|
||||
|
||||
ipcMain.handle("wsl-servers-subscribe", (event) => {
|
||||
const id = event.sender.id
|
||||
if (subscriptions.has(id)) return
|
||||
subscriptions.set(
|
||||
id,
|
||||
controller.subscribe((payload) => {
|
||||
if (event.sender.isDestroyed()) {
|
||||
unsubscribe(id)
|
||||
return
|
||||
}
|
||||
event.sender.send("wsl-servers-event", payload)
|
||||
}),
|
||||
)
|
||||
event.sender.once("destroyed", () => unsubscribe(id))
|
||||
})
|
||||
ipcMain.handle("wsl-servers-unsubscribe", (event) => unsubscribe(event.sender.id))
|
||||
ipcMain.handle("wsl-servers-get-state", () => controller.getState())
|
||||
ipcMain.handle("wsl-servers-probe-runtime", () => controller.probeRuntime())
|
||||
ipcMain.handle("wsl-servers-refresh-distros", () => controller.refreshDistros())
|
||||
ipcMain.handle("wsl-servers-install-wsl", () => controller.installWsl())
|
||||
ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
controller.installDistro(requireWslIpcString("distro", name)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
controller.probeDistro(requireWslIpcString("distro", name)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
controller.probeOpencode(requireWslIpcString("distro", name)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
controller.installOpencode(requireWslIpcString("distro", name)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
controller.openTerminal(requireWslIpcString("distro", name)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) =>
|
||||
controller.addServer(requireWslIpcString("distro", distro)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) =>
|
||||
controller.removeServer(requireWslIpcString("server id", id)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) =>
|
||||
controller.startServer(requireWslIpcString("server id", id)),
|
||||
)
|
||||
}
|
||||
|
||||
function registerUnavailableWslIpcHandlers() {
|
||||
const unavailable = () => {
|
||||
throw new Error("WSL is only available on Windows")
|
||||
}
|
||||
const state = (): WslServersState => ({
|
||||
runtime: {
|
||||
available: false,
|
||||
version: null,
|
||||
error: "WSL is only available on Windows",
|
||||
},
|
||||
installed: [],
|
||||
online: [],
|
||||
distroProbes: {},
|
||||
opencodeChecks: {},
|
||||
pendingRestart: false,
|
||||
servers: [],
|
||||
job: null,
|
||||
})
|
||||
|
||||
ipcMain.handle("wsl-servers-subscribe", (event) => {
|
||||
event.sender.send("wsl-servers-event", { type: "state", state: state() })
|
||||
})
|
||||
ipcMain.handle("wsl-servers-unsubscribe", () => undefined)
|
||||
ipcMain.handle("wsl-servers-get-state", () => state())
|
||||
ipcMain.handle("wsl-servers-probe-runtime", unavailable)
|
||||
ipcMain.handle("wsl-servers-refresh-distros", unavailable)
|
||||
ipcMain.handle("wsl-servers-install-wsl", unavailable)
|
||||
ipcMain.handle("wsl-servers-install-distro", unavailable)
|
||||
ipcMain.handle("wsl-servers-probe-distro", unavailable)
|
||||
ipcMain.handle("wsl-servers-probe-opencode", unavailable)
|
||||
ipcMain.handle("wsl-servers-install-opencode", unavailable)
|
||||
ipcMain.handle("wsl-servers-open-terminal", unavailable)
|
||||
ipcMain.handle("wsl-servers-add", unavailable)
|
||||
ipcMain.handle("wsl-servers-remove", unavailable)
|
||||
ipcMain.handle("wsl-servers-start", unavailable)
|
||||
}
|
||||
26
packages/desktop/src/main/wsl/policy.ts
Normal file
26
packages/desktop/src/main/wsl/policy.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { WslDistroProbe, WslOpencodeCheck, WslServerItem } from "../../preload/types"
|
||||
|
||||
export function wslServerIdToRestart(servers: WslServerItem[], distro: string) {
|
||||
return servers.find((item) => item.config.distro === distro)?.config.id
|
||||
}
|
||||
|
||||
export function clearWslDistroState(
|
||||
distroProbes: Record<string, WslDistroProbe>,
|
||||
opencodeChecks: Record<string, WslOpencodeCheck>,
|
||||
distro: string,
|
||||
) {
|
||||
const nextDistroProbes = { ...distroProbes }
|
||||
const nextOpencodeChecks = { ...opencodeChecks }
|
||||
delete nextDistroProbes[distro]
|
||||
delete nextOpencodeChecks[distro]
|
||||
return { distroProbes: nextDistroProbes, opencodeChecks: nextOpencodeChecks }
|
||||
}
|
||||
|
||||
export function wslTerminalArgs(distro?: string | null) {
|
||||
return ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])]
|
||||
}
|
||||
|
||||
export function requireWslIpcString(name: string, value: unknown) {
|
||||
if (typeof value === "string" && value.length > 0) return value
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
400
packages/desktop/src/main/wsl/runtime.ts
Normal file
400
packages/desktop/src/main/wsl/runtime.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import { existsSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import * as pty from "@lydell/node-pty"
|
||||
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types"
|
||||
import { wslTerminalArgs } from "./policy"
|
||||
|
||||
export type WslCommandLine = {
|
||||
stream: "stdout" | "stderr"
|
||||
text: string
|
||||
}
|
||||
|
||||
export type WslCommandResult = {
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
export type RunWslOptions = {
|
||||
signal?: AbortSignal
|
||||
/**
|
||||
* Ceiling on how long we wait for the child process to exit. When the
|
||||
* LXSS service or a specific distro wedges (e.g. Ubuntu-24.04 with a
|
||||
* pending first-run prompt), `wsl.exe` never returns and any command
|
||||
* that doesn't specify a timeout hangs the entire startup flow. Default
|
||||
* is 20s — enough for slow cold-starts, short enough to fail fast on
|
||||
* a wedge. Callers can override for longer-running jobs.
|
||||
*/
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
const DEFAULT_WSL_TIMEOUT_MS = 20_000
|
||||
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
|
||||
|
||||
export function wslArgs(args: string[], distro?: string | null, user?: string | null) {
|
||||
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args]
|
||||
}
|
||||
|
||||
export function runWsl(args: string[], opts: RunWslOptions = {}) {
|
||||
return runCommand("wsl", args, opts)
|
||||
}
|
||||
|
||||
function runPowerShell(command: string, opts: RunWslOptions = {}) {
|
||||
return runCommand(
|
||||
"powershell.exe",
|
||||
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command],
|
||||
opts,
|
||||
)
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
|
||||
return new Promise<WslCommandResult>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
signal: opts.signal,
|
||||
})
|
||||
|
||||
// Guard every wsl.exe invocation with a timeout. When the distro or
|
||||
// the LXSS service is wedged (Ubuntu first-run state, Windows update
|
||||
// pending, etc.) wsl.exe produces no output and never exits; without
|
||||
// this the whole sidecar spawn flow stalls the app forever.
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_WSL_TIMEOUT_MS
|
||||
const timeoutId = setTimeout(() => {
|
||||
try {
|
||||
child.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
const stdoutDecoder = createOutputDecoder()
|
||||
const stderrDecoder = createOutputDecoder()
|
||||
|
||||
const append = (stream: WslCommandLine["stream"], chunk: string) => {
|
||||
if (!chunk) return
|
||||
if (stream === "stdout") {
|
||||
stdout += chunk
|
||||
return
|
||||
}
|
||||
stderr += chunk
|
||||
}
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
append("stdout", stdoutDecoder.decode(chunk))
|
||||
})
|
||||
child.stdout.on("end", () => {
|
||||
append("stdout", stdoutDecoder.flush())
|
||||
})
|
||||
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
append("stderr", stderrDecoder.decode(chunk))
|
||||
})
|
||||
child.stderr.on("end", () => {
|
||||
append("stderr", stderrDecoder.flush())
|
||||
})
|
||||
|
||||
child.once("error", (error) => {
|
||||
clearTimeout(timeoutId)
|
||||
reject(error)
|
||||
})
|
||||
child.once("close", (code, signal) => {
|
||||
clearTimeout(timeoutId)
|
||||
resolve({ code, signal, stdout, stderr })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) {
|
||||
return new Promise<WslCommandResult>((resolve, reject) => {
|
||||
const child = pty.spawn(command, args, {
|
||||
name: "xterm-color",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
useConpty: true,
|
||||
})
|
||||
|
||||
let settled = false
|
||||
let stdout = ""
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId)
|
||||
abortCleanup?.()
|
||||
}
|
||||
|
||||
const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs
|
||||
const timeoutId = setTimeout(() => {
|
||||
try {
|
||||
child.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
const abortHandler = () => {
|
||||
try {
|
||||
child.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(new DOMException("Aborted", "AbortError"))
|
||||
}
|
||||
const abortCleanup = opts.signal
|
||||
? (() => {
|
||||
opts.signal?.addEventListener("abort", abortHandler, { once: true })
|
||||
return () => opts.signal?.removeEventListener("abort", abortHandler)
|
||||
})()
|
||||
: undefined
|
||||
|
||||
child.onData((data: string) => {
|
||||
stdout += data
|
||||
})
|
||||
child.onExit((event: { exitCode: number }) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve({ code: event.exitCode, signal: null, stdout, stderr: "" })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createOutputDecoder() {
|
||||
let decoder: TextDecoder | undefined
|
||||
return {
|
||||
decode(chunk: Buffer) {
|
||||
decoder ??= new TextDecoder(detectOutputEncoding(chunk))
|
||||
return decoder.decode(chunk, { stream: true })
|
||||
},
|
||||
flush() {
|
||||
return decoder?.decode() ?? ""
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function detectOutputEncoding(chunk: Uint8Array) {
|
||||
if (chunk[0] === 0xff && chunk[1] === 0xfe) return "utf-16le"
|
||||
const pairs = Math.floor(chunk.length / 2)
|
||||
if (pairs < 2) return "utf-8"
|
||||
const oddZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2 + 1] === 0).length
|
||||
const evenZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2] === 0).length
|
||||
return oddZeroes >= Math.ceil(pairs / 3) && evenZeroes * 2 <= oddZeroes ? "utf-16le" : "utf-8"
|
||||
}
|
||||
|
||||
export function runWslInDistro(args: string[], distro?: string | null, opts?: RunWslOptions) {
|
||||
return runWsl(wslArgs(args, distro), opts)
|
||||
}
|
||||
|
||||
export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) {
|
||||
return runWslInDistro(["sh", "-lc", script], distro, opts)
|
||||
}
|
||||
|
||||
export async function probeWslRuntime(opts?: RunWslOptions): Promise<WslRuntimeCheck> {
|
||||
const version = await runWsl(["--version"], opts).catch((error) => ({
|
||||
code: 1,
|
||||
signal: null,
|
||||
stdout: "",
|
||||
stderr: error instanceof Error ? error.message : String(error),
|
||||
}))
|
||||
|
||||
if (version.code !== 0) {
|
||||
return {
|
||||
available: false,
|
||||
version: null,
|
||||
error: summarize(version.stderr || version.stdout) || "WSL is unavailable",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
version: firstLine(version.stdout),
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listInstalledWslDistros(opts?: RunWslOptions) {
|
||||
const result = await runWsl(["--list", "--verbose"], opts)
|
||||
if (result.code !== 0) {
|
||||
throw new Error(summarize(result.stderr || result.stdout) || "Failed to list installed WSL distros")
|
||||
}
|
||||
return parseInstalledDistros(result.stdout)
|
||||
}
|
||||
|
||||
export async function listOnlineWslDistros(opts?: RunWslOptions) {
|
||||
const result = await runWsl(["--list", "--online"], opts)
|
||||
if (result.code !== 0) {
|
||||
throw new Error(summarize(result.stderr || result.stdout) || "Failed to list online WSL distros")
|
||||
}
|
||||
return parseOnlineDistros(result.stdout)
|
||||
}
|
||||
|
||||
export async function installWslRuntimeElevated(opts?: RunWslOptions) {
|
||||
const script = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
"$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru",
|
||||
"if ($null -ne $process.ExitCode) { exit $process.ExitCode }",
|
||||
].join("; ")
|
||||
return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
|
||||
}
|
||||
|
||||
export async function installWslDistro(name: string, opts?: RunWslOptions) {
|
||||
return runInteractiveCommand(
|
||||
resolveSystem32Command("wsl.exe"),
|
||||
["--install", "-d", name, "--web-download", "--no-launch"],
|
||||
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
|
||||
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) {
|
||||
return runInteractiveCommand(
|
||||
resolveSystem32Command("wsl.exe"),
|
||||
wslArgs(
|
||||
["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`],
|
||||
distro,
|
||||
),
|
||||
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
|
||||
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<WslDistroProbe> {
|
||||
const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({
|
||||
code: 1,
|
||||
signal: null,
|
||||
stdout: "",
|
||||
stderr: error instanceof Error ? error.message : String(error),
|
||||
}))
|
||||
if (executable.code !== 0) {
|
||||
return {
|
||||
name,
|
||||
canExecute: false,
|
||||
hasBash: false,
|
||||
hasCurl: false,
|
||||
error: summarize(executable.stderr || executable.stdout) || "Cannot execute commands in distro",
|
||||
}
|
||||
}
|
||||
|
||||
const [bash, curl] = await Promise.all([
|
||||
runWslSh("command -v bash >/dev/null && printf yes || printf no", name, opts),
|
||||
runWslSh("command -v curl >/dev/null && printf yes || printf no", name, opts),
|
||||
])
|
||||
|
||||
return {
|
||||
name,
|
||||
canExecute: true,
|
||||
hasBash: bash.code === 0 && summarize(bash.stdout) === "yes",
|
||||
hasCurl: curl.code === 0 && summarize(curl.stdout) === "yes",
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
|
||||
return firstLine(
|
||||
(
|
||||
await runWslSh(
|
||||
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
|
||||
distro,
|
||||
opts,
|
||||
)
|
||||
).stdout,
|
||||
)
|
||||
}
|
||||
|
||||
export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) {
|
||||
const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts)
|
||||
return firstLine(result.stdout)
|
||||
}
|
||||
|
||||
export function openWslTerminal(distro?: string | null) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("cmd.exe", wslTerminalArgs(distro), {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
child.once("error", reject)
|
||||
child.once("spawn", () => {
|
||||
child.unref()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function parseInstalledDistros(output: string) {
|
||||
return output.split(/\r?\n/g).flatMap((line) => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return []
|
||||
const match = line.match(/^\s*(\*)?\s*(.*?)\s{2,}\S+\s+(\d+)\s*$/)
|
||||
if (!match) return []
|
||||
const [, marker, name, version] = match
|
||||
if (!name || /^name$/i.test(name)) return []
|
||||
return [
|
||||
{
|
||||
name: name.trim(),
|
||||
version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10),
|
||||
isDefault: marker === "*",
|
||||
} satisfies WslInstalledDistro,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function parseOnlineDistros(output: string) {
|
||||
return output.split(/\r?\n/g).flatMap((line) => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return []
|
||||
const match = trimmed.match(/^([A-Za-z0-9._-]+)\s{2,}(.+)$/)
|
||||
if (!match) return []
|
||||
const [, name, label] = match
|
||||
if (/^name$/i.test(name)) return []
|
||||
return [{ name, label: label.trim() } satisfies WslOnlineDistro]
|
||||
})
|
||||
}
|
||||
|
||||
function firstLine(value: string) {
|
||||
return (
|
||||
value
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function summarize(value: string) {
|
||||
return value
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export function shellEscape(value: string) {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`
|
||||
}
|
||||
|
||||
function resolveSystem32Command(command: string) {
|
||||
const root = process.env.SystemRoot ?? process.env.windir
|
||||
if (!root) return command
|
||||
const resolved = join(root, "System32", command)
|
||||
return existsSync(resolved) ? resolved : command
|
||||
}
|
||||
|
||||
function withTimeout(opts: RunWslOptions | undefined, timeoutMs: number): RunWslOptions {
|
||||
return {
|
||||
...opts,
|
||||
timeoutMs: opts?.timeoutMs ?? timeoutMs,
|
||||
}
|
||||
}
|
||||
167
packages/desktop/src/main/wsl/servers.test.ts
Normal file
167
packages/desktop/src/main/wsl/servers.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { clearWslDistroState, requireWslIpcString, wslServerIdToRestart, wslTerminalArgs } from "./policy"
|
||||
import {
|
||||
expectOpencodeVersion,
|
||||
pendingRestartAfterWslInstall,
|
||||
pollWslHealth,
|
||||
wslServerIdsToStartOnInitialize,
|
||||
} from "./startup"
|
||||
import { createWslServersController, type WslServerConfig } from "./servers"
|
||||
|
||||
let persistedServers: WslServerConfig[] = []
|
||||
let releaseOpencodeResolve: (() => void) | undefined
|
||||
|
||||
test("starts every configured WSL server on initialization", () => {
|
||||
expect(
|
||||
wslServerIdsToStartOnInitialize([
|
||||
{ id: "wsl:Debian", distro: "Debian" },
|
||||
{ id: "wsl:Ubuntu-24.04", distro: "Ubuntu-24.04" },
|
||||
]),
|
||||
).toEqual(["wsl:Debian", "wsl:Ubuntu-24.04"])
|
||||
})
|
||||
|
||||
test("rejects an update that did not install the desktop version", () => {
|
||||
expect(() => expectOpencodeVersion("1.16.2", "1.16.2")).not.toThrow()
|
||||
expect(() => expectOpencodeVersion("1.14.35", "1.16.2")).toThrow(
|
||||
"OpenCode update finished but Debian still reports 1.14.35; expected 1.16.2",
|
||||
)
|
||||
})
|
||||
|
||||
test("restarts an existing distro server after updating OpenCode", () => {
|
||||
expect(
|
||||
wslServerIdToRestart(
|
||||
[
|
||||
{
|
||||
config: { id: "wsl:Debian", distro: "Debian" },
|
||||
runtime: { kind: "ready", url: "", username: null, password: null },
|
||||
},
|
||||
],
|
||||
"Debian",
|
||||
),
|
||||
).toBe("wsl:Debian")
|
||||
expect(wslServerIdToRestart([], "Debian")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clears cached distro probes when removing a WSL server", () => {
|
||||
expect(
|
||||
clearWslDistroState(
|
||||
{ Debian: { name: "Debian", canExecute: true, hasBash: true, hasCurl: true, error: null } },
|
||||
{
|
||||
Debian: {
|
||||
distro: "Debian",
|
||||
resolvedPath: "/home/luke/.opencode/bin/opencode",
|
||||
version: "1.16.2",
|
||||
expectedVersion: "1.16.2",
|
||||
matchesDesktop: true,
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
"Debian",
|
||||
),
|
||||
).toEqual({ distroProbes: {}, opencodeChecks: {} })
|
||||
})
|
||||
|
||||
test("opens terminals for distro names containing spaces", () => {
|
||||
expect(wslTerminalArgs("Ubuntu Preview")).toEqual(["/c", "start", "", "wsl", "-d", "Ubuntu Preview"])
|
||||
})
|
||||
|
||||
test("stops health polling when sidecar startup settles", async () => {
|
||||
const abort = new AbortController()
|
||||
let checks = 0
|
||||
const polling = pollWslHealth(
|
||||
async () => {
|
||||
checks++
|
||||
return false
|
||||
},
|
||||
abort.signal,
|
||||
1,
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
abort.abort()
|
||||
await polling
|
||||
const settled = checks
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
expect(checks).toBe(settled)
|
||||
})
|
||||
|
||||
test("validates WSL IPC identifiers at the module boundary", () => {
|
||||
expect(requireWslIpcString("distro", "Debian")).toBe("Debian")
|
||||
expect(() => requireWslIpcString("distro", "")).toThrow("Invalid distro")
|
||||
expect(() => requireWslIpcString("server id", undefined)).toThrow("Invalid server id")
|
||||
})
|
||||
|
||||
test("derives a required Windows restart from the post-install runtime probe", () => {
|
||||
expect(pendingRestartAfterWslInstall({ available: false, version: null, error: "WSL unavailable" })).toBe(true)
|
||||
expect(pendingRestartAfterWslInstall({ available: true, version: "WSL version: 2.6.1", error: null })).toBe(false)
|
||||
})
|
||||
|
||||
test("ignores stale background OpenCode checks after removing a WSL server", async () => {
|
||||
persistedServers = []
|
||||
releaseOpencodeResolve = undefined
|
||||
const controller = createWslServersController(
|
||||
"1.16.2",
|
||||
async () => ({
|
||||
listener: {
|
||||
stop: () => undefined,
|
||||
onExit: () => undefined,
|
||||
},
|
||||
url: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
}),
|
||||
testControllerOptions(),
|
||||
)
|
||||
|
||||
await controller.addServer("Debian")
|
||||
await waitFor(() => !!releaseOpencodeResolve)
|
||||
await controller.removeServer("wsl:Debian")
|
||||
releaseOpencodeResolve?.()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(controller.getState().servers).toEqual([])
|
||||
expect(controller.getState().opencodeChecks).toEqual({})
|
||||
})
|
||||
|
||||
test("ignores stale startup OpenCode checks after removing a WSL server", async () => {
|
||||
persistedServers = [{ id: "wsl:Debian", distro: "Debian" }]
|
||||
releaseOpencodeResolve = undefined
|
||||
const controller = createWslServersController(
|
||||
"1.16.2",
|
||||
async () => new Promise<never>(() => undefined),
|
||||
testControllerOptions(),
|
||||
)
|
||||
|
||||
await controller.initialize()
|
||||
await waitFor(() => !!releaseOpencodeResolve)
|
||||
await controller.removeServer("wsl:Debian")
|
||||
releaseOpencodeResolve?.()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(controller.getState().servers).toEqual([])
|
||||
expect(controller.getState().opencodeChecks).toEqual({})
|
||||
})
|
||||
|
||||
async function waitFor(check: () => boolean) {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
if (check()) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
throw new Error("Timed out waiting for condition")
|
||||
}
|
||||
|
||||
function testControllerOptions() {
|
||||
return {
|
||||
readServers: () => persistedServers,
|
||||
writeServers: (servers: WslServerConfig[]) => {
|
||||
persistedServers = servers
|
||||
},
|
||||
readCommandVersion: async () => "1.16.2",
|
||||
resolveOpencode: async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseOpencodeResolve = resolve
|
||||
})
|
||||
return "/home/me/.opencode/bin/opencode"
|
||||
},
|
||||
}
|
||||
}
|
||||
499
packages/desktop/src/main/wsl/servers.ts
Normal file
499
packages/desktop/src/main/wsl/servers.ts
Normal file
@@ -0,0 +1,499 @@
|
||||
import type {
|
||||
WslDistroProbe,
|
||||
WslInstalledDistro,
|
||||
WslJob,
|
||||
WslOnlineDistro,
|
||||
WslOpencodeCheck,
|
||||
WslRuntimeCheck,
|
||||
WslServerConfig,
|
||||
WslServerItem,
|
||||
WslServerRuntime,
|
||||
WslServersEvent,
|
||||
WslServersState,
|
||||
} from "../../preload/types"
|
||||
import { WSL_SERVERS_KEY } from "../store-keys"
|
||||
import { getStore } from "../store"
|
||||
import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup"
|
||||
import { clearWslDistroState, wslServerIdToRestart } from "./policy"
|
||||
import {
|
||||
installWslDistro,
|
||||
installWslOpencode,
|
||||
installWslRuntimeElevated,
|
||||
listInstalledWslDistros,
|
||||
listOnlineWslDistros,
|
||||
openWslTerminal,
|
||||
probeWslDistro,
|
||||
probeWslRuntime,
|
||||
readWslCommandVersion,
|
||||
resolveWslOpencode,
|
||||
summarize,
|
||||
} from "./runtime"
|
||||
|
||||
type RunningSidecar = {
|
||||
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
|
||||
url: string
|
||||
username: string | null
|
||||
password: string
|
||||
}
|
||||
|
||||
type SpawnSidecar = (distro: string) => Promise<RunningSidecar>
|
||||
|
||||
type ControllerLogger = {
|
||||
log: (message: string, meta?: unknown) => void
|
||||
error: (message: string, meta?: unknown) => void
|
||||
}
|
||||
|
||||
type WslServersControllerOptions = {
|
||||
logger?: ControllerLogger
|
||||
readServers?: () => WslServerConfig[]
|
||||
writeServers?: (servers: WslServerConfig[]) => void
|
||||
resolveOpencode?: typeof resolveWslOpencode
|
||||
readCommandVersion?: typeof readWslCommandVersion
|
||||
}
|
||||
|
||||
export type WslServersController = ReturnType<typeof createWslServersController>
|
||||
|
||||
export function wslServerIdForDistro(distro: string) {
|
||||
return `wsl:${distro}`
|
||||
}
|
||||
|
||||
export function createWslServersController(
|
||||
appVersion: string,
|
||||
spawnSidecar: SpawnSidecar,
|
||||
options?: WslServersControllerOptions,
|
||||
) {
|
||||
let state: WslServersState = initialState()
|
||||
const listeners = new Set<(event: WslServersEvent) => void>()
|
||||
const sidecars = new Map<string, RunningSidecar>()
|
||||
const startAttempts = new Map<string, number>()
|
||||
let jobAbort: AbortController | undefined
|
||||
const logger = options?.logger
|
||||
const readServers = options?.readServers ?? readPersistedServers
|
||||
const writeServers = options?.writeServers ?? writePersistedServers
|
||||
|
||||
const emit = () => {
|
||||
for (const listener of listeners) listener({ type: "state", state })
|
||||
}
|
||||
|
||||
const setState = (next: Partial<WslServersState>) => {
|
||||
state = { ...state, ...next }
|
||||
emit()
|
||||
}
|
||||
|
||||
const persistServers = (servers: WslServerConfig[]) => {
|
||||
writeServers(servers)
|
||||
}
|
||||
|
||||
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
|
||||
const next = state.servers.map((item) => (item.config.id === id ? update(item) : item))
|
||||
setState({ servers: next })
|
||||
}
|
||||
|
||||
const beginJob = (job: WslJob): AbortController => {
|
||||
jobAbort?.abort()
|
||||
const abort = new AbortController()
|
||||
jobAbort = abort
|
||||
setState({ job })
|
||||
return abort
|
||||
}
|
||||
|
||||
const endJob = (abort: AbortController) => {
|
||||
if (jobAbort !== abort) return
|
||||
jobAbort = undefined
|
||||
setState({ job: null })
|
||||
}
|
||||
|
||||
const refreshFromStore = () => {
|
||||
const persisted = readServers()
|
||||
const items: WslServerItem[] = persisted.map((config) => {
|
||||
const existing = state.servers.find((item) => item.config.id === config.id)
|
||||
return {
|
||||
config,
|
||||
runtime: existing?.runtime ?? { kind: "stopped" },
|
||||
}
|
||||
})
|
||||
setState({ servers: items })
|
||||
}
|
||||
|
||||
const setRuntime = (id: string, runtime: WslServerRuntime) => {
|
||||
updateServer(id, (item) => ({ ...item, runtime }))
|
||||
}
|
||||
|
||||
const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => {
|
||||
setState({
|
||||
opencodeChecks: {
|
||||
...state.opencodeChecks,
|
||||
[distro]: check,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => {
|
||||
const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts)
|
||||
const version = resolved
|
||||
? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts)
|
||||
: null
|
||||
return opencodeCheck(distro, resolved, version, appVersion)
|
||||
}
|
||||
|
||||
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
|
||||
setOpencodeCheck(distro, await checkOpencode(distro, opts))
|
||||
}
|
||||
|
||||
const hasServer = (id: string, distro: string) => {
|
||||
return state.servers.some((item) => item.config.id === id && item.config.distro === distro)
|
||||
}
|
||||
|
||||
const refreshOpencodeCheckBackground = (id: string, distro: string) => {
|
||||
void checkOpencode(distro)
|
||||
.then((check) => {
|
||||
if (!hasServer(id, distro)) return
|
||||
setOpencodeCheck(distro, check)
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger?.error("wsl opencode check failed", { id, distro, message })
|
||||
})
|
||||
}
|
||||
|
||||
const refreshOpencodeChecks = async () => {
|
||||
await Promise.all(
|
||||
state.servers.map((item) =>
|
||||
checkOpencode(item.config.distro)
|
||||
.then((check) => {
|
||||
if (!hasServer(item.config.id, item.config.distro)) return
|
||||
setOpencodeCheck(item.config.distro, check)
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger?.error("wsl opencode check failed", {
|
||||
id: item.config.id,
|
||||
distro: item.config.distro,
|
||||
message,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
|
||||
const [installed, online] = await Promise.all([listInstalledWslDistros(opts), listOnlineWslDistros(opts)])
|
||||
return { installed, online }
|
||||
}
|
||||
|
||||
const nextStartAttempt = (id: string) => {
|
||||
const next = (startAttempts.get(id) ?? 0) + 1
|
||||
startAttempts.set(id, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const invalidateStartAttempt = (id: string) => {
|
||||
startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const isCurrentStartAttempt = (id: string, attempt: number) => {
|
||||
return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id)
|
||||
}
|
||||
|
||||
const startServer = async (id: string) => {
|
||||
const item = state.servers.find((x) => x.config.id === id)
|
||||
if (!item) return
|
||||
const attempt = nextStartAttempt(id)
|
||||
await stopServerInternal(id)
|
||||
if (!isCurrentStartAttempt(id, attempt)) return
|
||||
setRuntime(id, { kind: "starting" })
|
||||
logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
|
||||
try {
|
||||
const sidecar = await spawnSidecar(item.config.distro)
|
||||
if (!isCurrentStartAttempt(id, attempt)) {
|
||||
try {
|
||||
sidecar.listener.stop()
|
||||
} catch {
|
||||
// ignore stop errors for stale sidecars
|
||||
}
|
||||
return
|
||||
}
|
||||
sidecars.set(id, sidecar)
|
||||
setRuntime(id, {
|
||||
kind: "ready",
|
||||
url: sidecar.url,
|
||||
username: sidecar.username,
|
||||
password: sidecar.password,
|
||||
})
|
||||
sidecar.listener.onExit((code, signal) => {
|
||||
if (sidecars.get(id) !== sidecar) return
|
||||
sidecars.delete(id)
|
||||
const message = startupFailure(code, signal)
|
||||
setRuntime(id, { kind: "failed", message })
|
||||
logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
|
||||
})
|
||||
refreshOpencodeCheckBackground(id, item.config.distro)
|
||||
logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (!isCurrentStartAttempt(id, attempt)) return
|
||||
setRuntime(id, { kind: "failed", message })
|
||||
// Without this, an Ubuntu-style silent failure leaves no trace in
|
||||
// main.log — the controller captures the message in its state but
|
||||
// nothing surfaces unless the user opens the WSL servers dialog.
|
||||
logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
|
||||
}
|
||||
}
|
||||
|
||||
const stopServerInternal = async (id: string) => {
|
||||
const existing = sidecars.get(id)
|
||||
if (!existing) return
|
||||
sidecars.delete(id)
|
||||
try {
|
||||
existing.listener.stop()
|
||||
} catch {
|
||||
// ignore stop errors
|
||||
}
|
||||
}
|
||||
|
||||
const runJob = async <T>(job: WslJob, runner: (abort: AbortController) => Promise<T>) => {
|
||||
const abort = beginJob(job)
|
||||
try {
|
||||
const value = await runner(abort)
|
||||
endJob(abort)
|
||||
return value
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
endJob(abort)
|
||||
return undefined
|
||||
}
|
||||
const err = error instanceof Error ? error : new Error(String(error))
|
||||
endJob(abort)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getState() {
|
||||
return state
|
||||
},
|
||||
subscribe(listener: (event: WslServersEvent) => void) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
|
||||
async initialize() {
|
||||
refreshFromStore()
|
||||
void refreshOpencodeChecks()
|
||||
for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id)
|
||||
},
|
||||
|
||||
async probeRuntime() {
|
||||
await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => {
|
||||
const runtime = await probeWslRuntime({ signal: abort.signal })
|
||||
setState({
|
||||
runtime,
|
||||
pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false,
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async refreshDistros() {
|
||||
await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => {
|
||||
setState(await refreshDistroLists({ signal: abort.signal }))
|
||||
})
|
||||
},
|
||||
|
||||
async installWsl() {
|
||||
await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => {
|
||||
const result = await installWslRuntimeElevated({ signal: abort.signal })
|
||||
if (result.code !== 0) {
|
||||
const message = summarize(result.stderr || result.stdout) || "WSL installation failed"
|
||||
throw new Error(message)
|
||||
}
|
||||
const runtime = await probeWslRuntime({ signal: abort.signal })
|
||||
setState({ runtime, pendingRestart: pendingRestartAfterWslInstall(runtime) })
|
||||
})
|
||||
},
|
||||
|
||||
async installDistro(name: string) {
|
||||
await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => {
|
||||
const result = await installWslDistro(name, { signal: abort.signal })
|
||||
if (result.code !== 0) {
|
||||
const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}`
|
||||
throw new Error(message)
|
||||
}
|
||||
const distros = await refreshDistroLists({ signal: abort.signal })
|
||||
const probe = await probeWslDistro(name, { signal: abort.signal })
|
||||
setState({
|
||||
...distros,
|
||||
distroProbes: { ...state.distroProbes, [name]: probe },
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async probeDistro(name: string) {
|
||||
await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => {
|
||||
const probe = await probeWslDistro(name, { signal: abort.signal })
|
||||
setState({ distroProbes: { ...state.distroProbes, [name]: probe } })
|
||||
})
|
||||
},
|
||||
|
||||
async probeOpencode(name: string) {
|
||||
await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
|
||||
await refreshOpencodeCheck(name, { signal: abort.signal })
|
||||
})
|
||||
},
|
||||
|
||||
async installOpencode(name: string) {
|
||||
await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
|
||||
const result = await installWslOpencode(appVersion, name, { signal: abort.signal })
|
||||
if (result.code !== 0) {
|
||||
throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed")
|
||||
}
|
||||
await refreshOpencodeCheck(name, { signal: abort.signal })
|
||||
expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, appVersion, name)
|
||||
const id = wslServerIdToRestart(state.servers, name)
|
||||
if (id) await startServer(id)
|
||||
})
|
||||
},
|
||||
|
||||
async openTerminal(name: string) {
|
||||
await openWslTerminal(name)
|
||||
},
|
||||
|
||||
async addServer(distro: string): Promise<WslServerConfig> {
|
||||
const id = wslServerIdForDistro(distro)
|
||||
if (state.servers.some((item) => item.config.id === id)) {
|
||||
throw new Error(`${distro} is already added`)
|
||||
}
|
||||
const config: WslServerConfig = {
|
||||
id,
|
||||
distro,
|
||||
}
|
||||
persistServers([...readServers(), config])
|
||||
setState({
|
||||
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
|
||||
})
|
||||
void startServer(id)
|
||||
return config
|
||||
},
|
||||
|
||||
async removeServer(id: string) {
|
||||
const distro = state.servers.find((item) => item.config.id === id)?.config.distro
|
||||
invalidateStartAttempt(id)
|
||||
await stopServerInternal(id)
|
||||
const remaining = readServers().filter((item) => item.id !== id)
|
||||
persistServers(remaining)
|
||||
setState({
|
||||
servers: state.servers.filter((item) => item.config.id !== id),
|
||||
...(distro ? clearWslDistroState(state.distroProbes, state.opencodeChecks, distro) : {}),
|
||||
})
|
||||
},
|
||||
|
||||
startServer,
|
||||
|
||||
stopAll() {
|
||||
for (const item of state.servers) invalidateStartAttempt(item.config.id)
|
||||
for (const existing of sidecars.values()) {
|
||||
try {
|
||||
existing.listener.stop()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
sidecars.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function initialState(): WslServersState {
|
||||
return {
|
||||
runtime: null,
|
||||
installed: [],
|
||||
online: [],
|
||||
distroProbes: {},
|
||||
opencodeChecks: {},
|
||||
pendingRestart: false,
|
||||
servers: [],
|
||||
job: null,
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedServers(): WslServerConfig[] {
|
||||
const store = getStore()
|
||||
const existing = store.get(WSL_SERVERS_KEY)
|
||||
if (existing && typeof existing === "object") {
|
||||
const record = existing as { servers?: unknown }
|
||||
const list = Array.isArray(record.servers) ? record.servers : []
|
||||
return list.flatMap(normalizePersistedServer)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function writePersistedServers(servers: WslServerConfig[]) {
|
||||
getStore().set(WSL_SERVERS_KEY, { servers })
|
||||
}
|
||||
|
||||
function normalizePersistedServer(value: unknown): WslServerConfig[] {
|
||||
if (!value || typeof value !== "object") return []
|
||||
const record = value as Record<string, unknown>
|
||||
const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
|
||||
if (!distro) return []
|
||||
const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro)
|
||||
return [
|
||||
{
|
||||
id,
|
||||
distro,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function opencodeCheck(
|
||||
distro: string,
|
||||
resolvedPath: string | null,
|
||||
version: string | null,
|
||||
expectedVersion: string,
|
||||
): WslOpencodeCheck {
|
||||
if (!resolvedPath) {
|
||||
return {
|
||||
distro,
|
||||
resolvedPath: null,
|
||||
version: null,
|
||||
expectedVersion,
|
||||
matchesDesktop: null,
|
||||
error: "opencode is not installed in this distro",
|
||||
}
|
||||
}
|
||||
if (!version) {
|
||||
return {
|
||||
distro,
|
||||
resolvedPath,
|
||||
version: null,
|
||||
expectedVersion,
|
||||
matchesDesktop: null,
|
||||
error: "opencode is installed but could not run",
|
||||
}
|
||||
}
|
||||
return {
|
||||
distro,
|
||||
resolvedPath,
|
||||
version,
|
||||
expectedVersion,
|
||||
matchesDesktop: version === expectedVersion,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
function startupFailure(code: number | null, signal: NodeJS.Signals | null) {
|
||||
return `WSL server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})`
|
||||
}
|
||||
|
||||
// Re-export types used by callers
|
||||
export type {
|
||||
WslInstalledDistro,
|
||||
WslOnlineDistro,
|
||||
WslRuntimeCheck,
|
||||
WslDistroProbe,
|
||||
WslOpencodeCheck,
|
||||
WslServerConfig,
|
||||
WslServerItem,
|
||||
WslServerRuntime,
|
||||
WslServersEvent,
|
||||
WslServersState,
|
||||
}
|
||||
129
packages/desktop/src/main/wsl/sidecar.ts
Normal file
129
packages/desktop/src/main/wsl/sidecar.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createServer } from "node:net"
|
||||
import { app } from "electron"
|
||||
import { checkHealth } from "../server"
|
||||
import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./runtime"
|
||||
import { pollWslHealth } from "./startup"
|
||||
|
||||
export type WslSidecar = {
|
||||
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
|
||||
url: string
|
||||
username: string | null
|
||||
password: string
|
||||
}
|
||||
|
||||
export async function spawnWslSidecar(
|
||||
distro: string,
|
||||
opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
|
||||
): Promise<WslSidecar> {
|
||||
const opencode = await resolveWslOpencode(distro)
|
||||
if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`)
|
||||
|
||||
const port = await allocatePort()
|
||||
const password = randomUUID()
|
||||
const username = "opencode"
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
'cd "$HOME" || cd /',
|
||||
'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")',
|
||||
"export PATH",
|
||||
"export WSLENV=",
|
||||
"export OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true",
|
||||
"export OPENCODE_CLIENT=desktop",
|
||||
`export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`,
|
||||
`export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,
|
||||
'export XDG_STATE_HOME="$HOME/.local/state"',
|
||||
`exec ${shellEscape(opencode)} --print-logs --log-level ${app.isPackaged ? "WARN" : "INFO"} serve --hostname 0.0.0.0 --port ${port}`,
|
||||
].join("\n")
|
||||
const child = spawn("wsl", wslArgs(["bash", "-se"], distro), {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
})
|
||||
child.stdin.end(script)
|
||||
|
||||
const recentOutput: string[] = []
|
||||
const emit = (line: WslCommandLine) => {
|
||||
if (!line.text.trim()) return
|
||||
recentOutput.push(`[${line.stream}] ${line.text}`)
|
||||
if (recentOutput.length > 12) recentOutput.shift()
|
||||
opts.onLine?.(line)
|
||||
}
|
||||
forwardLines(child.stdout, "stdout", emit)
|
||||
forwardLines(child.stderr, "stderr", emit)
|
||||
|
||||
const exit = new Promise<never>((_, reject) => {
|
||||
child.once("error", reject)
|
||||
child.once("exit", (code, signal) => reject(new Error(startupFailure(code, signal, recentOutput))))
|
||||
})
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
const startup = new AbortController()
|
||||
const health = pollWslHealth(() => checkHealth(url, password), startup.signal)
|
||||
const timeoutMs = opts.healthTimeoutMs ?? 30_000
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
const timedOut = new Promise<never>(
|
||||
(_, reject) =>
|
||||
(timeout = setTimeout(
|
||||
() => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
)),
|
||||
)
|
||||
|
||||
await Promise.race([health, exit, timedOut])
|
||||
.catch((error) => {
|
||||
child.kill()
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timeout)
|
||||
startup.abort()
|
||||
})
|
||||
return {
|
||||
listener: {
|
||||
stop: () => child.kill(),
|
||||
onExit: (cb) => child.once("exit", cb),
|
||||
},
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
function allocatePort() {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.on("error", reject)
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
if (typeof address !== "object" || !address) {
|
||||
server.close()
|
||||
reject(new Error("Failed to get port"))
|
||||
return
|
||||
}
|
||||
server.close(() => resolve(address.port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function forwardLines(
|
||||
stream: NodeJS.ReadableStream,
|
||||
source: WslCommandLine["stream"],
|
||||
onLine: (line: WslCommandLine) => void,
|
||||
) {
|
||||
let pending = ""
|
||||
stream.setEncoding("utf8")
|
||||
stream.on("data", (chunk: string) => {
|
||||
pending += chunk
|
||||
const lines = pending.split(/\r?\n/g)
|
||||
pending = lines.pop() ?? ""
|
||||
lines.forEach((text) => onLine({ stream: source, text }))
|
||||
})
|
||||
stream.on("end", () => {
|
||||
if (pending) onLine({ stream: source, text: pending })
|
||||
})
|
||||
}
|
||||
|
||||
function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) {
|
||||
const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : ""
|
||||
return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}`
|
||||
}
|
||||
31
packages/desktop/src/main/wsl/startup.ts
Normal file
31
packages/desktop/src/main/wsl/startup.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export function wslServerIdsToStartOnInitialize(servers: { id: string }[]) {
|
||||
return servers.map((server) => server.id)
|
||||
}
|
||||
|
||||
export function expectOpencodeVersion(installed: string | null, expected: string, distro = "Debian") {
|
||||
if (installed === expected) return
|
||||
throw new Error(
|
||||
`OpenCode update finished but ${distro} still reports ${installed ?? "no version"}; expected ${expected}`,
|
||||
)
|
||||
}
|
||||
|
||||
export const pendingRestartAfterWslInstall = (runtime: { available: boolean }) => !runtime.available
|
||||
|
||||
export async function pollWslHealth(check: () => Promise<boolean>, signal: AbortSignal, interval = 100) {
|
||||
while (!signal.aborted) {
|
||||
if (await check()) return
|
||||
await abortableDelay(interval, signal)
|
||||
}
|
||||
}
|
||||
|
||||
function abortableDelay(duration: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const done = () => {
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
const timeout = setTimeout(done, duration)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user