/** * 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<string, string> => 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-<stamp>.zip` and reveal it in the file * manager. Returns the zip path. */export const exportDiagnostics = async (): Promise<string> => { 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 = [ "<!-- Describe what happened and what you expected. -->", "", "", "---", "", "| | |", "|---|---|", `| 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/RhysSullivan/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)}`, }); }};
/**
* 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<string, string> =>
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-<stamp>.zip` and reveal it in the file
* manager. Returns the zip path.
*/
export const exportDiagnostics = async (): Promise<string> => {
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 = [
"<!-- Describe what happened and what you expected. -->",
"",
"",
"---",
"",
"| | |",
"|---|---|",
`| 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/RhysSullivan/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)}`,
});
}
};