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