diff --git a/bun.lock b/bun.lock index eb5390cefc..814ea3b981 100644 --- a/bun.lock +++ b/bun.lock @@ -255,6 +255,7 @@ "name": "@opencode-ai/desktop", "version": "1.15.6", "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/context/platform.tsx b/packages/app/src/context/platform.tsx index 1ded3a7f1a..93c3a8db21 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -9,13 +9,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 @@ -91,6 +101,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/desktop-menu.ts b/packages/app/src/desktop-menu.ts index 106d95cd14..677738fde9 100644 --- a/packages/app/src/desktop-menu.ts +++ b/packages/app/src/desktop-menu.ts @@ -80,6 +80,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [ { type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } }, { type: "item", label: "Reload Webview", action: "view.reload" }, { type: "item", label: "Restart", action: "app.relaunch" }, + { type: "item", label: "Export Logs...", command: "logs.export" }, { type: "separator" }, { type: "item", role: "hide" }, { type: "item", role: "hideOthers" }, @@ -201,6 +202,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [ items: [ { type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" }, { type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" }, + { type: "item", label: "Export Logs...", command: "logs.export" }, { type: "separator" }, { type: "item", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 2f82d0a78d..25963b9727 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -470,6 +470,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 31d3e5dccd..6505341f61 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1081,6 +1081,18 @@ export default function Layout(props: ParentProps) { keybind: "mod+comma", onSelect: () => openSettings(), }, + ...(platform.platform === "desktop" && platform.exportDebugLogs + ? [ + { + id: "logs.export", + title: "Export logs", + category: language.t("command.category.settings"), + onSelect: () => { + void platform.exportDebugLogs?.() + }, + }, + ] + : []), { id: "session.previous", title: language.t("command.session.previous"), diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 8bb115b11f..e05eefb11e 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 a14c6a383c..bfecce2701 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -15,7 +15,7 @@ import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } fr 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 { @@ -31,6 +31,7 @@ import { createLoadingWindow, createMainWindow, registerRendererProtocol, + setRelaunchHandler, setBackgroundColor, setDockIcon, } from "./windows" @@ -49,6 +50,7 @@ const APP_IDS: Record = { prod: "ai.opencode.desktop", } const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" +const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports" let logger: ReturnType let mainWindow: BrowserWindow | null = null @@ -141,6 +143,7 @@ const main = Effect.gen(function* () { ) if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) logger = initLogging() + initCrashReporter() try { setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) @@ -157,6 +160,8 @@ const main = Effect.gen(function* () { 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()) { @@ -192,6 +197,21 @@ const main = Effect.gen(function* () { void killSidecar() }) + app.on("child-process-gone", (_event, details) => { + writeLog("utility", "child process gone", { details }, "error") + }) + + app.on("render-process-gone", (_event, webContents, details) => { + writeLog("window", "app render process gone", { url: webContents.getURL(), details }, "error") + }) + + setRelaunchHandler(() => { + 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)) @@ -236,6 +256,8 @@ const main = Effect.gen(function* () { checkUpdate: async () => checkUpdate(), installUpdate: async () => installUpdate(killSidecar), setBackgroundColor: (color) => setBackgroundColor(color), + exportDebugLogs: () => exportDebugLogs(), + recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"), }) yield* Effect.promise(() => app.whenReady()) @@ -245,6 +267,13 @@ const main = Effect.gen(function* () { registerRendererProtocol() setDockIcon() setupAutoUpdater() + yield* Effect.promise(() => startNetLog()).pipe( + Effect.catch((error) => + Effect.sync(() => { + logger.warn("failed to start net log", error) + }), + ), + ) const needsMigration = ((): boolean => { if (process.env.OPENCODE_DB === ":memory:") return false @@ -300,9 +329,9 @@ const main = Effect.gen(function* () { 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) => 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/ipc.ts b/packages/desktop/src/main/ipc.ts index 40c54c0856..21bc17294e 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -5,6 +5,7 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" import type { InitStep, + FatalRendererError, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, @@ -40,6 +41,8 @@ type Deps = { checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }> installUpdate: () => Promise | void setBackgroundColor: (color: string) => void + exportDebugLogs: () => Promise + recordFatalRendererError: (error: FatalRendererError) => Promise | void } export function registerIpcHandlers(deps: Deps) { @@ -71,6 +74,10 @@ 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("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/main/logging.ts b/packages/desktop/src/main/logging.ts index 5d373ed27f..8866fd78ef 100644 --- a/packages/desktop/src/main/logging.ts +++ b/packages/desktop/src/main/logging.ts @@ -1,21 +1,92 @@ import { MainLogger } from "electron-log" 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 let logger: MainLogger export const getLogger = () => logger export function initLogging() { + initRunDirectory() log.transports.file.maxSize = 5 * 1024 * 1024 + log.transports.file.resolvePathFn = (_vars, message) => + join( + run, + `${safeLogName(message?.scope ?? (message?.variables?.processType === "renderer" ? "renderer" : "main"))}.log`, + ) + log.initialize({ preload: false, spyRendererConsole: true }) initConsoleTransport() cleanup() return (logger = log) } +export function initCrashReporter() { + const dir = join(app.getPath("userData"), "Crashpad") + mkdirSync(dir, { recursive: true }) + app.setPath("crashDumps", dir) + crashReporter.start({ uploadToServer: false, compress: true }) + write("crash", "crash reporter started", { path: dir }) +} + +export async function startNetLog() { + if (netLog.currentlyLogging) return + netLogPath = join(run, "network.netlog") + await netLog.startLogging(netLogPath, { captureMode: "default", maxFileSize: NET_LOG_SIZE }) + write("network", "net log started", { path: netLogPath }) +} + +export async function exportDebugLogs() { + const restartNetLog = netLog.currentlyLogging + if (restartNetLog) { + await netLog.stopLogging().catch((error) => write("network", "failed to stop net log", { error })) + } + + const output = join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`) + try { + write("main", "exporting debug logs", { output }) + await writeZip(output, [ + { name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(), null, 2)) }, + ...collect(root, "desktop"), + ...serverLogRoots().flatMap((dir, i) => collect(dir, `server-${i + 1}`)), + ...collect(app.getPath("crashDumps"), "crashpad"), + ]) + shell.showItemInFolder(output) + return output + } finally { + if (restartNetLog) { + await startNetLog().catch((error) => write("network", "failed to restart net log", { error })) + } + } +} + +export function write( + name: string, + message: string, + extra?: Record, + level: "info" | "warn" | "error" = "info", +) { + if (!run) return + const scoped = log.scope(safeLogName(name)) + if (extra !== undefined) { + scoped[level](message, extra) + return + } + scoped[level](message) +} + export function tail(): string { try { const path = log.transports.file.getFile().path @@ -27,23 +98,96 @@ export function tail(): string { } } +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) { + const data = entry.data ?? readFileSync(entry.path!) + await writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(data)]))) + } + const zip = await writer.close() + writeFileSync(output, Buffer.from(await zip.arrayBuffer())) +} + function initConsoleTransport() { const write = log.transports.console.writeFn.bind(log.transports.console) log.transports.console.writeFn = (options) => { diff --git a/packages/desktop/src/main/unresponsive.ts b/packages/desktop/src/main/unresponsive.ts new file mode 100644 index 0000000000..1cc0924c3a --- /dev/null +++ b/packages/desktop/src/main/unresponsive.ts @@ -0,0 +1,69 @@ +import type { BrowserWindow } from "electron" +import { write as writeLog } from "./logging" + +const sampleInterval = 1000 +const samplePeriod = 15000 + +export function createUnresponsiveSampler(win: BrowserWindow, name: string) { + let sampleTimer: ReturnType | 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 }, "error") + return undefined + }) + if (!active()) return + if (stack) samples.set(stack, (samples.get(stack) ?? 0) + 1) + schedule() + } + + const stopAndFlush = () => { + const wasSampling = sampling + sampling = false + clearTimers() + if (samples.size === 0) return wasSampling + + const entries = [...samples.entries()].sort((a, b) => b[1] - a[1]) + const total = entries.reduce((sum, entry) => sum + entry[1], 0) + const message = [ + "renderer unresponsive samples", + `Window: ${name}`, + `URL: ${win.isDestroyed() ? "" : win.webContents.getURL()}`, + ...entries.map((entry) => `<${entry[1]}> ${entry[0]}`), + `Total Samples: ${total}`, + ].join("\n") + writeLog("window", message, undefined, "error") + samples.clear() + return wasSampling + } + + const start = () => { + if (sampling || win.isDestroyed() || win.webContents.isDestroyed() || win.webContents.isDevToolsOpened()) return + sampling = true + samples.clear() + schedule() + stopTimer = setTimeout(stopAndFlush, samplePeriod) + } + + win.on("closed", stopAndFlush) + + return { start, stopAndFlush } +} diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts index ec66e4f963..33849c7c7c 100644 --- a/packages/desktop/src/main/updater.ts +++ b/packages/desktop/src/main/updater.ts @@ -1,13 +1,13 @@ import { app, dialog } from "electron" import pkg from "electron-updater" import { UPDATER_ENABLED } from "./constants" -import { initLogging } from "./logging" +import { getLogger } from "./logging" -const logger = initLogging() const { autoUpdater } = pkg export function setupAutoUpdater() { if (!UPDATER_ENABLED) return + const logger = getLogger() autoUpdater.logger = logger autoUpdater.channel = "latest" autoUpdater.allowPrerelease = false @@ -24,6 +24,7 @@ export function setupAutoUpdater() { export async function checkUpdate() { if (!UPDATER_ENABLED) return { updateAvailable: false } + const logger = getLogger() logger.log("checking for updates", { currentVersion: app.getVersion(), channel: autoUpdater.channel, @@ -58,6 +59,7 @@ export async function checkUpdate() { export async function installUpdate(killSidecar: () => Promise) { const result = await checkUpdate() + const logger = getLogger() if (!result.updateAvailable) { logger.log("install update skipped", { reason: result.failed ? "update check failed" : "no update available", @@ -73,6 +75,7 @@ export async function installUpdate(killSidecar: () => Promise) { export async function checkForUpdates(alertOnFail: boolean, killSidecar: () => Promise) { if (!UPDATER_ENABLED) return + const logger = getLogger() logger.log("checkForUpdates invoked", { alertOnFail }) const result = await checkUpdate() if (!result.updateAvailable) { diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index e1b87cad69..3eed61b5b2 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 { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron" import { dirname, isAbsolute, join, relative, resolve } from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" import type { TitlebarTheme } from "../preload/types" +import { exportDebugLogs, write as writeLog } from "./logging" +import { createUnresponsiveSampler } from "./unresponsive" const root = dirname(fileURLToPath(import.meta.url)) const rendererRoot = join(root, "../renderer") @@ -11,6 +13,8 @@ const rendererHost = "renderer" const clipboardWritePermission = "clipboard-sanitized-write" const notificationPermission = "notifications" const rendererPermissions = new Set([clipboardWritePermission, notificationPermission]) +const documentPolicyHeader = "Document-Policy" +const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports" protocol.registerSchemesAsPrivileged([ { @@ -24,9 +28,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 } @@ -112,6 +124,7 @@ export function createMainWindow() { }) allowRendererPermissions(win) + wireWindowRecovery(win, "main") win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => { const { requestHeaders } = details @@ -121,8 +134,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", ["*"]) + addRendererHeaders(details.url, responseHeaders) callback({ responseHeaders }) }) @@ -165,6 +177,7 @@ export function createLoadingWindow() { }) allowRendererPermissions(win) + wireWindowRecovery(win, "loading") loadWindow(win, "loading.html") @@ -174,19 +187,35 @@ 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) { + writeLog("protocol", "rejected host", { url: request.url }, "warn") return new Response("Not found", { status: 404 }) } const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`) const rel = relative(rendererRoot, file) if (rel.startsWith("..") || isAbsolute(rel)) { + writeLog("protocol", "rejected path", { url: request.url, file }, "warn") return new Response("Not found", { status: 404 }) } - return net.fetch(pathToFileURL(file).toString()) + try { + const response = await net.fetch(pathToFileURL(file).toString()) + if (response.status >= 400) { + writeLog("protocol", "fetch failed", { + url: request.url, + file, + status: response.status, + statusText: response.statusText, + }, "error") + } + return addDocumentPolicy(response, file) + } catch (error) { + writeLog("protocol", "fetch error", { url: request.url, file, error }, "error") + return new Response("Not found", { status: 404 }) + } }) } @@ -201,6 +230,117 @@ function loadWindow(win: BrowserWindow, html: string) { void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`) } +function wireWindowRecovery(win: BrowserWindow, name: string) { + let showing = false + const sampler = createUnresponsiveSampler(win, name) + + const handle = async (button: string | undefined, wait: boolean) => { + if (button === "Export Logs") { + const sampling = sampler.stopAndFlush() + await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error }, "error")) + if (wait && sampling) sampler.start() + return true + } + if (button === "Relaunch") { + sampler.stopAndFlush() + relaunchHandler() + return false + } + if (button === "Quit") { + sampler.stopAndFlush() + app.quit() + } + return false + } + + const show = async (message: string, detail: string, wait: boolean) => { + if (showing || win.isDestroyed()) return + showing = true + try { + while (!win.isDestroyed()) { + const buttons = wait ? ["Relaunch", "Export Logs", "Keep Waiting"] : ["Relaunch", "Export Logs", "Quit"] + const result = await dialog.showMessageBox(win, { + type: "warning", + buttons, + defaultId: 0, + cancelId: 2, + message, + detail, + }) + if (await handle(buttons[result.response], wait)) continue + return + } + } finally { + showing = false + } + } + + const failed = ( + event: string, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean, + ) => { + writeLog("window", "renderer load failed", { + window: name, + event, + errorCode, + errorDescription, + validatedURL, + currentURL: win.webContents.getURL(), + isMainFrame, + }, "error") + + if (!isMainFrame || errorCode === -3) return + void show( + "OpenCode failed to load", + [`Window: ${name}`, `URL: ${validatedURL}`, `Error: ${errorCode} ${errorDescription}`].join("\n"), + false, + ) + } + + win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + failed("did-fail-load", errorCode, errorDescription, validatedURL, isMainFrame) + }) + win.webContents.on("did-fail-provisional-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + failed("did-fail-provisional-load", errorCode, errorDescription, validatedURL, isMainFrame) + }) + win.webContents.on("render-process-gone", (_event, details) => { + sampler.stopAndFlush() + writeLog("window", "renderer process gone", { window: name, currentURL: win.webContents.getURL(), details }, "error") + void show( + "OpenCode window terminated unexpectedly", + [`Window: ${name}`, `Reason: ${details.reason}`, `Code: ${details.exitCode ?? ""}`].join("\n"), + false, + ) + }) + win.on("unresponsive", () => { + writeLog("window", "renderer unresponsive", { window: name, currentURL: win.webContents.getURL() }, "error") + sampler.start() + void show("OpenCode is not responding", "You can relaunch the app, open the logs, or keep waiting.", true) + }) + win.on("responsive", () => { + writeLog("window", "renderer responsive", { window: name, currentURL: win.webContents.getURL() }, "error") + sampler.stopAndFlush() + }) + win.webContents.on("console-message", (_event, level, message, line, sourceId) => { + if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) { + writeLog("pty", "console", { window: name, level, message, line, sourceId }) + } + }) + win.webContents.on("preload-error", (_event, preloadPath, error) => { + writeLog("preload", "preload error", { window: name, preloadPath, error }, "error") + }) +} + +function addDocumentPolicy(response: Response, file: string) { + if (!file.toLowerCase().endsWith(".html")) return response + const headers = new Headers(response.headers) + headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy) + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }) +} + function allowRendererPermissions(win: BrowserWindow) { win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => { callback( @@ -217,8 +357,19 @@ function allowRendererPermissions(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 diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 4adbfb62aa..ac69632037 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -67,6 +67,8 @@ 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"), + 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 ce931a1d9e..055f8589b3 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -20,6 +20,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 @@ -78,4 +86,6 @@ export type ElectronAPI = { checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }> 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 16cc41842f..e0b0ad2bbf 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -216,6 +216,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()