Skip to content

Commit 38aa34f

Browse files
Apply PR #26262: feat(desktop): Add Export Logs
2 parents 9e2d422 + 53641d9 commit 38aa34f

18 files changed

Lines changed: 515 additions & 21 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
@@ -78,6 +78,7 @@ declare global {
7878
}
7979
api?: {
8080
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
81+
exportDebugLogs?: () => Promise<string>
8182
}
8283
}
8384
}

packages/app/src/context/platform.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,23 @@ 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 Platform = {
1323
/** Platform discriminator */
14-
platform: "web" | "desktop"
24+
platform: PlatformName
1525

1626
/** Desktop OS (Tauri only) */
17-
os?: "macos" | "windows" | "linux"
27+
os?: DesktopOS
1828

1929
/** App version */
2030
version?: string
@@ -87,6 +97,12 @@ export type Platform = {
8797

8898
/** Read image from clipboard (desktop only) */
8999
readClipboardImage?(): Promise<File | null>
100+
101+
/** Export collected diagnostic logs (desktop only) */
102+
exportDebugLogs?(): Promise<string>
103+
104+
/** Record a fatal renderer error in platform logs (desktop only) */
105+
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
90106
}
91107

92108
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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ export { AppBaseProviders, AppInterface } from "./app"
22
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
33
export { useCommand } from "./context/command"
44
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
5-
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
5+
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
66
export { ServerConnection } from "./context/server"
77
export { handleNotificationClick } from "./utils/notification-click"

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
@@ -1081,6 +1081,18 @@ export default function Layout(props: ParentProps) {
10811081
keybind: "mod+comma",
10821082
onSelect: () => openSettings(),
10831083
},
1084+
...(platform.platform === "desktop" && platform.exportDebugLogs
1085+
? [
1086+
{
1087+
id: "logs.export",
1088+
title: "Export logs",
1089+
category: language.t("command.category.settings"),
1090+
onSelect: () => {
1091+
void platform.exportDebugLogs?.()
1092+
},
1093+
},
1094+
]
1095+
: []),
10841096
{
10851097
id: "session.previous",
10861098
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
@@ -15,7 +15,7 @@ import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } fr
1515
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
1616
import { CHANNEL, UPDATER_ENABLED } from "./constants"
1717
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
18-
import { initLogging } from "./logging"
18+
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
1919
import { parseMarkdown } from "./markdown"
2020
import { createMenu } from "./menu"
2121
import {
@@ -31,6 +31,7 @@ import {
3131
createLoadingWindow,
3232
createMainWindow,
3333
registerRendererProtocol,
34+
setRelaunchHandler,
3435
setBackgroundColor,
3536
setDockIcon,
3637
} 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
try {
146149
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
@@ -157,6 +160,8 @@ const main = Effect.gen(function* () {
157160
ensureLoopbackNoProxy()
158161
useEnvProxy()
159162
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
163+
const features = app.commandLine.getSwitchValue("enable-features")
164+
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
160165
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
161166

162167
if (!app.requestSingleInstanceLock()) {
@@ -192,6 +197,21 @@ const main = Effect.gen(function* () {
192197
void killSidecar()
193198
})
194199

200+
app.on("child-process-gone", (_event, details) => {
201+
writeLog("utility", "child process gone", { details }, "error")
202+
})
203+
204+
app.on("render-process-gone", (_event, webContents, details) => {
205+
writeLog("window", "app render process gone", { url: webContents.getURL(), details }, "error")
206+
})
207+
208+
setRelaunchHandler(() => {
209+
void killSidecar().finally(() => {
210+
app.relaunch()
211+
app.exit(0)
212+
})
213+
})
214+
195215
for (const signal of ["SIGINT", "SIGTERM"] as const) {
196216
process.on(signal, () => {
197217
void killSidecar().finally(() => app.exit(0))
@@ -236,6 +256,8 @@ const main = Effect.gen(function* () {
236256
checkUpdate: async () => checkUpdate(),
237257
installUpdate: async () => installUpdate(killSidecar),
238258
setBackgroundColor: (color) => setBackgroundColor(color),
259+
exportDebugLogs: () => exportDebugLogs(),
260+
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
239261
})
240262

241263
yield* Effect.promise(() => app.whenReady())
@@ -245,6 +267,13 @@ const main = Effect.gen(function* () {
245267
registerRendererProtocol()
246268
setDockIcon()
247269
setupAutoUpdater()
270+
yield* Effect.promise(() => startNetLog()).pipe(
271+
Effect.catch((error) =>
272+
Effect.sync(() => {
273+
logger.warn("failed to start net log", error)
274+
}),
275+
),
276+
)
248277

249278
const needsMigration = ((): boolean => {
250279
if (process.env.OPENCODE_DB === ":memory:") return false
@@ -300,9 +329,9 @@ const main = Effect.gen(function* () {
300329
needsMigration,
301330
userDataPath: app.getPath("userData"),
302331
onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress),
303-
onStdout: (message) => logger.log("sidecar stdout", { message }),
304-
onStderr: (message) => logger.warn("sidecar stderr", { message }),
305-
onExit: (code) => logger.warn("sidecar exited", { code }),
332+
onStdout: (message) => writeLog("server", "stdout", { message }),
333+
onStderr: (message) => writeLog("server", "stderr", { message }, "warn"),
334+
onExit: (code) => writeLog("utility", "sidecar exited", { code }, "warn"),
306335
}),
307336
)
308337
server = listener
@@ -356,6 +385,9 @@ const main = Effect.gen(function* () {
356385
app.exit(0)
357386
})
358387
},
388+
exportDebugLogs: () => {
389+
void exportDebugLogs().catch((error) => logger.error("failed to export debug logs", error))
390+
},
359391
})
360392
}
361393

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,
@@ -38,6 +39,8 @@ type Deps = {
3839
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
3940
installUpdate: () => Promise<void> | void
4041
setBackgroundColor: (color: string) => void
42+
exportDebugLogs: () => Promise<string>
43+
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
4144
}
4245

4346
export function registerIpcHandlers(deps: Deps) {
@@ -69,6 +72,10 @@ export function registerIpcHandlers(deps: Deps) {
6972
ipcMain.handle("check-update", () => deps.checkUpdate())
7073
ipcMain.handle("install-update", () => deps.installUpdate())
7174
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
75+
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
76+
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
77+
deps.recordFatalRendererError(error),
78+
)
7279
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
7380
try {
7481
const store = getStore(name)

0 commit comments

Comments
 (0)