Apply PR #26262: feat(desktop): Add Export Logs

This commit is contained in:
opencode-agent[bot]
2026-05-19 17:18:49 +00:00
18 changed files with 515 additions and 20 deletions

View File

@@ -255,6 +255,7 @@
"name": "@opencode-ai/desktop",
"version": "1.15.5",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron-context-menu": "4.1.2",

View File

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

View File

@@ -8,6 +8,16 @@ 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 WslRuntimeCheck = {
available: boolean
@@ -93,10 +103,10 @@ export type WslServersPlatform = {
export type Platform = {
/** Platform discriminator */
platform: "web" | "desktop"
platform: PlatformName
/** Desktop OS (Tauri only) */
os?: "macos" | "windows" | "linux"
os?: DesktopOS
/** App version */
version?: string
@@ -166,6 +176,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"

View File

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

View File

@@ -5,6 +5,7 @@ export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language
export { useWslServers } from "./context/wsl-servers"
export {
type DisplayBackend,
type FatalRendererErrorLog,
type Platform,
PlatformProvider,
type WslInstalledDistro,

View File

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

View File

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

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

@@ -16,7 +16,7 @@ import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../prel
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 {
@@ -32,6 +32,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()
const wslServers = createWslServersController(
app.getVersion(),
@@ -181,6 +184,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()) {
@@ -216,6 +221,21 @@ const main = Effect.gen(function* () {
void stopSidecars()
})
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 stopSidecars().finally(() => app.exit(0))
@@ -272,6 +292,8 @@ const main = Effect.gen(function* () {
checkUpdate: async () => checkUpdate(),
installUpdate: async () => installUpdate(stopSidecars),
setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(),
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
})
yield* Effect.promise(() => app.whenReady())
@@ -281,6 +303,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
@@ -336,9 +365,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
@@ -391,6 +420,9 @@ const main = Effect.gen(function* () {
},
reload: () => mainWindow?.reload(),
relaunch,
exportDebugLogs: () => {
void exportDebugLogs().catch((error) => logger.error("failed to export debug logs", error))
},
})
}

View File

@@ -4,6 +4,7 @@ import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type {
InitStep,
FatalRendererError,
ServerReadyData,
SqliteMigrationProgress,
TitlebarTheme,
@@ -52,6 +53,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) {
@@ -145,6 +148,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)

View File

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

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

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

View File

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

View File

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

View File

@@ -87,6 +87,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)

View File

@@ -99,6 +99,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>
@@ -155,4 +163,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>
}

View File

@@ -203,6 +203,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()