Luke Parker 3 месяцев назад
Родитель
Сommit
bea3ca5b05

+ 1 - 0
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",

+ 1 - 0
packages/app/src/app.tsx

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

+ 18 - 2
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<File | null>
+
+  /** Export collected diagnostic logs (desktop only) */
+  exportDebugLogs?(): Promise<string>
+
+  /** Record a fatal renderer error in platform logs (desktop only) */
+  recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
 }
 
 export type DisplayBackend = "auto" | "wayland"

+ 2 - 0
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",

+ 1 - 0
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}}",

+ 1 - 1
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"

+ 36 - 2
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<ErrorPageProps> = (props) => {
   const platform = usePlatform()
   const language = useLanguage()
+  const formattedError = () => formatError(props.error, language.t)
+  let recordedFatalError: Promise<void> | 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<ErrorPageProps> = (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 (
     <div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
       <div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
@@ -263,7 +292,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
           <p class="text-sm text-text-weak">{language.t("error.page.description")}</p>
         </div>
         <TextField
-          value={formatError(props.error, language.t)}
+          value={formattedError()}
           readOnly
           copyable
           multiline
@@ -275,6 +304,11 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
           <Button size="large" onClick={platform.restart}>
             {language.t("error.page.action.restart")}
           </Button>
+          <Show when={platform.platform === "desktop" && platform.exportDebugLogs}>
+            <Button size="large" variant="ghost" onClick={exportDebugLogs}>
+              {language.t("error.page.action.exportLogs")}
+            </Button>
+          </Show>
           <Show when={Sentry.isEnabled}>
             {(_) => {
               const [reported, setReported] = createSignal(false)

+ 12 - 0
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"),

+ 1 - 0
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",

+ 33 - 4
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<string, string> = {
   prod: "ai.opencode.desktop",
 }
 const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
+const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
 
 let logger: ReturnType<typeof initLogging>
 let mainWindow: BrowserWindow | null = null
@@ -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

+ 7 - 0
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> | void
   setBackgroundColor: (color: string) => void
+  exportDebugLogs: () => Promise<string>
+  recordFatalRendererError: (error: FatalRendererError) => Promise<void> | 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)

+ 149 - 5
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<string, unknown>,
+  level: "info" | "warn" | "error" = "info",
+) {
+  if (!run) return
+  const scoped = log.scope(safeLogName(name))
+  if (extra !== undefined) {
+    scoped[level](message, extra)
+    return
+  }
+  scoped[level](message)
+}
+
 export function tail(): string {
   try {
     const path = log.transports.file.getFile().path
@@ -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) => {

+ 69 - 0
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<typeof setTimeout> | undefined
+  let stopTimer: ReturnType<typeof setTimeout> | undefined
+  let sampling = false
+  const samples = new Map<string, number>()
+
+  const active = () => sampling && !win.isDestroyed() && !win.webContents.isDestroyed()
+  const clearTimers = () => {
+    if (sampleTimer) clearTimeout(sampleTimer)
+    if (stopTimer) clearTimeout(stopTimer)
+    sampleTimer = undefined
+    stopTimer = undefined
+  }
+
+  const schedule = () => {
+    sampleTimer = setTimeout(() => {
+      void collect()
+    }, sampleInterval)
+  }
+
+  const collect = async () => {
+    if (!active()) return
+    const stack = await win.webContents.mainFrame.collectJavaScriptCallStack().catch((error) => {
+      writeLog("window", "failed to collect unresponsive sample", { window: name, error }, "error")
+      return undefined
+    })
+    if (!active()) return
+    if (stack) samples.set(stack, (samples.get(stack) ?? 0) + 1)
+    schedule()
+  }
+
+  const stopAndFlush = () => {
+    const wasSampling = sampling
+    sampling = false
+    clearTimers()
+    if (samples.size === 0) return wasSampling
+
+    const entries = [...samples.entries()].sort((a, b) => b[1] - a[1])
+    const total = entries.reduce((sum, entry) => sum + entry[1], 0)
+    const message = [
+      "renderer unresponsive samples",
+      `Window: ${name}`,
+      `URL: ${win.isDestroyed() ? "<destroyed>" : win.webContents.getURL()}`,
+      ...entries.map((entry) => `<${entry[1]}> ${entry[0]}`),
+      `Total Samples: ${total}`,
+    ].join("\n")
+    writeLog("window", message, undefined, "error")
+    samples.clear()
+    return wasSampling
+  }
+
+  const start = () => {
+    if (sampling || win.isDestroyed() || win.webContents.isDestroyed() || win.webContents.isDevToolsOpened()) return
+    sampling = true
+    samples.clear()
+    schedule()
+    stopTimer = setTimeout(stopAndFlush, samplePeriod)
+  }
+
+  win.on("closed", stopAndFlush)
+
+  return { start, stopAndFlush }
+}

+ 5 - 2
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<void>) {
   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<void>) {
 
 export async function checkForUpdates(alertOnFail: boolean, killSidecar: () => Promise<void>) {
   if (!UPDATER_ENABLED) return
+  const logger = getLogger()
   logger.log("checkForUpdates invoked", { alertOnFail })
   const result = await checkUpdate()
   if (!result.updateAvailable) {

+ 156 - 5
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<BrowserWindow, Partial<TitlebarTheme>>()
 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 ?? "<unknown>"}`].join("\n"),
+      false,
+    )
+  })
+  win.on("unresponsive", () => {
+    writeLog("window", "renderer unresponsive", { window: name, currentURL: win.webContents.getURL() }, "error")
+    sampler.start()
+    void show("OpenCode is not responding", "You can relaunch the app, open the logs, or keep waiting.", true)
+  })
+  win.on("responsive", () => {
+    writeLog("window", "renderer responsive", { window: name, currentURL: win.webContents.getURL() }, "error")
+    sampler.stopAndFlush()
+  })
+  win.webContents.on("console-message", (_event, level, message, line, sourceId) => {
+    if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) {
+      writeLog("pty", "console", { window: name, level, message, line, sourceId })
+    }
+  })
+  win.webContents.on("preload-error", (_event, preloadPath, error) => {
+    writeLog("preload", "preload error", { window: name, preloadPath, error }, "error")
+  })
+}
+
+function addDocumentPolicy(response: Response, file: string) {
+  if (!file.toLowerCase().endsWith(".html")) return response
+  const headers = new Headers(response.headers)
+  headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
+  return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
+}
+
 function allowRendererPermissions(win: BrowserWindow) {
   win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
     callback(
@@ -217,8 +357,19 @@ function allowRendererPermissions(win: BrowserWindow) {
 }
 
 function isTrustedRendererUrl(value?: string) {
+  return isRendererUrl(value)
+}
+
+function addRendererHeaders(value: string, headers: Record<string, any>) {
+  upsertKeyValue(headers, "Access-Control-Allow-Origin", ["*"])
+  upsertKeyValue(headers, "Access-Control-Allow-Headers", ["*"])
+  if (isRendererUrl(value, true)) upsertKeyValue(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy])
+}
+
+function isRendererUrl(value?: string, html = false) {
   if (!value || !URL.canParse(value)) return false
   const url = new URL(value)
+  if (html && !url.pathname.endsWith(".html")) return false
   if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
   const devUrl = process.env.ELECTRON_RENDERER_URL
   if (!devUrl || !URL.canParse(devUrl)) return false

+ 2 - 0
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)

+ 10 - 0
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<void>
   installCli: () => Promise<string>
@@ -78,4 +86,6 @@ export type ElectronAPI = {
   checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
   installUpdate: () => Promise<void>
   setBackgroundColor: (color: string) => Promise<void>
+  exportDebugLogs: () => Promise<string>
+  recordFatalRendererError: (error: FatalRendererError) => Promise<void>
 }

+ 4 - 0
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()