mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-21 19:35:10 +00:00
Apply PR #26262: feat(desktop): Add Export Logs
This commit is contained in:
1
bun.lock
1
bun.lock
@@ -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",
|
||||
|
||||
@@ -79,6 +79,7 @@ declare global {
|
||||
}
|
||||
api?: {
|
||||
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||
exportDebugLogs?: () => Promise<string>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" },
|
||||
{
|
||||
|
||||
69
packages/desktop/src/main/unresponsive.ts
Normal file
69
packages/desktop/src/main/unresponsive.ts
Normal 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 }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user