/** * Crash reporting + diagnostics export for the Electron main process. * * Error reporting is Sentry-backed and gated entirely on a DSN being baked * in at build time (publish-desktop.yml exports DESKTOP_SENTRY_DSN; see the define * in electron.vite.config.ts). Local/dev builds have no DSN, so nothing is * ever sent — instead Electron's native crash reporter still writes * minidumps locally so they ride along in the diagnostics zip. * * What reaches Sentry when enabled: * - main-process uncaught exceptions / unhandled rejections * - native minidumps (main, renderer, GPU) via the Crashpad integration * - renderer/child process terminations (render-process-gone et al.) * - sidecar crashes after a successful boot, with a stderr tail * * What never leaves the machine: executor data (~/.executor — data.db holds * user secrets) and the desktop settings password. The diagnostics zip only * packs log files, crash dumps, and a redacted manifest. */ import { readdirSync, statSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { app, crashReporter, dialog, shell } from "electron"; import log from "electron-log/main.js"; import * as Sentry from "@sentry/electron/main"; import { getServerSettings } from "./settings"; const sentryDsn = __EXECUTOR_SENTRY_DSN__; // The informal cross-tool opt-out (consoledonottrack.com). Checked before // any SDK initializes, and it covers all three processes because the // renderer and sidecar both receive their config from this module. const doNotTrack = process.env.DO_NOT_TRACK === "1" || process.env.DO_NOT_TRACK?.toLowerCase() === "true"; export const errorReportingEnabled = sentryDsn.length > 0 && !doNotTrack; /** * One id per app launch, shared by every process (main, renderer, sidecar) * and stamped into the diagnostics manifest — lets a user-sent zip be * matched to its Sentry events and vice versa. */ export const runId = crypto.randomUUID().replace(/-/g, "").slice(0, 12); const releaseTag = () => `executor-desktop@${app.getVersion()}`; const environmentTag = () => (app.isPackaged ? "production" : "development"); /** * Runtime crash-reporting config for the renderer (fetched over the preload * bridge). The web UI is the same bundle `executor web` serves, so nothing * is baked in at build time — outside the desktop app this returns null and * the renderer never initializes Sentry. */ export const getCrashReportingConfig = () => errorReportingEnabled ? { dsn: sentryDsn, release: releaseTag(), environment: environmentTag(), runId, } : null; /** Env vars handed to the sidecar so its process reports under the same id. */ export const sidecarCrashReportingEnv = (): Record => errorReportingEnabled ? { EXECUTOR_SENTRY_DSN: sentryDsn, EXECUTOR_SENTRY_RELEASE: releaseTag(), EXECUTOR_SENTRY_ENVIRONMENT: environmentTag(), EXECUTOR_RUN_ID: runId, } : {}; /** * Must run before `app.whenReady()` so the Crashpad handler attaches to * every child process Electron spawns. */ export const initErrorReporting = () => { if (errorReportingEnabled) { Sentry.init({ dsn: sentryDsn, release: releaseTag(), environment: environmentTag(), initialScope: { tags: { platform: process.platform, arch: process.arch, runId, }, }, }); } else { // No DSN baked in — keep native crash dumps local so a user-reported // crash still leaves minidumps for the diagnostics zip to collect. crashReporter.start({ uploadToServer: false, compress: true }); } // Persist process-death signals to main.log regardless of Sentry — these // are the events a "the app just disappeared" report hinges on. Sentry's // ChildProcess integration reports them upstream; this keeps a local copy. app.on("child-process-gone", (_event, details) => { log.error("[crash] child process gone", details); }); app.on("render-process-gone", (_event, webContents, details) => { log.error("[crash] render process gone", { url: webContents.getURL(), ...details }); }); // Main-process uncaught errors: electron-log writes them to main.log and // keeps the process alive (matching its default), Sentry (when enabled) // captures them via its own integrations. log.errorHandler.startCatching({ showDialog: false }); // Every log line becomes a Sentry breadcrumb, so an error event arrives // with the recent log context (sidecar restarts, update checks, …) instead // of a bare stack. Hooked on the file transport only so each line is // recorded once. No-ops when Sentry is disabled. log.hooks.push((message, transport) => { if (transport !== log.transports.file) return message; Sentry.addBreadcrumb({ category: message.scope ?? "main", level: message.level === "warn" ? "warning" : message.level === "error" ? "error" : "info", message: message.data .map((part) => (typeof part === "string" ? part : JSON.stringify(part))) .join(" ") .slice(0, 1024), }); return message; }); }; /** * Report a sidecar crash that happened after a successful boot. The startup * path already surfaces its own dialog; this covers the "server died under * a running window" case, which is otherwise invisible. */ export const reportSidecarCrash = (message: string, stderrTail: string) => { // No-op when Sentry isn't initialized — captures are dropped client-side. Sentry.captureMessage(message, { level: "error", extra: { stderrTail }, }); }; // --------------------------------------------------------------------------- // Diagnostics export — one zip in ~/Downloads a user can attach to a report. // --------------------------------------------------------------------------- const MAX_EXPORT_FILE_BYTES = 50 * 1024 * 1024; const EXPORT_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000; interface ZipEntry { readonly name: string; readonly path: string; } /** Recursively list files under `dir`, capped by size and age. */ const collectFiles = (dir: string, prefix: string): ZipEntry[] => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fs probing of optional directories (crash dumps may not exist) try { const cutoff = Date.now() - EXPORT_MAX_AGE_MS; return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const full = join(dir, entry.name); if (entry.isDirectory()) return collectFiles(full, `${prefix}/${entry.name}`); if (!entry.isFile()) return []; const info = statSync(full); if (info.size > MAX_EXPORT_FILE_BYTES) return []; if (info.mtimeMs < cutoff) return []; return [{ name: `${prefix}/${entry.name}`, path: full }]; }); } catch { return []; } }; const exportStamp = () => new Date() .toISOString() .replace(/[-:]/g, "") .replace(/\.\d+Z$/, "Z"); const buildManifest = () => { const settings = getServerSettings(); const executorLogs = join(app.getPath("home"), ".executor", "logs"); return { generated: new Date().toISOString(), app: app.getName(), runId, version: app.getVersion(), packaged: app.isPackaged, platform: process.platform, arch: process.arch, versions: process.versions, uptimeSeconds: Math.round(process.uptime()), errorReportingEnabled, paths: { userData: app.getPath("userData"), logs: dirname(log.transports.file.getFile().path), executorLogs, crashDumps: app.getPath("crashDumps"), }, // The bearer token is never included — it stays in auth.json on the machine. serverSettings: { port: settings.port, }, }; }; /** * Pack manifest + electron-log files + sidecar log + crash dumps into * `~/Downloads/executor-diagnostics-.zip` and reveal it in the file * manager. Returns the zip path. */ export const exportDiagnostics = async (): Promise => { const { TextReader, Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } = await import("@zip.js/zip.js"); const { readFile } = await import("node:fs/promises"); const logsDir = dirname(log.transports.file.getFile().path); const executorLogsDir = join(app.getPath("home"), ".executor", "logs"); const entries: ZipEntry[] = [ ...collectFiles(logsDir, "logs"), ...collectFiles(executorLogsDir, "executor-logs"), ...collectFiles(app.getPath("crashDumps"), "crash-dumps"), ]; const writer = new ZipWriter(new Uint8ArrayWriter()); await writer.add("manifest.json", new TextReader(JSON.stringify(buildManifest(), null, 2))); for (const entry of entries) { await writer.add(entry.name, new Uint8ArrayReader(new Uint8Array(await readFile(entry.path)))); } const zipped = await writer.close(); const output = join(app.getPath("downloads"), `executor-diagnostics-${exportStamp()}.zip`); writeFileSync(output, zipped); log.info("[diagnostics] exported", { output, files: entries.length }); shell.showItemInFolder(output); return output; }; /** * "Report a Problem…" menu flow: export the diagnostics zip, then open a * prefilled GitHub issue. The zip is revealed in the file manager so the * user can drag it onto the issue; nothing is uploaded automatically. */ export const reportAProblem = async () => { await exportDiagnosticsInteractive(); const body = [ "", "", "", "---", "", "| | |", "|---|---|", `| Version | ${app.getVersion()} |`, `| OS | ${process.platform} ${process.arch} |`, `| Run ID | ${runId} |`, "", "_A diagnostics zip was saved to your Downloads folder — please drag it into this issue._", ].join("\n"); const url = new URL("https://github.com/UsefulSoftwareCo/executor/issues/new"); url.searchParams.set("title", "[desktop] "); url.searchParams.set("body", body); await shell.openExternal(url.toString()); }; /** Menu-item wrapper: surface failures in a dialog instead of dying silently. */ export const exportDiagnosticsInteractive = async () => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: user-initiated export surfaces failures in a native dialog try { await exportDiagnostics(); } catch (error) { log.error("[diagnostics] export failed", error); // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: fs/zip failures arrive as plain Node errors and render in a native dialog const detail = error instanceof Error ? (error.stack ?? error.message) : String(error); await dialog.showMessageBox({ type: "error", title: "Diagnostics export failed", message: "Couldn't write the diagnostics zip.", detail: `${detail.slice(0, 1200)}\n\nLogs live at: ${dirname(log.transports.file.getFile().path)}`, }); } };