Skip to content

Commit cd6f94b

Browse files
Apply PR #26262: feat(desktop): Add Export Logs
2 parents dfc0ba2 + 53641d9 commit cd6f94b

18 files changed

Lines changed: 515 additions & 20 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/app/src/app.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ declare global {
7979
}
8080
api?: {
8181
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
82+
exportDebugLogs?: () => Promise<string>
8283
}
8384
}
8485
}

packages/app/src/context/platform.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
88
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
99
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
1010
type UpdateInfo = { updateAvailable: boolean; version?: string }
11+
type PlatformName = "web" | "desktop"
12+
type DesktopOS = "macos" | "windows" | "linux"
13+
14+
export type FatalRendererErrorLog = {
15+
error: string
16+
url: string
17+
version?: string
18+
platform: PlatformName
19+
os?: DesktopOS
20+
}
1121

1222
export type WslRuntimeCheck = {
1323
available: boolean
@@ -93,10 +103,10 @@ export type WslServersPlatform = {
93103

94104
export type Platform = {
95105
/** Platform discriminator */
96-
platform: "web" | "desktop"
106+
platform: PlatformName
97107

98108
/** Desktop OS (Tauri only) */
99-
os?: "macos" | "windows" | "linux"
109+
os?: DesktopOS
100110

101111
/** App version */
102112
version?: string
@@ -166,6 +176,12 @@ export type Platform = {
166176

167177
/** Read image from clipboard (desktop only) */
168178
readClipboardImage?(): Promise<File | null>
179+
180+
/** Export collected diagnostic logs (desktop only) */
181+
exportDebugLogs?(): Promise<string>
182+
183+
/** Record a fatal renderer error in platform logs (desktop only) */
184+
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
169185
}
170186

171187
export type DisplayBackend = "auto" | "wayland"

packages/app/src/i18n/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,7 @@ export const dict = {
470470
"error.page.action.restart": "Restart",
471471
"error.page.action.report": "Report Error",
472472
"error.page.action.reported": "Error Reported",
473+
"error.page.action.exportLogs": "Export Logs",
473474
"error.page.action.checking": "Checking...",
474475
"error.page.action.checkUpdates": "Check for updates",
475476
"error.page.action.updateTo": "Update to {{version}}",

packages/app/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language
55
export { useWslServers } from "./context/wsl-servers"
66
export {
77
type DisplayBackend,
8+
type FatalRendererErrorLog,
89
type Platform,
910
PlatformProvider,
1011
type WslInstalledDistro,

packages/app/src/pages/error.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
22
import * as Sentry from "@sentry/solid"
33
import { Logo } from "@opencode-ai/ui/logo"
44
import { Button } from "@opencode-ai/ui/button"
5-
import { Component, createSignal, Show } from "solid-js"
5+
import { Component, createSignal, onMount, Show } from "solid-js"
66
import { createStore } from "solid-js/store"
77
import { usePlatform } from "@/context/platform"
88
import { useLanguage } from "@/context/language"
@@ -221,12 +221,30 @@ interface ErrorPageProps {
221221
export const ErrorPage: Component<ErrorPageProps> = (props) => {
222222
const platform = usePlatform()
223223
const language = useLanguage()
224+
const formattedError = () => formatError(props.error, language.t)
225+
let recordedFatalError: Promise<void> | undefined
224226
const [store, setStore] = createStore({
225227
checking: false,
226228
version: undefined as string | undefined,
227229
actionError: undefined as string | undefined,
228230
})
229231

232+
function ensureFatalErrorRecorded() {
233+
recordedFatalError ??=
234+
platform.recordFatalRendererError?.({
235+
error: formattedError(),
236+
url: location.href,
237+
version: platform.version,
238+
platform: platform.platform,
239+
os: platform.os,
240+
}) ?? Promise.resolve()
241+
return recordedFatalError
242+
}
243+
244+
onMount(() => {
245+
void ensureFatalErrorRecorded().catch(() => undefined)
246+
})
247+
230248
async function checkForUpdates() {
231249
if (!platform.checkUpdate) return
232250
setStore("checking", true)
@@ -254,6 +272,17 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
254272
})
255273
}
256274

275+
async function exportDebugLogs() {
276+
const exportLogs = platform.exportDebugLogs
277+
if (!exportLogs) return
278+
await ensureFatalErrorRecorded()
279+
.then(() => exportLogs())
280+
.then(() => setStore("actionError", undefined))
281+
.catch((err) => {
282+
setStore("actionError", formatError(err, language.t))
283+
})
284+
}
285+
257286
return (
258287
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
259288
<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) => {
263292
<p class="text-sm text-text-weak">{language.t("error.page.description")}</p>
264293
</div>
265294
<TextField
266-
value={formatError(props.error, language.t)}
295+
value={formattedError()}
267296
readOnly
268297
copyable
269298
multiline
@@ -275,6 +304,11 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
275304
<Button size="large" onClick={platform.restart}>
276305
{language.t("error.page.action.restart")}
277306
</Button>
307+
<Show when={platform.platform === "desktop" && platform.exportDebugLogs}>
308+
<Button size="large" variant="ghost" onClick={exportDebugLogs}>
309+
{language.t("error.page.action.exportLogs")}
310+
</Button>
311+
</Show>
278312
<Show when={Sentry.isEnabled}>
279313
{(_) => {
280314
const [reported, setReported] = createSignal(false)

packages/app/src/pages/layout.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,18 @@ export default function Layout(props: ParentProps) {
10821082
keybind: "mod+comma",
10831083
onSelect: () => openSettings(),
10841084
},
1085+
...(platform.platform === "desktop" && platform.exportDebugLogs
1086+
? [
1087+
{
1088+
id: "logs.export",
1089+
title: "Export logs",
1090+
category: language.t("command.category.settings"),
1091+
onSelect: () => {
1092+
void platform.exportDebugLogs?.()
1093+
},
1094+
},
1095+
]
1096+
: []),
10851097
{
10861098
id: "session.previous",
10871099
title: language.t("command.session.previous"),

packages/desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
},
2525
"main": "./out/main/index.js",
2626
"dependencies": {
27+
"@zip.js/zip.js": "2.7.62",
2728
"effect": "catalog:",
2829
"electron-context-menu": "4.1.2",
2930
"electron-log": "^5",

packages/desktop/src/main/index.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../prel
1616
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
1717
import { CHANNEL, UPDATER_ENABLED } from "./constants"
1818
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
19-
import { initLogging } from "./logging"
19+
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
2020
import { parseMarkdown } from "./markdown"
2121
import { createMenu } from "./menu"
2222
import {
@@ -32,6 +32,7 @@ import {
3232
createLoadingWindow,
3333
createMainWindow,
3434
registerRendererProtocol,
35+
setRelaunchHandler,
3536
setBackgroundColor,
3637
setDockIcon,
3738
} from "./windows"
@@ -49,6 +50,7 @@ const APP_IDS: Record<string, string> = {
4950
prod: "ai.opencode.desktop",
5051
}
5152
const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
53+
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
5254

5355
let logger: ReturnType<typeof initLogging>
5456
let mainWindow: BrowserWindow | null = null
@@ -141,6 +143,7 @@ const main = Effect.gen(function* () {
141143
)
142144
if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
143145
logger = initLogging()
146+
initCrashReporter()
144147

145148
const wslServers = createWslServersController(
146149
app.getVersion(),
@@ -181,6 +184,8 @@ const main = Effect.gen(function* () {
181184
ensureLoopbackNoProxy()
182185
useEnvProxy()
183186
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
187+
const features = app.commandLine.getSwitchValue("enable-features")
188+
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
184189
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
185190

186191
if (!app.requestSingleInstanceLock()) {
@@ -216,6 +221,21 @@ const main = Effect.gen(function* () {
216221
void stopSidecars()
217222
})
218223

224+
app.on("child-process-gone", (_event, details) => {
225+
writeLog("utility", "child process gone", { details }, "error")
226+
})
227+
228+
app.on("render-process-gone", (_event, webContents, details) => {
229+
writeLog("window", "app render process gone", { url: webContents.getURL(), details }, "error")
230+
})
231+
232+
setRelaunchHandler(() => {
233+
void killSidecar().finally(() => {
234+
app.relaunch()
235+
app.exit(0)
236+
})
237+
})
238+
219239
for (const signal of ["SIGINT", "SIGTERM"] as const) {
220240
process.on(signal, () => {
221241
void stopSidecars().finally(() => app.exit(0))
@@ -272,6 +292,8 @@ const main = Effect.gen(function* () {
272292
checkUpdate: async () => checkUpdate(),
273293
installUpdate: async () => installUpdate(stopSidecars),
274294
setBackgroundColor: (color) => setBackgroundColor(color),
295+
exportDebugLogs: () => exportDebugLogs(),
296+
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
275297
})
276298

277299
yield* Effect.promise(() => app.whenReady())
@@ -281,6 +303,13 @@ const main = Effect.gen(function* () {
281303
registerRendererProtocol()
282304
setDockIcon()
283305
setupAutoUpdater()
306+
yield* Effect.promise(() => startNetLog()).pipe(
307+
Effect.catch((error) =>
308+
Effect.sync(() => {
309+
logger.warn("failed to start net log", error)
310+
}),
311+
),
312+
)
284313

285314
const needsMigration = ((): boolean => {
286315
if (process.env.OPENCODE_DB === ":memory:") return false
@@ -336,9 +365,9 @@ const main = Effect.gen(function* () {
336365
needsMigration,
337366
userDataPath: app.getPath("userData"),
338367
onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress),
339-
onStdout: (message) => logger.log("sidecar stdout", { message }),
340-
onStderr: (message) => logger.warn("sidecar stderr", { message }),
341-
onExit: (code) => logger.warn("sidecar exited", { code }),
368+
onStdout: (message) => writeLog("server", "stdout", { message }),
369+
onStderr: (message) => writeLog("server", "stderr", { message }, "warn"),
370+
onExit: (code) => writeLog("utility", "sidecar exited", { code }, "warn"),
342371
}),
343372
)
344373
server = listener
@@ -391,6 +420,9 @@ const main = Effect.gen(function* () {
391420
},
392421
reload: () => mainWindow?.reload(),
393422
relaunch,
423+
exportDebugLogs: () => {
424+
void exportDebugLogs().catch((error) => logger.error("failed to export debug logs", error))
425+
},
394426
})
395427
}
396428

packages/desktop/src/main/ipc.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
44

55
import type {
66
InitStep,
7+
FatalRendererError,
78
ServerReadyData,
89
SqliteMigrationProgress,
910
TitlebarTheme,
@@ -52,6 +53,8 @@ type Deps = {
5253
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
5354
installUpdate: () => Promise<void> | void
5455
setBackgroundColor: (color: string) => void
56+
exportDebugLogs: () => Promise<string>
57+
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
5558
}
5659

5760
export function registerIpcHandlers(deps: Deps) {
@@ -145,6 +148,10 @@ export function registerIpcHandlers(deps: Deps) {
145148
ipcMain.handle("check-update", () => deps.checkUpdate())
146149
ipcMain.handle("install-update", () => deps.installUpdate())
147150
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
151+
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
152+
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
153+
deps.recordFatalRendererError(error),
154+
)
148155
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
149156
try {
150157
const store = getStore(name)

0 commit comments

Comments
 (0)