feat(desktop): add export logs

This commit is contained in:
LukeParkerDev
2026-05-08 11:36:50 +10:00
parent b8799be3c8
commit 230affe435
11 changed files with 337 additions and 12 deletions

View File

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

View File

@@ -78,6 +78,7 @@ declare global {
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
exportDebugLogs?: () => Promise<string>
}
}
}

View File

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

View File

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

View File

@@ -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() {

View File

@@ -38,6 +38,7 @@ type Deps = {
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void> | void
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
}
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)

View File

@@ -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<string, unknown>) {
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) => {

View File

@@ -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" },
{

View File

@@ -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<BrowserWindow, Partial<TitlebarTheme>>()
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 ?? "<unknown>"}`].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(

View File

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

View File

@@ -76,4 +76,5 @@ export type ElectronAPI = {
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void>
setBackgroundColor: (color: string) => Promise<void>
exportDebugLogs: () => Promise<string>
}