From 230affe4351cce59e06fa7b9fcf9805a4b875cb1 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 8 May 2026 11:36:50 +1000 Subject: [PATCH 1/5] feat(desktop): add export logs --- bun.lock | 1 + packages/app/src/app.tsx | 1 + packages/app/src/pages/layout.tsx | 12 +++ packages/desktop/package.json | 1 + packages/desktop/src/main/index.ts | 41 +++++++- packages/desktop/src/main/ipc.ts | 2 + packages/desktop/src/main/logging.ts | 141 ++++++++++++++++++++++++- packages/desktop/src/main/menu.ts | 6 ++ packages/desktop/src/main/windows.ts | 142 +++++++++++++++++++++++++- packages/desktop/src/preload/index.ts | 1 + packages/desktop/src/preload/types.ts | 1 + 11 files changed, 337 insertions(+), 12 deletions(-) diff --git a/bun.lock b/bun.lock index 2f21ed7d54..4baab92d6d 100644 --- a/bun.lock +++ b/bun.lock @@ -230,6 +230,7 @@ "name": "@opencode-ai/desktop", "version": "1.14.41", "dependencies": { + "@zip.js/zip.js": "2.7.62", "drizzle-orm": "catalog:", "effect": "catalog:", "electron-context-menu": "4.1.2", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 3189d80257..f983ace80c 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -78,6 +78,7 @@ declare global { } api?: { setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise + exportDebugLogs?: () => Promise } } } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index a08372649f..27656a33db 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1059,6 +1059,18 @@ export default function Layout(props: ParentProps) { keybind: "mod+comma", onSelect: () => openSettings(), }, + ...(window.api?.exportDebugLogs + ? [ + { + id: "logs.export", + title: "Export logs", + category: language.t("command.category.settings"), + onSelect: () => { + void window.api?.exportDebugLogs?.() + }, + }, + ] + : []), { id: "session.previous", title: language.t("command.session.previous"), diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 49e35c5db8..4ef3d06994 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -24,6 +24,7 @@ }, "main": "./out/main/index.js", "dependencies": { + "@zip.js/zip.js": "2.7.62", "effect": "catalog:", "electron-context-menu": "4.1.2", "electron-log": "^5", diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 52e45a702c..6e393ea360 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -38,13 +38,14 @@ app.setAppUserModelId(appId) app.setPath("userData", onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId)) if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) const logger = initLogging() +initCrashReporter() const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" import { CHANNEL, UPDATER_ENABLED } from "./constants" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" -import { initLogging } from "./logging" +import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" import { @@ -60,6 +61,7 @@ import { createLoadingWindow, createMainWindow, registerRendererProtocol, + setRelaunchHandler, setBackgroundColor, setDockIcon, } from "./windows" @@ -138,6 +140,23 @@ function setupApp() { void killSidecar() }) + app.on("child-process-gone", (_event, details) => { + logger.error("child process gone", details) + writeLog("utility", "child process gone", { details }) + }) + + app.on("render-process-gone", (_event, webContents, details) => { + logger.error("render process gone", { url: webContents.getURL(), details }) + writeLog("window", "app render process gone", { url: webContents.getURL(), details }) + }) + + setRelaunchHandler(() => { + void killSidecar().finally(() => { + app.relaunch() + app.exit(0) + }) + }) + for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { void killSidecar().finally(() => app.exit(0)) @@ -150,6 +169,7 @@ function setupApp() { registerRendererProtocol() setDockIcon() setupAutoUpdater() + await startNetLog().catch((error) => logger.warn("failed to start net log", error)) await initialize() }) } @@ -220,9 +240,18 @@ async function initialize() { needsMigration, userDataPath: app.getPath("userData"), onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), - onStdout: (message) => logger.log("sidecar stdout", { message }), - onStderr: (message) => logger.warn("sidecar stderr", { message }), - onExit: (code) => logger.warn("sidecar exited", { code }), + onStdout: (message) => { + logger.log("sidecar stdout", { message }) + writeLog("server", "stdout", { message }) + }, + onStderr: (message) => { + logger.warn("sidecar stderr", { message }) + writeLog("server", "stderr", { message }) + }, + onExit: (code) => { + logger.warn("sidecar exited", { code }) + writeLog("utility", "sidecar exited", { code }) + }, }, ) server = listener @@ -279,6 +308,9 @@ function wireMenu() { app.exit(0) }) }, + exportDebugLogs: () => { + void exportDebugLogs().catch((error) => logger.error("failed to export debug logs", error)) + }, }) } @@ -314,6 +346,7 @@ registerIpcHandlers({ checkUpdate: async () => checkUpdate(), installUpdate: async () => installUpdate(), setBackgroundColor: (color) => setBackgroundColor(color), + exportDebugLogs: () => exportDebugLogs(), }) async function killSidecar() { diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index dbcd4239dc..e714184200 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -38,6 +38,7 @@ type Deps = { checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }> installUpdate: () => Promise | void setBackgroundColor: (color: string) => void + exportDebugLogs: () => Promise } export function registerIpcHandlers(deps: Deps) { @@ -69,6 +70,7 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("check-update", () => deps.checkUpdate()) ipcMain.handle("install-update", () => deps.installUpdate()) ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) + ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs()) ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { try { const store = getStore(name) diff --git a/packages/desktop/src/main/logging.ts b/packages/desktop/src/main/logging.ts index 1f1c5e54e3..276dba0b6a 100644 --- a/packages/desktop/src/main/logging.ts +++ b/packages/desktop/src/main/logging.ts @@ -1,17 +1,73 @@ import log from "electron-log/main.js" -import { readFileSync, readdirSync, statSync, unlinkSync } from "node:fs" +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 export function initLogging() { + initRunDirectory() log.transports.file.maxSize = 5 * 1024 * 1024 + log.transports.file.resolvePathFn = (_vars, message) => join(run, `${safeLogName(message?.scope ?? "main")}.log`) initConsoleTransport() cleanup() return 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) { + if (!run) return + log.scope(safeLogName(name)).info(message, extra ?? {}) +} + export function tail(): string { try { const path = log.transports.file.getFile().path @@ -23,23 +79,98 @@ export function tail(): string { } } +export function runDirectory() { + return run +} + +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 path = log.transports.file.getFile().path - const dir = dirname(path) + 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.isFile()) continue - if (info.mtimeMs < cutoff) unlinkSync(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) { + await writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(entry.data ?? readFileSync(entry.path!))]))) + } + writeFileSync(output, Buffer.from(await (await writer.close()).arrayBuffer())) +} + function initConsoleTransport() { const write = log.transports.console.writeFn.bind(log.transports.console) log.transports.console.writeFn = (options) => { diff --git a/packages/desktop/src/main/menu.ts b/packages/desktop/src/main/menu.ts index 2d5a900f39..b913d07a84 100644 --- a/packages/desktop/src/main/menu.ts +++ b/packages/desktop/src/main/menu.ts @@ -8,6 +8,7 @@ type Deps = { checkForUpdates: () => void reload: () => void relaunch: () => void + exportDebugLogs: () => void } export function createMenu(deps: Deps) { @@ -36,6 +37,10 @@ export function createMenu(deps: Deps) { label: "Restart", click: () => deps.relaunch(), }, + { + label: "Export Logs...", + click: () => deps.exportDebugLogs(), + }, { type: "separator" }, { role: "hide" }, { role: "hideOthers" }, @@ -122,6 +127,7 @@ export function createMenu(deps: Deps) { submenu: [ { label: "OpenCode Documentation", click: () => shell.openExternal("https://opencode.ai/docs") }, { label: "Support Forum", click: () => shell.openExternal("https://discord.com/invite/opencode") }, + { label: "Export Logs...", click: () => deps.exportDebugLogs() }, { type: "separator" }, { type: "separator" }, { diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index 41abfc784d..6646b9dbfe 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -1,8 +1,10 @@ import windowState from "electron-window-state" -import { app, BrowserWindow, net, nativeImage, nativeTheme, protocol } from "electron" +import log from "electron-log/main.js" +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" const root = dirname(fileURLToPath(import.meta.url)) const rendererRoot = join(root, "../renderer") @@ -22,9 +24,17 @@ protocol.registerSchemesAsPrivileged([ ]) let backgroundColor: string | undefined +let relaunchHandler = () => { + app.relaunch() + app.exit(0) +} const titlebarThemes = new WeakMap>() const titlebarHeight = 40 +export function setRelaunchHandler(handler: () => void) { + relaunchHandler = handler +} + export function setBackgroundColor(color: string) { backgroundColor = color } @@ -109,6 +119,7 @@ export function createMainWindow() { }) allowClipboardWrite(win) + wireWindowRecovery(win, "main") win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => { const { requestHeaders } = details @@ -161,6 +172,7 @@ export function createLoadingWindow() { }) allowClipboardWrite(win) + wireWindowRecovery(win, "loading") loadWindow(win, "loading.html") @@ -170,19 +182,44 @@ export function createLoadingWindow() { export function registerRendererProtocol() { if (protocol.isProtocolHandled(rendererProtocol)) return - protocol.handle(rendererProtocol, (request) => { + protocol.handle(rendererProtocol, async (request) => { const url = new URL(request.url) if (url.host !== rendererHost) { + log.warn("renderer protocol rejected host", { url: request.url }) + writeLog("protocol", "rejected host", { url: request.url }) return new Response("Not found", { status: 404 }) } const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`) const rel = relative(rendererRoot, file) if (rel.startsWith("..") || isAbsolute(rel)) { + log.warn("renderer protocol rejected path", { url: request.url, file }) + writeLog("protocol", "rejected path", { url: request.url, file }) return new Response("Not found", { status: 404 }) } - return net.fetch(pathToFileURL(file).toString()) + try { + const response = await net.fetch(pathToFileURL(file).toString()) + if (response.status >= 400) { + log.error("renderer protocol fetch failed", { + url: request.url, + file, + status: response.status, + statusText: response.statusText, + }) + writeLog("protocol", "fetch failed", { + url: request.url, + file, + status: response.status, + statusText: response.statusText, + }) + } + return response + } catch (error) { + log.error("renderer protocol fetch error", { url: request.url, file, error }) + writeLog("protocol", "fetch error", { url: request.url, file, error }) + return new Response("Not found", { status: 404 }) + } }) } @@ -197,6 +234,105 @@ function loadWindow(win: BrowserWindow, html: string) { void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`) } +function wireWindowRecovery(win: BrowserWindow, name: string) { + let showing = 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 (result.response === 1) { + await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error })) + continue + } + if (result.response === 0) relaunchHandler() + if (!wait && result.response === 2) app.quit() + return + } + } finally { + showing = false + } + } + + const failed = ( + event: string, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean, + ) => { + log.error("renderer load failed", { + window: name, + event, + errorCode, + errorDescription, + validatedURL, + currentURL: win.webContents.getURL(), + isMainFrame, + }) + writeLog("window", "renderer load failed", { + window: name, + event, + errorCode, + errorDescription, + validatedURL, + currentURL: win.webContents.getURL(), + isMainFrame, + }) + + 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) => { + log.error("renderer process gone", { window: name, currentURL: win.webContents.getURL(), details }) + writeLog("window", "renderer process gone", { window: name, currentURL: win.webContents.getURL(), details }) + void show( + "OpenCode window terminated unexpectedly", + [`Window: ${name}`, `Reason: ${details.reason}`, `Code: ${details.exitCode ?? ""}`].join("\n"), + false, + ) + }) + win.on("unresponsive", () => { + log.error("renderer unresponsive", { window: name, currentURL: win.webContents.getURL() }) + writeLog("window", "renderer unresponsive", { window: name, currentURL: win.webContents.getURL() }) + void show("OpenCode is not responding", "You can relaunch the app, open the logs, or keep waiting.", true) + }) + win.on("responsive", () => { + log.error("renderer responsive", { window: name, currentURL: win.webContents.getURL() }) + writeLog("window", "renderer responsive", { window: name, currentURL: win.webContents.getURL() }) + }) + win.webContents.on("console-message", (_event, level, message, line, sourceId) => { + writeLog("renderer", "console", { window: name, 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 }) + }) +} + function allowClipboardWrite(win: BrowserWindow) { win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => { callback( diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 6261419ca5..6b500bf67a 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -66,6 +66,7 @@ const api: ElectronAPI = { checkUpdate: () => ipcRenderer.invoke("check-update"), installUpdate: () => ipcRenderer.invoke("install-update"), setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color), + exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"), } contextBridge.exposeInMainWorld("api", api) diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 6e22954d18..4041671304 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -76,4 +76,5 @@ export type ElectronAPI = { checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }> installUpdate: () => Promise setBackgroundColor: (color: string) => Promise + exportDebugLogs: () => Promise } From 7ad767144d2e247cd2768d952dc8de5693826cb5 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 8 May 2026 11:43:34 +1000 Subject: [PATCH 2/5] fix(desktop): capture renderer console logs --- packages/desktop/src/main/logging.ts | 7 ++++++- packages/desktop/src/main/windows.ts | 1 - 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src/main/logging.ts b/packages/desktop/src/main/logging.ts index 276dba0b6a..737fbf9a65 100644 --- a/packages/desktop/src/main/logging.ts +++ b/packages/desktop/src/main/logging.ts @@ -18,7 +18,12 @@ let netLogPath: string | undefined export function initLogging() { initRunDirectory() log.transports.file.maxSize = 5 * 1024 * 1024 - log.transports.file.resolvePathFn = (_vars, message) => join(run, `${safeLogName(message?.scope ?? "main")}.log`) + 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 log diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index 6646b9dbfe..f9a575f5af 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -323,7 +323,6 @@ function wireWindowRecovery(win: BrowserWindow, name: string) { writeLog("window", "renderer responsive", { window: name, currentURL: win.webContents.getURL() }) }) win.webContents.on("console-message", (_event, level, message, line, sourceId) => { - writeLog("renderer", "console", { window: name, level, message, line, sourceId }) if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) { writeLog("pty", "console", { window: name, level, message, line, sourceId }) } From 0862c31ee5fe645da294baaece0f4b9695b0ca4b Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 8 May 2026 13:26:52 +1000 Subject: [PATCH 3/5] feat(desktop): log unresponsive renderer stacks --- packages/desktop/src/main/index.ts | 3 + packages/desktop/src/main/logging.ts | 7 ++- packages/desktop/src/main/unresponsive.ts | 71 +++++++++++++++++++++++ packages/desktop/src/main/windows.ts | 38 +++++++++++- 4 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 packages/desktop/src/main/unresponsive.ts diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 6e393ea360..3668d0ccfe 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -31,6 +31,7 @@ const APP_IDS: Record = { prod: "ai.opencode.desktop", } const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" +const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports" const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" const onboardingTestRoot = setupOnboardingTestEnv() app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") @@ -108,6 +109,8 @@ function setupApp() { 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()) { diff --git a/packages/desktop/src/main/logging.ts b/packages/desktop/src/main/logging.ts index 737fbf9a65..5daedffeba 100644 --- a/packages/desktop/src/main/logging.ts +++ b/packages/desktop/src/main/logging.ts @@ -70,7 +70,12 @@ export async function exportDebugLogs() { export function write(name: string, message: string, extra?: Record) { if (!run) return - log.scope(safeLogName(name)).info(message, extra ?? {}) + const scoped = log.scope(safeLogName(name)) + if (extra !== undefined) { + scoped.info(message, extra) + return + } + scoped.info(message) } export function tail(): string { diff --git a/packages/desktop/src/main/unresponsive.ts b/packages/desktop/src/main/unresponsive.ts new file mode 100644 index 0000000000..cd5dd06a42 --- /dev/null +++ b/packages/desktop/src/main/unresponsive.ts @@ -0,0 +1,71 @@ +import type { BrowserWindow } from "electron" +import log from "electron-log/main.js" +import { write as writeLog } from "./logging" + +const sampleInterval = 1000 +const samplePeriod = 15000 + +export function createUnresponsiveSampler(win: BrowserWindow, name: string) { + let sampleTimer: ReturnType | undefined + let stopTimer: ReturnType | undefined + let sampling = false + const samples = new Map() + + 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 }) + return undefined + }) + if (!active()) return + if (stack) samples.set(stack, (samples.get(stack) ?? 0) + 1) + schedule() + } + + const stop = () => { + 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() ? "" : win.webContents.getURL()}`, + ...entries.map((entry) => `<${entry[1]}> ${entry[0]}`), + `Total Samples: ${total}`, + ].join("\n") + log.error(message) + writeLog("window", message) + samples.clear() + return wasSampling + } + + const start = () => { + if (sampling || win.isDestroyed() || win.webContents.isDestroyed() || win.webContents.isDevToolsOpened()) return + sampling = true + samples.clear() + schedule() + stopTimer = setTimeout(stop, samplePeriod) + } + + win.on("closed", stop) + + return { start, stop } +} diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index f9a575f5af..c6abb7cc64 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -5,12 +5,15 @@ 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 { 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 documentPolicyHeader = "Document-Policy" +const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports" protocol.registerSchemesAsPrivileged([ { @@ -131,6 +134,7 @@ export function createMainWindow() { const { responseHeaders = {} } = details upsertKeyValue(responseHeaders, "Access-Control-Allow-Origin", ["*"]) upsertKeyValue(responseHeaders, "Access-Control-Allow-Headers", ["*"]) + if (isRendererDocumentUrl(details.url)) upsertKeyValue(responseHeaders, documentPolicyHeader, [jsCallStacksDocumentPolicy]) callback({ responseHeaders }) }) @@ -214,7 +218,7 @@ export function registerRendererProtocol() { statusText: response.statusText, }) } - return response + return withDocumentPolicy(response, file) } catch (error) { log.error("renderer protocol fetch error", { url: request.url, file, error }) writeLog("protocol", "fetch error", { url: request.url, file, error }) @@ -236,6 +240,7 @@ function loadWindow(win: BrowserWindow, html: string) { function wireWindowRecovery(win: BrowserWindow, name: string) { let showing = false + const sampler = createUnresponsiveSampler(win, name) const show = async (message: string, detail: string, wait: boolean) => { if (showing || win.isDestroyed()) return @@ -252,11 +257,19 @@ function wireWindowRecovery(win: BrowserWindow, name: string) { detail, }) if (result.response === 1) { + const sampling = sampler.stop() await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error })) + if (wait && sampling) sampler.start() continue } - if (result.response === 0) relaunchHandler() - if (!wait && result.response === 2) app.quit() + if (result.response === 0) { + sampler.stop() + relaunchHandler() + } + if (!wait && result.response === 2) { + sampler.stop() + app.quit() + } return } } finally { @@ -305,6 +318,7 @@ function wireWindowRecovery(win: BrowserWindow, name: string) { failed("did-fail-provisional-load", errorCode, errorDescription, validatedURL, isMainFrame) }) win.webContents.on("render-process-gone", (_event, details) => { + sampler.stop() log.error("renderer process gone", { window: name, currentURL: win.webContents.getURL(), details }) writeLog("window", "renderer process gone", { window: name, currentURL: win.webContents.getURL(), details }) void show( @@ -316,11 +330,13 @@ function wireWindowRecovery(win: BrowserWindow, name: string) { win.on("unresponsive", () => { log.error("renderer unresponsive", { window: name, currentURL: win.webContents.getURL() }) writeLog("window", "renderer unresponsive", { window: name, currentURL: win.webContents.getURL() }) + sampler.start() void show("OpenCode is not responding", "You can relaunch the app, open the logs, or keep waiting.", true) }) win.on("responsive", () => { log.error("renderer responsive", { window: name, currentURL: win.webContents.getURL() }) writeLog("window", "renderer responsive", { window: name, currentURL: win.webContents.getURL() }) + sampler.stop() }) win.webContents.on("console-message", (_event, level, message, line, sourceId) => { if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) { @@ -332,6 +348,13 @@ function wireWindowRecovery(win: BrowserWindow, name: string) { }) } +function withDocumentPolicy(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 allowClipboardWrite(win: BrowserWindow) { win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => { callback( @@ -356,6 +379,15 @@ function isTrustedRendererUrl(value?: string) { return url.origin === new URL(devUrl).origin } +function isRendererDocumentUrl(value: string) { + if (!URL.canParse(value)) return false + const url = new URL(value) + if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost && url.pathname.endsWith(".html")) return true + const devUrl = process.env.ELECTRON_RENDERER_URL + if (!devUrl || !URL.canParse(devUrl)) return false + return url.origin === new URL(devUrl).origin && url.pathname.endsWith(".html") +} + function wireZoom(win: BrowserWindow) { win.webContents.setZoomFactor(1) win.webContents.on("zoom-changed", () => { From f6363f1f434e743708b60c0994a4e06011db3fa0 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 8 May 2026 15:09:16 +1000 Subject: [PATCH 4/5] refactor(desktop): simplify log diagnostics --- packages/desktop/src/main/index.ts | 21 +---- packages/desktop/src/main/logging.ts | 21 +++-- packages/desktop/src/main/unresponsive.ts | 14 ++- packages/desktop/src/main/windows.ts | 106 +++++++++------------- 4 files changed, 68 insertions(+), 94 deletions(-) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 3668d0ccfe..99aba38b1b 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -144,13 +144,11 @@ function setupApp() { }) app.on("child-process-gone", (_event, details) => { - logger.error("child process gone", details) - writeLog("utility", "child process gone", { details }) + writeLog("utility", "child process gone", { details }, "error") }) app.on("render-process-gone", (_event, webContents, details) => { - logger.error("render process gone", { url: webContents.getURL(), details }) - writeLog("window", "app render process gone", { url: webContents.getURL(), details }) + writeLog("window", "app render process gone", { url: webContents.getURL(), details }, "error") }) setRelaunchHandler(() => { @@ -243,18 +241,9 @@ async function initialize() { needsMigration, userDataPath: app.getPath("userData"), onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), - onStdout: (message) => { - logger.log("sidecar stdout", { message }) - writeLog("server", "stdout", { message }) - }, - onStderr: (message) => { - logger.warn("sidecar stderr", { message }) - writeLog("server", "stderr", { message }) - }, - onExit: (code) => { - logger.warn("sidecar exited", { code }) - writeLog("utility", "sidecar exited", { code }) - }, + onStdout: (message) => writeLog("server", "stdout", { message }), + onStderr: (message) => writeLog("server", "stderr", { message }, "warn"), + onExit: (code) => writeLog("utility", "sidecar exited", { code }, "warn"), }, ) server = listener diff --git a/packages/desktop/src/main/logging.ts b/packages/desktop/src/main/logging.ts index 5daedffeba..f25438891f 100644 --- a/packages/desktop/src/main/logging.ts +++ b/packages/desktop/src/main/logging.ts @@ -68,14 +68,19 @@ export async function exportDebugLogs() { } } -export function write(name: string, message: string, extra?: Record) { +export function write( + name: string, + message: string, + extra?: Record, + level: "info" | "warn" | "error" = "info", +) { if (!run) return const scoped = log.scope(safeLogName(name)) if (extra !== undefined) { - scoped.info(message, extra) + scoped[level](message, extra) return } - scoped.info(message) + scoped[level](message) } export function tail(): string { @@ -89,10 +94,6 @@ export function tail(): string { } } -export function runDirectory() { - return run -} - function initRunDirectory() { root = join(app.getPath("userData"), "logs") run = join(root, stamp()) @@ -176,9 +177,11 @@ function collect(dir: string, prefix: string): Entry[] { async function writeZip(output: string, entries: Entry[]) { const writer = new ZipWriter(new BlobWriter("application/zip")) for (const entry of entries) { - await writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(entry.data ?? readFileSync(entry.path!))]))) + const data = entry.data ?? readFileSync(entry.path!) + await writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(data)]))) } - writeFileSync(output, Buffer.from(await (await writer.close()).arrayBuffer())) + const zip = await writer.close() + writeFileSync(output, Buffer.from(await zip.arrayBuffer())) } function initConsoleTransport() { diff --git a/packages/desktop/src/main/unresponsive.ts b/packages/desktop/src/main/unresponsive.ts index cd5dd06a42..1cc0924c3a 100644 --- a/packages/desktop/src/main/unresponsive.ts +++ b/packages/desktop/src/main/unresponsive.ts @@ -1,5 +1,4 @@ import type { BrowserWindow } from "electron" -import log from "electron-log/main.js" import { write as writeLog } from "./logging" const sampleInterval = 1000 @@ -28,7 +27,7 @@ export function createUnresponsiveSampler(win: BrowserWindow, name: string) { 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 }) + writeLog("window", "failed to collect unresponsive sample", { window: name, error }, "error") return undefined }) if (!active()) return @@ -36,7 +35,7 @@ export function createUnresponsiveSampler(win: BrowserWindow, name: string) { schedule() } - const stop = () => { + const stopAndFlush = () => { const wasSampling = sampling sampling = false clearTimers() @@ -51,8 +50,7 @@ export function createUnresponsiveSampler(win: BrowserWindow, name: string) { ...entries.map((entry) => `<${entry[1]}> ${entry[0]}`), `Total Samples: ${total}`, ].join("\n") - log.error(message) - writeLog("window", message) + writeLog("window", message, undefined, "error") samples.clear() return wasSampling } @@ -62,10 +60,10 @@ export function createUnresponsiveSampler(win: BrowserWindow, name: string) { sampling = true samples.clear() schedule() - stopTimer = setTimeout(stop, samplePeriod) + stopTimer = setTimeout(stopAndFlush, samplePeriod) } - win.on("closed", stop) + win.on("closed", stopAndFlush) - return { start, stop } + return { start, stopAndFlush } } diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index c6abb7cc64..8991cfb326 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -1,5 +1,4 @@ import windowState from "electron-window-state" -import log from "electron-log/main.js" 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" @@ -132,9 +131,7 @@ export function createMainWindow() { win.webContents.session.webRequest.onHeadersReceived((details, callback) => { const { responseHeaders = {} } = details - upsertKeyValue(responseHeaders, "Access-Control-Allow-Origin", ["*"]) - upsertKeyValue(responseHeaders, "Access-Control-Allow-Headers", ["*"]) - if (isRendererDocumentUrl(details.url)) upsertKeyValue(responseHeaders, documentPolicyHeader, [jsCallStacksDocumentPolicy]) + addRendererHeaders(details.url, responseHeaders) callback({ responseHeaders }) }) @@ -189,39 +186,30 @@ export function registerRendererProtocol() { protocol.handle(rendererProtocol, async (request) => { const url = new URL(request.url) if (url.host !== rendererHost) { - log.warn("renderer protocol rejected host", { url: request.url }) - writeLog("protocol", "rejected host", { url: request.url }) + 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)) { - log.warn("renderer protocol rejected path", { url: request.url, file }) - writeLog("protocol", "rejected path", { url: request.url, file }) + 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) { - log.error("renderer protocol fetch failed", { - url: request.url, - file, - status: response.status, - statusText: response.statusText, - }) writeLog("protocol", "fetch failed", { url: request.url, file, status: response.status, statusText: response.statusText, - }) + }, "error") } - return withDocumentPolicy(response, file) + return addDocumentPolicy(response, file) } catch (error) { - log.error("renderer protocol fetch error", { url: request.url, file, error }) - writeLog("protocol", "fetch error", { url: request.url, file, error }) + writeLog("protocol", "fetch error", { url: request.url, file, error }, "error") return new Response("Not found", { status: 404 }) } }) @@ -242,6 +230,25 @@ 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 @@ -256,20 +263,7 @@ function wireWindowRecovery(win: BrowserWindow, name: string) { message, detail, }) - if (result.response === 1) { - const sampling = sampler.stop() - await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error })) - if (wait && sampling) sampler.start() - continue - } - if (result.response === 0) { - sampler.stop() - relaunchHandler() - } - if (!wait && result.response === 2) { - sampler.stop() - app.quit() - } + if (await handle(buttons[result.response], wait)) continue return } } finally { @@ -284,15 +278,6 @@ function wireWindowRecovery(win: BrowserWindow, name: string) { validatedURL: string, isMainFrame: boolean, ) => { - log.error("renderer load failed", { - window: name, - event, - errorCode, - errorDescription, - validatedURL, - currentURL: win.webContents.getURL(), - isMainFrame, - }) writeLog("window", "renderer load failed", { window: name, event, @@ -301,7 +286,7 @@ function wireWindowRecovery(win: BrowserWindow, name: string) { validatedURL, currentURL: win.webContents.getURL(), isMainFrame, - }) + }, "error") if (!isMainFrame || errorCode === -3) return void show( @@ -318,9 +303,8 @@ function wireWindowRecovery(win: BrowserWindow, name: string) { failed("did-fail-provisional-load", errorCode, errorDescription, validatedURL, isMainFrame) }) win.webContents.on("render-process-gone", (_event, details) => { - sampler.stop() - log.error("renderer process gone", { window: name, currentURL: win.webContents.getURL(), details }) - writeLog("window", "renderer process gone", { window: name, currentURL: win.webContents.getURL(), 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 ?? ""}`].join("\n"), @@ -328,15 +312,13 @@ function wireWindowRecovery(win: BrowserWindow, name: string) { ) }) win.on("unresponsive", () => { - log.error("renderer unresponsive", { window: name, currentURL: win.webContents.getURL() }) - writeLog("window", "renderer unresponsive", { window: name, currentURL: win.webContents.getURL() }) + 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", () => { - log.error("renderer responsive", { window: name, currentURL: win.webContents.getURL() }) - writeLog("window", "renderer responsive", { window: name, currentURL: win.webContents.getURL() }) - sampler.stop() + 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")) { @@ -344,11 +326,11 @@ function wireWindowRecovery(win: BrowserWindow, name: string) { } }) win.webContents.on("preload-error", (_event, preloadPath, error) => { - writeLog("preload", "preload error", { window: name, preloadPath, error }) + writeLog("preload", "preload error", { window: name, preloadPath, error }, "error") }) } -function withDocumentPolicy(response: Response, file: string) { +function addDocumentPolicy(response: Response, file: string) { if (!file.toLowerCase().endsWith(".html")) return response const headers = new Headers(response.headers) headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy) @@ -371,23 +353,25 @@ function allowClipboardWrite(win: BrowserWindow) { } function isTrustedRendererUrl(value?: string) { + return isRendererUrl(value) +} + +function addRendererHeaders(value: string, headers: Record) { + 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 isRendererDocumentUrl(value: string) { - if (!URL.canParse(value)) return false - const url = new URL(value) - if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost && url.pathname.endsWith(".html")) return true - const devUrl = process.env.ELECTRON_RENDERER_URL - if (!devUrl || !URL.canParse(devUrl)) return false - return url.origin === new URL(devUrl).origin && url.pathname.endsWith(".html") -} - function wireZoom(win: BrowserWindow) { win.webContents.setZoomFactor(1) win.webContents.on("zoom-changed", () => { From 21887b22d9a7492021974a5741b6a09b5fc7b2f4 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 15 May 2026 12:48:37 +1000 Subject: [PATCH 5/5] fix(desktop): include fatal errors in log exports --- packages/app/src/context/platform.tsx | 20 +++++++++++-- packages/app/src/i18n/en.ts | 1 + packages/app/src/index.ts | 2 +- packages/app/src/pages/error.tsx | 38 +++++++++++++++++++++++-- packages/app/src/pages/layout.tsx | 4 +-- packages/desktop/src/main/index.ts | 1 + packages/desktop/src/main/ipc.ts | 5 ++++ packages/desktop/src/preload/index.ts | 1 + packages/desktop/src/preload/types.ts | 9 ++++++ packages/desktop/src/renderer/index.tsx | 4 +++ 10 files changed, 78 insertions(+), 7 deletions(-) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index fd89bf51ba..4e23ee1f53 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -8,13 +8,23 @@ type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] } type SaveFilePickerOptions = { title?: string; defaultPath?: string } type UpdateInfo = { updateAvailable: boolean; version?: string } +type PlatformName = "web" | "desktop" +type DesktopOS = "macos" | "windows" | "linux" + +export type FatalRendererErrorLog = { + error: string + url: string + version?: string + platform: PlatformName + os?: DesktopOS +} export type Platform = { /** Platform discriminator */ - platform: "web" | "desktop" + platform: PlatformName /** Desktop OS (Tauri only) */ - os?: "macos" | "windows" | "linux" + os?: DesktopOS /** App version */ version?: string @@ -87,6 +97,12 @@ export type Platform = { /** Read image from clipboard (desktop only) */ readClipboardImage?(): Promise + + /** Export collected diagnostic logs (desktop only) */ + exportDebugLogs?(): Promise + + /** Record a fatal renderer error in platform logs (desktop only) */ + recordFatalRendererError?(error: FatalRendererErrorLog): Promise } export type DisplayBackend = "auto" | "wayland" diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 250d26edbe..ce51210483 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -467,6 +467,7 @@ export const dict = { "error.page.action.restart": "Restart", "error.page.action.report": "Report Error", "error.page.action.reported": "Error Reported", + "error.page.action.exportLogs": "Export Logs", "error.page.action.checking": "Checking...", "error.page.action.checkUpdates": "Check for updates", "error.page.action.updateTo": "Update to {{version}}", diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index d80e9fffb0..57365ca7ca 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -2,6 +2,6 @@ export { AppBaseProviders, AppInterface } from "./app" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" -export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" +export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform" export { ServerConnection } from "./context/server" export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 5f3d7baa68..f9b65bb943 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -2,7 +2,7 @@ import { TextField } from "@opencode-ai/ui/text-field" import * as Sentry from "@sentry/solid" import { Logo } from "@opencode-ai/ui/logo" import { Button } from "@opencode-ai/ui/button" -import { Component, createSignal, Show } from "solid-js" +import { Component, createSignal, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" @@ -221,12 +221,30 @@ interface ErrorPageProps { export const ErrorPage: Component = (props) => { const platform = usePlatform() const language = useLanguage() + const formattedError = () => formatError(props.error, language.t) + let recordedFatalError: Promise | undefined const [store, setStore] = createStore({ checking: false, version: undefined as string | undefined, actionError: undefined as string | undefined, }) + function ensureFatalErrorRecorded() { + recordedFatalError ??= + platform.recordFatalRendererError?.({ + error: formattedError(), + url: location.href, + version: platform.version, + platform: platform.platform, + os: platform.os, + }) ?? Promise.resolve() + return recordedFatalError + } + + onMount(() => { + void ensureFatalErrorRecorded().catch(() => undefined) + }) + async function checkForUpdates() { if (!platform.checkUpdate) return setStore("checking", true) @@ -254,6 +272,17 @@ export const ErrorPage: Component = (props) => { }) } + async function exportDebugLogs() { + const exportLogs = platform.exportDebugLogs + if (!exportLogs) return + await ensureFatalErrorRecorded() + .then(() => exportLogs()) + .then(() => setStore("actionError", undefined)) + .catch((err) => { + setStore("actionError", formatError(err, language.t)) + }) + } + return (
@@ -263,7 +292,7 @@ export const ErrorPage: Component = (props) => {

{language.t("error.page.description")}

= (props) => { + + + {(_) => { const [reported, setReported] = createSignal(false) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 27656a33db..ebf366465a 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1059,14 +1059,14 @@ export default function Layout(props: ParentProps) { keybind: "mod+comma", onSelect: () => openSettings(), }, - ...(window.api?.exportDebugLogs + ...(platform.platform === "desktop" && platform.exportDebugLogs ? [ { id: "logs.export", title: "Export logs", category: language.t("command.category.settings"), onSelect: () => { - void window.api?.exportDebugLogs?.() + void platform.exportDebugLogs?.() }, }, ] diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 99aba38b1b..4ffa73e3bb 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -339,6 +339,7 @@ registerIpcHandlers({ installUpdate: async () => installUpdate(), setBackgroundColor: (color) => setBackgroundColor(color), exportDebugLogs: () => exportDebugLogs(), + recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"), }) async function killSidecar() { diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index e714184200..3a6e49c674 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -4,6 +4,7 @@ import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" import type { InitStep, + FatalRendererError, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, @@ -39,6 +40,7 @@ type Deps = { installUpdate: () => Promise | void setBackgroundColor: (color: string) => void exportDebugLogs: () => Promise + recordFatalRendererError: (error: FatalRendererError) => Promise | void } export function registerIpcHandlers(deps: Deps) { @@ -71,6 +73,9 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("install-update", () => deps.installUpdate()) 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) diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 6b500bf67a..1f8a67f9e0 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -67,6 +67,7 @@ const api: ElectronAPI = { installUpdate: () => ipcRenderer.invoke("install-update"), setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color), exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"), + recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error), } contextBridge.exposeInMainWorld("api", api) diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 4041671304..53d5973ce8 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -19,6 +19,14 @@ export type WindowConfig = { updaterEnabled: boolean } +export type FatalRendererError = { + error: string + url: string + version?: string + platform: string + os?: string +} + export type ElectronAPI = { killSidecar: () => Promise installCli: () => Promise @@ -77,4 +85,5 @@ export type ElectronAPI = { installUpdate: () => Promise setBackgroundColor: (color: string) => Promise exportDebugLogs: () => Promise + recordFatalRendererError: (error: FatalRendererError) => Promise } diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index f9114c7550..32caa469ec 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -200,6 +200,10 @@ const createPlatform = (): Platform => { await window.api.installUpdate() }, + exportDebugLogs: () => window.api.exportDebugLogs(), + + recordFatalRendererError: (error) => window.api.recordFatalRendererError(error), + restart: async () => { await window.api.killSidecar().catch(() => undefined) window.api.relaunch()