+
+ Packs app and server logs, crash dumps, and version info into a zip in your Downloads
+ folder — attach it when reporting a bug. Your sources, secrets, and passwords are not
+ included.
+
+
+ {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */}
+
+ {diagnostics.state === "done" && (
+
+ {diagnostics.path}
+
+ )}
+
+
+ )}
);
From 83624b016a783a3f8a1689bf5022c98834956dbc Mon Sep 17 00:00:00 2001
From: Rhys Sullivan
Date: Thu, 11 Jun 2026 16:05:54 -0700
Subject: [PATCH 2/5] Extend desktop crash reporting to renderer and sidecar,
add Report a Problem
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Renderer: the shared web bundle initializes browser error reporting
only when the desktop preload bridge hands it a DSN at runtime —
nothing is baked into the bundle, so executor web / self-host / cloud
stay inert. Handled UI errors already route through reportError and
are picked up by the global handlers.
- Sidecar: env-gated init in the sidecar entry (DSN passed by the main
process, desktop-with-DSN builds only). Captures uncaught exceptions
and unhandled rejections in the server process. The CLI never sets
the env vars, keeping executor web telemetry-free.
- One runId per launch tags events from all three processes and is
stamped into the diagnostics-zip manifest, so a user-sent zip and its
Sentry events can be cross-referenced.
- electron-log lines become Sentry breadcrumbs (file transport hook),
so events arrive with recent log context.
- 'Report a Problem…' menu item exports the diagnostics zip and opens
a prefilled GitHub issue (version, OS, runId).
Verified against a local fake ingest server: session/event envelopes
received from all three SDKs (browser on boot, electron on forced
sidecar SIGKILL with breadcrumbs attached, bun on forced bind failure).
Compiled-sidecar smoke test passes with @sentry/bun bundled.
---
apps/desktop/package.json | 1 +
apps/desktop/src/main/diagnostics.ts | 87 +++++++++++++++++++++++++++-
apps/desktop/src/main/index.ts | 13 ++++-
apps/desktop/src/main/sidecar.ts | 6 +-
apps/desktop/src/preload/index.ts | 13 +++++
apps/desktop/src/sidecar/server.ts | 24 ++++++++
bun.lock | 44 ++++++++++----
packages/app/package.json | 1 +
packages/app/src/crash-reporting.ts | 54 +++++++++++++++++
packages/app/src/entry-client.tsx | 3 +
10 files changed, 230 insertions(+), 16 deletions(-)
create mode 100644 packages/app/src/crash-reporting.ts
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index a5a2a89d6..e9dd41d18 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -21,6 +21,7 @@
"typecheck:slow": "tsc --noEmit"
},
"dependencies": {
+ "@sentry/bun": "^10.57.0",
"@sentry/electron": "^7.13.0",
"electron-log": "^5",
"electron-store": "^10",
diff --git a/apps/desktop/src/main/diagnostics.ts b/apps/desktop/src/main/diagnostics.ts
index 26c797fc0..586f6dd07 100644
--- a/apps/desktop/src/main/diagnostics.ts
+++ b/apps/desktop/src/main/diagnostics.ts
@@ -29,6 +29,43 @@ const sentryDsn = __EXECUTOR_SENTRY_DSN__;
export const errorReportingEnabled = sentryDsn.length > 0;
+/**
+ * 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.
@@ -37,12 +74,13 @@ export const initErrorReporting = () => {
if (errorReportingEnabled) {
Sentry.init({
dsn: sentryDsn,
- release: `executor-desktop@${app.getVersion()}`,
- environment: app.isPackaged ? "production" : "development",
+ release: releaseTag(),
+ environment: environmentTag(),
initialScope: {
tags: {
platform: process.platform,
arch: process.arch,
+ runId,
},
},
});
@@ -66,6 +104,23 @@ export const initErrorReporting = () => {
// 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;
+ });
};
/**
@@ -123,6 +178,7 @@ const buildManifest = () => {
return {
generated: new Date().toISOString(),
app: app.getName(),
+ runId,
version: app.getVersion(),
packaged: app.isPackaged,
platform: process.platform,
@@ -173,6 +229,33 @@ export const exportDiagnostics = async (): Promise => {
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/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
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index 70066c4a1..a1abf9c72 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -23,7 +23,13 @@ import {
SidecarPortInUseError,
type SidecarConnection,
} from "./sidecar";
-import { exportDiagnostics, exportDiagnosticsInteractive, initErrorReporting } from "./diagnostics";
+import {
+ exportDiagnostics,
+ exportDiagnosticsInteractive,
+ getCrashReportingConfig,
+ initErrorReporting,
+ reportAProblem,
+} from "./diagnostics";
import {
getServerProfiles,
getServerSettings,
@@ -319,6 +325,7 @@ const registerIpcHandlers = () => {
});
ipcMain.handle("executor:server:restart", () => restartSidecarAndReload());
ipcMain.handle("executor:diagnostics:export", () => exportDiagnostics());
+ ipcMain.handle("executor:crash-reporting:get", () => getCrashReportingConfig());
ipcMain.handle("executor:shell:open-external", async (_evt, rawUrl: unknown) => {
if (typeof rawUrl !== "string") return;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: untrusted renderer string, URL ctor throws on malformed input
@@ -484,6 +491,10 @@ const installApplicationMenu = () => {
label: "Export Diagnostics…",
click: () => void exportDiagnosticsInteractive(),
},
+ {
+ label: "Report a Problem…",
+ click: () => void reportAProblem(),
+ },
{ type: "separator" },
...(isMac
? ([
diff --git a/apps/desktop/src/main/sidecar.ts b/apps/desktop/src/main/sidecar.ts
index 98132b34b..0abba986a 100644
--- a/apps/desktop/src/main/sidecar.ts
+++ b/apps/desktop/src/main/sidecar.ts
@@ -24,7 +24,7 @@ import {
serializeExecutorLocalServerManifest,
} from "@executor-js/sdk/shared";
import { getServerSettings } from "./settings";
-import { reportSidecarCrash } from "./diagnostics";
+import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics";
import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings";
// Sidecar output is echoed to the terminal (visible when Electron is run
@@ -286,6 +286,10 @@ export async function startSidecar(options: StartOptions = {}): Promise {
return ipcRenderer.invoke("executor:diagnostics:export");
},
+ /**
+ * Crash-reporting config for the renderer. Null unless this desktop build
+ * shipped with a DSN baked in — the shared web UI only initializes its
+ * error reporting when this returns a config.
+ */
+ getCrashReporting(): Promise<{
+ readonly dsn: string;
+ readonly release: string;
+ readonly environment: string;
+ readonly runId: string;
+ } | null> {
+ return ipcRenderer.invoke("executor:crash-reporting:get");
+ },
} as const;
contextBridge.exposeInMainWorld("executor", api);
diff --git a/apps/desktop/src/sidecar/server.ts b/apps/desktop/src/sidecar/server.ts
index 392acd67b..9c5b5119a 100644
--- a/apps/desktop/src/sidecar/server.ts
+++ b/apps/desktop/src/sidecar/server.ts
@@ -39,6 +39,30 @@ if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) {
setQuickJSModule(mod);
}
+// Crash reporting — only when the Electron main process handed us a DSN
+// (desktop builds with SENTRY_DSN baked in). `executor web` and self-host
+// never set these env vars, so this stays inert everywhere else. Captures
+// uncaught exceptions / unhandled rejections in the server process; the
+// shared runId ties events to the main process and diagnostics zip.
+const sentryDsn = process.env.EXECUTOR_SENTRY_DSN;
+if (sentryDsn) {
+ const Sentry = await import("@sentry/bun");
+ Sentry.init({
+ dsn: sentryDsn,
+ release: process.env.EXECUTOR_SENTRY_RELEASE,
+ environment: process.env.EXECUTOR_SENTRY_ENVIRONMENT ?? "production",
+ tracesSampleRate: 0,
+ initialScope: {
+ tags: {
+ process: "sidecar",
+ platform: process.platform,
+ arch: process.arch,
+ ...(process.env.EXECUTOR_RUN_ID ? { runId: process.env.EXECUTOR_RUN_ID } : {}),
+ },
+ },
+ });
+}
+
import { startServer } from "@executor-js/local";
const requestedPort = parseInt(process.env.EXECUTOR_PORT ?? "0", 10);
diff --git a/bun.lock b/bun.lock
index 9a07ea3e6..6d28bd9d6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -123,6 +123,7 @@
"name": "@executor-js/desktop",
"version": "1.5.4",
"dependencies": {
+ "@sentry/bun": "^10.57.0",
"@sentry/electron": "^7.13.0",
"electron-log": "^5",
"electron-store": "^10",
@@ -402,6 +403,7 @@
"@executor-js/react": "workspace:*",
"@executor-js/sdk": "workspace:*",
"@executor-js/vite-plugin": "workspace:*",
+ "@sentry/browser": "^10.57.0",
"@tanstack/react-router": "catalog:",
"effect": "catalog:",
"lucide-react": "^1.7.0",
@@ -2450,27 +2452,31 @@
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
- "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.48.0", "", { "dependencies": { "@sentry/core": "10.48.0" } }, "sha512-SCiTLBXzugFKxev6NoKYBIhQoDk0gUh0AVVVepCBqfCJiWBG01Zvv0R5tCVohr4cWRllkQ8mlBdNQd/I7s9tdA=="],
+ "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.57.0", "", { "dependencies": { "@sentry/core": "10.57.0" } }, "sha512-tXObp954rMTSYKlbftjVXHtNl4t/6ssks3jkqyzmKb+PDPWzabGQO7sWwqVuTjT8Kx/8A3FmriS1bGmqxiJy3A=="],
- "@sentry-internal/feedback": ["@sentry-internal/feedback@10.48.0", "", { "dependencies": { "@sentry/core": "10.48.0" } }, "sha512-tGkEyOM1HDS9qebDphUMEnyk3qq/50AnuTBiFmMJyjNzowylVGmRRk0sr3xkmbVHCDXQCiYnDmSVlJ2x4SDMrQ=="],
+ "@sentry-internal/feedback": ["@sentry-internal/feedback@10.57.0", "", { "dependencies": { "@sentry/core": "10.57.0" } }, "sha512-ZcF4QhkqGX3iiQSXB2N0N3Awp+j5iqnDRu6PA/qyLFrWqH5ZiiAAgu59OLD9E6XAdg6iFtLYw19MAMZVK8qNOQ=="],
- "@sentry-internal/replay": ["@sentry-internal/replay@10.48.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.48.0", "@sentry/core": "10.48.0" } }, "sha512-sevRTePfuk4PNuz9KAKpmTZEomAU0aLXyIhOwA0OnUDdxPhkY8kq5lwDbuxTHv6DQUjUX3YgFbY45VH1JEqHKA=="],
+ "@sentry-internal/replay": ["@sentry-internal/replay@10.57.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.57.0", "@sentry/core": "10.57.0" } }, "sha512-Wmnx/6ABynVH1iwuoNUqJNyjIUqsqoGML7qsyivBRKb5Wo2YQtPOQlQYfxfZSvWzGpcoSVdInkRjDssUQxQEQg=="],
- "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.48.0", "", { "dependencies": { "@sentry-internal/replay": "10.48.0", "@sentry/core": "10.48.0" } }, "sha512-9nWuN2z4O+iwbTfuYV5ZmngBgJU/ZxfOo47A5RJP3Nu/kl59aJ1lUhILYOKyeNOIC/JyeERmpIcTxnlPXQzZ3Q=="],
+ "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.57.0", "", { "dependencies": { "@sentry-internal/replay": "10.57.0", "@sentry/core": "10.57.0" } }, "sha512-zsfa4JcfV0AEc9YhNxNabd5lSZL2Av84saAyexGAqcHs+67m9Gd0cGStOzMb/nCl7UAtmdP0aI+G7a3rcxxN/A=="],
- "@sentry/browser": ["@sentry/browser@10.48.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.48.0", "@sentry-internal/feedback": "10.48.0", "@sentry-internal/replay": "10.48.0", "@sentry-internal/replay-canvas": "10.48.0", "@sentry/core": "10.48.0" } }, "sha512-4jt2zX2ExgFcNe2x+W+/k81fmDUsOrquGtt028CiGuDuma6kEsWBI4JbooT1jhj2T+eeUxe3YGbM23Zhh7Ghhw=="],
+ "@sentry-internal/server-utils": ["@sentry-internal/server-utils@10.57.0", "", { "dependencies": { "@sentry/core": "10.57.0" } }, "sha512-Qu8ETmX/ITzteG7Im46b9HOxKKzeaIeqNvftaIlFURu1RUQdHbtGerS7QOmXzwnhuqNGNeiCQYkduB798IfRqA=="],
+
+ "@sentry/browser": ["@sentry/browser@10.57.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.57.0", "@sentry-internal/feedback": "10.57.0", "@sentry-internal/replay": "10.57.0", "@sentry-internal/replay-canvas": "10.57.0", "@sentry/core": "10.57.0" } }, "sha512-s36AQy/CKXTfyY9Z+qUhzNomntZXgfs0rbaK7q9ffnFkqcPwzE8qQtVs58y3Suut56u+AhwSztgQtERcuZ5VIA=="],
+
+ "@sentry/bun": ["@sentry/bun@10.57.0", "", { "dependencies": { "@sentry/core": "10.57.0", "@sentry/node": "10.57.0" } }, "sha512-bh62U3UiXYs0Uu37DX0vfO+JdwXXG16KvMUUHi/KfQdFO5VMuCDTdtLEMt+1fEXnjOi7DfQaaItoNPkagQc52w=="],
"@sentry/cloudflare": ["@sentry/cloudflare@10.48.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/core": "10.48.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-i02Ps4/cJjFpbcHLMhNEFXTeVqLB9XpB3+/OFQ9aMFV3yDcxlvHwe0oo7WZf41iroArvpysotLG8Y8NBOU9omA=="],
- "@sentry/core": ["@sentry/core@10.48.0", "", {}, "sha512-h8F+fXVwYC9ro5ZaO8V+v3vqc0awlXHGblEAuVxSGgh4IV/oFX+QVzXeDTTrFOFS6v/Vn5vAyu240eJrJAS6/g=="],
+ "@sentry/core": ["@sentry/core@10.57.0", "", {}, "sha512-kntItTA2kiT0YpL7encXaF6mkdZMB+y48lwj8w1wkfBpfJAC7sifdgrzLQZqmsqVNE3crg9VfufaAGA+78uFMg=="],
"@sentry/electron": ["@sentry/electron@7.13.0", "", { "dependencies": { "@sentry/browser": "10.50.0", "@sentry/core": "10.50.0", "@sentry/node": "10.50.0" }, "peerDependencies": { "@sentry/node-native": "10.50.0" }, "optionalPeers": ["@sentry/node-native"] }, "sha512-zW/1c9fKafCZsvhRRp9mIDH9bSvzBiIUwoN087zDDHc0vatVsqqai8nUk0bUewwYZY4Inia7r5w+kPWi8LXZzg=="],
- "@sentry/node": ["@sentry/node@10.50.0", "", { "dependencies": { "@fastify/otel": "0.18.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-amqplib": "0.61.0", "@opentelemetry/instrumentation-connect": "0.57.0", "@opentelemetry/instrumentation-dataloader": "0.31.0", "@opentelemetry/instrumentation-fs": "0.33.0", "@opentelemetry/instrumentation-generic-pool": "0.57.0", "@opentelemetry/instrumentation-graphql": "0.62.0", "@opentelemetry/instrumentation-hapi": "0.60.0", "@opentelemetry/instrumentation-http": "0.214.0", "@opentelemetry/instrumentation-ioredis": "0.62.0", "@opentelemetry/instrumentation-kafkajs": "0.23.0", "@opentelemetry/instrumentation-knex": "0.58.0", "@opentelemetry/instrumentation-koa": "0.62.0", "@opentelemetry/instrumentation-lru-memoizer": "0.58.0", "@opentelemetry/instrumentation-mongodb": "0.67.0", "@opentelemetry/instrumentation-mongoose": "0.60.0", "@opentelemetry/instrumentation-mysql": "0.60.0", "@opentelemetry/instrumentation-mysql2": "0.60.0", "@opentelemetry/instrumentation-pg": "0.66.0", "@opentelemetry/instrumentation-redis": "0.62.0", "@opentelemetry/instrumentation-tedious": "0.33.0", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@prisma/instrumentation": "7.6.0", "@sentry/core": "10.50.0", "@sentry/node-core": "10.50.0", "@sentry/opentelemetry": "10.50.0", "import-in-the-middle": "^3.0.0" } }, "sha512-TvwzFQu8MGKzMQ2/tqxcNzFA8UG2kKTB+GDmA4uOzx3+GT849YZRRSJzEXCmYhk1teVd2fbmgqyYY2nyLF5a+Q=="],
+ "@sentry/node": ["@sentry/node@10.57.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@sentry-internal/server-utils": "10.57.0", "@sentry/core": "10.57.0", "@sentry/node-core": "10.57.0", "@sentry/opentelemetry": "10.57.0", "import-in-the-middle": "^3.0.0" } }, "sha512-7KEStrJ97wPf1fA5nU5ONeTTcIIlh7oT8OMffEVA1PXmlhFoXhcQZVzr4rM+zj9tfMWT01og5Ng/Grgh3dN+FA=="],
- "@sentry/node-core": ["@sentry/node-core@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0", "@sentry/opentelemetry": "10.50.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base", "@opentelemetry/semantic-conventions"] }, "sha512-Eb1BYf4Lc7ZYmdX3acKP6SgyGikrBA370gbGHaWI5jRu7G7vig8sIu1ghPmY5AlvqBPOetado7GniXr6fAXbTw=="],
+ "@sentry/node-core": ["@sentry/node-core@10.57.0", "", { "dependencies": { "@sentry/core": "10.57.0", "@sentry/opentelemetry": "10.57.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base", "@opentelemetry/semantic-conventions"] }, "sha512-2v2IF6MfTiu7pimWEq2rYhZsmlwyNbs3bHUsrYFPeP/Rpa6ObDuUWPdVEzJjfyK+AqqYZYxZdV0l3+B13kTEmQ=="],
- "@sentry/opentelemetry": ["@sentry/opentelemetry@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" } }, "sha512-axn3pgDPveGdaMUC0abMCmFN7ux2pA5ebPufCef4lMIsyg7BBQvaEJ+vE19wjstMaBCAJGsdZlL3eeP2rtgRMw=="],
+ "@sentry/opentelemetry": ["@sentry/opentelemetry@10.57.0", "", { "dependencies": { "@sentry/core": "10.57.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" } }, "sha512-iwRz8cEK0GOISG34aJRO8GdYOk3nfpuT6dT2GDQrxw8f7JjkJKx9LPU8MaenOFa4MhY+Z02hI6NNcrbsoI3cXg=="],
"@sentry/react": ["@sentry/react@10.48.0", "", { "dependencies": { "@sentry/browser": "10.48.0", "@sentry/core": "10.48.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-uc93vKjmu6gNns+JAX4qquuxWpAMit0uGPA1TYlMjct9NG1uX3TkDPJAr9Pgd1lOXx8mKqCmj5fK33QeExMpPw=="],
@@ -5430,15 +5436,17 @@
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
+ "@sentry/cloudflare/@sentry/core": ["@sentry/core@10.48.0", "", {}, "sha512-h8F+fXVwYC9ro5ZaO8V+v3vqc0awlXHGblEAuVxSGgh4IV/oFX+QVzXeDTTrFOFS6v/Vn5vAyu240eJrJAS6/g=="],
+
"@sentry/electron/@sentry/browser": ["@sentry/browser@10.50.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.50.0", "@sentry-internal/feedback": "10.50.0", "@sentry-internal/replay": "10.50.0", "@sentry-internal/replay-canvas": "10.50.0", "@sentry/core": "10.50.0" } }, "sha512-1f6rAvET6myiTaSeYqvaaBwvq1LfxqWjAPIoAW/NVC9bPMkeEcuvgDajHrnZMrBeWoJ81NMyoLkyX+iOc7MoFA=="],
"@sentry/electron/@sentry/core": ["@sentry/core@10.50.0", "", {}, "sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg=="],
- "@sentry/node/@sentry/core": ["@sentry/core@10.50.0", "", {}, "sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg=="],
+ "@sentry/electron/@sentry/node": ["@sentry/node@10.50.0", "", { "dependencies": { "@fastify/otel": "0.18.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-amqplib": "0.61.0", "@opentelemetry/instrumentation-connect": "0.57.0", "@opentelemetry/instrumentation-dataloader": "0.31.0", "@opentelemetry/instrumentation-fs": "0.33.0", "@opentelemetry/instrumentation-generic-pool": "0.57.0", "@opentelemetry/instrumentation-graphql": "0.62.0", "@opentelemetry/instrumentation-hapi": "0.60.0", "@opentelemetry/instrumentation-http": "0.214.0", "@opentelemetry/instrumentation-ioredis": "0.62.0", "@opentelemetry/instrumentation-kafkajs": "0.23.0", "@opentelemetry/instrumentation-knex": "0.58.0", "@opentelemetry/instrumentation-koa": "0.62.0", "@opentelemetry/instrumentation-lru-memoizer": "0.58.0", "@opentelemetry/instrumentation-mongodb": "0.67.0", "@opentelemetry/instrumentation-mongoose": "0.60.0", "@opentelemetry/instrumentation-mysql": "0.60.0", "@opentelemetry/instrumentation-mysql2": "0.60.0", "@opentelemetry/instrumentation-pg": "0.66.0", "@opentelemetry/instrumentation-redis": "0.62.0", "@opentelemetry/instrumentation-tedious": "0.33.0", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@prisma/instrumentation": "7.6.0", "@sentry/core": "10.50.0", "@sentry/node-core": "10.50.0", "@sentry/opentelemetry": "10.50.0", "import-in-the-middle": "^3.0.0" } }, "sha512-TvwzFQu8MGKzMQ2/tqxcNzFA8UG2kKTB+GDmA4uOzx3+GT849YZRRSJzEXCmYhk1teVd2fbmgqyYY2nyLF5a+Q=="],
- "@sentry/node-core/@sentry/core": ["@sentry/core@10.50.0", "", {}, "sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg=="],
+ "@sentry/react/@sentry/browser": ["@sentry/browser@10.48.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.48.0", "@sentry-internal/feedback": "10.48.0", "@sentry-internal/replay": "10.48.0", "@sentry-internal/replay-canvas": "10.48.0", "@sentry/core": "10.48.0" } }, "sha512-4jt2zX2ExgFcNe2x+W+/k81fmDUsOrquGtt028CiGuDuma6kEsWBI4JbooT1jhj2T+eeUxe3YGbM23Zhh7Ghhw=="],
- "@sentry/opentelemetry/@sentry/core": ["@sentry/core@10.50.0", "", {}, "sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg=="],
+ "@sentry/react/@sentry/core": ["@sentry/core@10.48.0", "", {}, "sha512-h8F+fXVwYC9ro5ZaO8V+v3vqc0awlXHGblEAuVxSGgh4IV/oFX+QVzXeDTTrFOFS6v/Vn5vAyu240eJrJAS6/g=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
@@ -6022,6 +6030,18 @@
"@sentry/electron/@sentry/browser/@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.50.0", "", { "dependencies": { "@sentry-internal/replay": "10.50.0", "@sentry/core": "10.50.0" } }, "sha512-jx6RKBmcJSWdI92qDGS/sBv1w+7Cww879Z/moX7bw7ipHa/Ts3iDcB3rgZwvhmi17U+mvYsbJeL2DXkPo3TjPw=="],
+ "@sentry/electron/@sentry/node/@sentry/node-core": ["@sentry/node-core@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0", "@sentry/opentelemetry": "10.50.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base", "@opentelemetry/semantic-conventions"] }, "sha512-Eb1BYf4Lc7ZYmdX3acKP6SgyGikrBA370gbGHaWI5jRu7G7vig8sIu1ghPmY5AlvqBPOetado7GniXr6fAXbTw=="],
+
+ "@sentry/electron/@sentry/node/@sentry/opentelemetry": ["@sentry/opentelemetry@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" } }, "sha512-axn3pgDPveGdaMUC0abMCmFN7ux2pA5ebPufCef4lMIsyg7BBQvaEJ+vE19wjstMaBCAJGsdZlL3eeP2rtgRMw=="],
+
+ "@sentry/react/@sentry/browser/@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.48.0", "", { "dependencies": { "@sentry/core": "10.48.0" } }, "sha512-SCiTLBXzugFKxev6NoKYBIhQoDk0gUh0AVVVepCBqfCJiWBG01Zvv0R5tCVohr4cWRllkQ8mlBdNQd/I7s9tdA=="],
+
+ "@sentry/react/@sentry/browser/@sentry-internal/feedback": ["@sentry-internal/feedback@10.48.0", "", { "dependencies": { "@sentry/core": "10.48.0" } }, "sha512-tGkEyOM1HDS9qebDphUMEnyk3qq/50AnuTBiFmMJyjNzowylVGmRRk0sr3xkmbVHCDXQCiYnDmSVlJ2x4SDMrQ=="],
+
+ "@sentry/react/@sentry/browser/@sentry-internal/replay": ["@sentry-internal/replay@10.48.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.48.0", "@sentry/core": "10.48.0" } }, "sha512-sevRTePfuk4PNuz9KAKpmTZEomAU0aLXyIhOwA0OnUDdxPhkY8kq5lwDbuxTHv6DQUjUX3YgFbY45VH1JEqHKA=="],
+
+ "@sentry/react/@sentry/browser/@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.48.0", "", { "dependencies": { "@sentry-internal/replay": "10.48.0", "@sentry/core": "10.48.0" } }, "sha512-9nWuN2z4O+iwbTfuYV5ZmngBgJU/ZxfOo47A5RJP3Nu/kl59aJ1lUhILYOKyeNOIC/JyeERmpIcTxnlPXQzZ3Q=="],
+
"@types/better-sqlite3/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"@types/cacheable-request/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
diff --git a/packages/app/package.json b/packages/app/package.json
index e14e9e288..479d51e67 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -22,6 +22,7 @@
"@executor-js/react": "workspace:*",
"@executor-js/sdk": "workspace:*",
"@executor-js/vite-plugin": "workspace:*",
+ "@sentry/browser": "^10.57.0",
"@tanstack/react-router": "catalog:",
"effect": "catalog:",
"lucide-react": "^1.7.0",
diff --git a/packages/app/src/crash-reporting.ts b/packages/app/src/crash-reporting.ts
new file mode 100644
index 000000000..e06146aa7
--- /dev/null
+++ b/packages/app/src/crash-reporting.ts
@@ -0,0 +1,54 @@
+/**
+ * Desktop-only renderer crash reporting.
+ *
+ * This bundle is served identically to `executor web`, self-host, and the
+ * desktop app, so nothing is baked in at build time. Inside the desktop app
+ * the preload bridge (`window.executor`) hands over a DSN at runtime —
+ * everywhere else the bridge is absent (or returns null in DSN-less builds)
+ * and Sentry is never imported, let alone initialized.
+ *
+ * Handled UI errors already flow through `globalThis.reportError` (see
+ * packages/react error-reporting), which Sentry's global handlers pick up
+ * once initialized — no reporter rewiring needed.
+ */
+
+interface CrashReportingConfig {
+ readonly dsn: string;
+ readonly release: string;
+ readonly environment: string;
+ readonly runId: string;
+}
+
+interface CrashReportingBridge {
+ readonly getCrashReporting?: () => Promise;
+}
+
+export const initDesktopCrashReporting = (): void => {
+ if (typeof window === "undefined") return;
+ const bridge = (window as Window & { readonly executor?: CrashReportingBridge }).executor;
+ if (typeof bridge?.getCrashReporting !== "function") return;
+ const init = async () => {
+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: crash reporting must never take the app down with it
+ try {
+ const config = await bridge.getCrashReporting?.();
+ if (!config?.dsn) return;
+ const Sentry = await import("@sentry/browser");
+ Sentry.init({
+ dsn: config.dsn,
+ release: config.release,
+ environment: config.environment,
+ sendDefaultPii: false,
+ tracesSampleRate: 0,
+ initialScope: {
+ tags: {
+ process: "renderer",
+ runId: config.runId,
+ },
+ },
+ });
+ } catch {
+ // Reporting failures stay silent — there is nowhere left to report them.
+ }
+ };
+ void init();
+};
diff --git a/packages/app/src/entry-client.tsx b/packages/app/src/entry-client.tsx
index 21ffe64d6..437785c65 100644
--- a/packages/app/src/entry-client.tsx
+++ b/packages/app/src/entry-client.tsx
@@ -2,8 +2,11 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "@tanstack/react-router";
import { getRouter } from "./router";
+import { initDesktopCrashReporting } from "./crash-reporting";
import "@executor-js/react/globals.css";
+initDesktopCrashReporting();
+
const router = getRouter();
ReactDOM.createRoot(document.getElementById("root")!).render();
From ed9d2649d7bb1b221df5a9abe4e91caf70b2c431 Mon Sep 17 00:00:00 2001
From: Rhys Sullivan
Date: Thu, 11 Jun 2026 16:24:43 -0700
Subject: [PATCH 3/5] Honor DO_NOT_TRACK, add sidecar crash screen, prove the
flow on camera
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- DO_NOT_TRACK=1 (or true) disables crash reporting in all three
processes — checked once in the main process, which is the single
source of the renderer's and sidecar's reporting config.
- When the sidecar dies under a live window the app now swaps the dead
web UI for an in-window crash screen (data: URL, preload bridge
intact) with Restart server / Export diagnostics actions. Restart
drives the existing sidecar restart IPC and reloads the console.
- New e2e desktop target + scenario: launches the real Electron app in
a throwaway HOME via Playwright's electron driver, SIGKILLs the
sidecar, asserts the crash screen, recovers via its Restart button,
and asserts the healed sidecar is a new pid. Produces session.mp4 +
per-step screenshots under e2e/runs/desktop/. Run with
`npm run test:desktop` in e2e/ (needs a display).
---
apps/desktop/src/main/crash-screen.ts | 85 ++++++++++++++++
apps/desktop/src/main/diagnostics.ts | 8 +-
apps/desktop/src/main/index.ts | 12 +++
apps/desktop/src/main/sidecar.ts | 9 ++
e2e/desktop/sidecar-crash-screen.test.ts | 124 +++++++++++++++++++++++
e2e/package.json | 3 +-
e2e/setup/desktop.globalsetup.ts | 21 ++++
e2e/targets/desktop.ts | 17 ++++
e2e/targets/registry.ts | 2 +
e2e/vitest.config.ts | 10 ++
10 files changed, 289 insertions(+), 2 deletions(-)
create mode 100644 apps/desktop/src/main/crash-screen.ts
create mode 100644 e2e/desktop/sidecar-crash-screen.test.ts
create mode 100644 e2e/setup/desktop.globalsetup.ts
create mode 100644 e2e/targets/desktop.ts
diff --git a/apps/desktop/src/main/crash-screen.ts b/apps/desktop/src/main/crash-screen.ts
new file mode 100644
index 000000000..fd64c95cd
--- /dev/null
+++ b/apps/desktop/src/main/crash-screen.ts
@@ -0,0 +1,85 @@
+/**
+ * In-window screen shown when the sidecar dies under a running window.
+ * Replaces the dead web UI (which would otherwise sit there failing every
+ * fetch) with an explanation and a recovery path. Rendered as a data: URL
+ * in the existing BrowserWindow, so the preload bridge stays available —
+ * the buttons drive the same `window.executor` IPC the settings page uses.
+ */
+
+export interface CrashScreenOptions {
+ /** Whether a crash report was sent upstream (DSN build, not opted out). */
+ readonly reported: boolean;
+}
+
+export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `
+
+
+
+ Executor
+
+
+
+
+
⚠️
+
The local Executor server stopped unexpectedly
+
+ Your data is safe.${reported ? " A crash report was sent automatically so this can get fixed." : ""}
+ Restart the server to keep working.
+
+
+
+
+
+
+
+
+
+`;
diff --git a/apps/desktop/src/main/diagnostics.ts b/apps/desktop/src/main/diagnostics.ts
index 586f6dd07..98cdb8b13 100644
--- a/apps/desktop/src/main/diagnostics.ts
+++ b/apps/desktop/src/main/diagnostics.ts
@@ -27,7 +27,13 @@ import { getServerSettings } from "./settings";
const sentryDsn = __EXECUTOR_SENTRY_DSN__;
-export const errorReportingEnabled = sentryDsn.length > 0;
+// 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)
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index a1abf9c72..7afb623b1 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -20,16 +20,19 @@ type UpdateInfo = { readonly version: string };
import {
startSidecar,
stopSidecar,
+ onUnexpectedSidecarExit,
SidecarPortInUseError,
type SidecarConnection,
} from "./sidecar";
import {
+ errorReportingEnabled,
exportDiagnostics,
exportDiagnosticsInteractive,
getCrashReportingConfig,
initErrorReporting,
reportAProblem,
} from "./diagnostics";
+import { sidecarCrashHtml } from "./crash-screen";
import {
getServerProfiles,
getServerSettings,
@@ -524,6 +527,15 @@ const boot = async () => {
installApplicationMenu();
setupAutoUpdater();
registerIpcHandlers();
+ // A sidecar that dies under a live window would leave the web UI failing
+ // every request with no explanation. Swap in the crash screen — its
+ // buttons drive the regular preload bridge (restart / export diagnostics).
+ onUnexpectedSidecarExit(() => {
+ const window = liveMainWindow();
+ if (!window) return;
+ const html = sidecarCrashHtml({ reported: errorReportingEnabled });
+ void window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
+ });
connection = await startWithCurrentSettings();
if (!connection) {
// Port conflicts already showed their dialog inside
diff --git a/apps/desktop/src/main/sidecar.ts b/apps/desktop/src/main/sidecar.ts
index 0abba986a..78ea39746 100644
--- a/apps/desktop/src/main/sidecar.ts
+++ b/apps/desktop/src/main/sidecar.ts
@@ -40,6 +40,14 @@ const STDERR_TAIL_LIMIT = 8 * 1024;
// their exits are expected and must not be reported as crashes.
const expectedExits = new WeakSet();
+// Main/index.ts subscribes to swap the dead web UI for the in-window crash
+// screen. A callback (not an import) keeps this module free of window
+// concerns.
+let unexpectedExitListener: (() => void) | null = null;
+export const onUnexpectedSidecarExit = (listener: () => void) => {
+ unexpectedExitListener = listener;
+};
+
/** Buffer chunked output into whole lines before handing them to `write`. */
const makeLineSplitter = (write: (line: string) => void) => {
let buffer = "";
@@ -368,6 +376,7 @@ export async function startSidecar(options: StartOptions = {}): Promise run(runDir));
+ }),
+);
+
+const run = async (runDir: string) => {
+ // Throwaway HOME = fresh ~/.executor data dir, fresh electron-store
+ // settings, no collision with a real desktop install on this machine.
+ const home = mkdtempSync(join(tmpdir(), "executor-desktop-e2e-"));
+ const videoTmp = join(runDir, ".video-tmp");
+ let stepIndex = 0;
+
+ const app = await _electron.launch({
+ executablePath: electronBinary,
+ args: [appDir],
+ cwd: appDir,
+ env: { ...process.env, HOME: home },
+ recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } },
+ timeout: 120_000,
+ });
+
+ try {
+ // firstWindow resolves only after the sidecar boots (the window is
+ // created with the server's URL) — this wait IS the boot assertion.
+ const page = await app.firstWindow({ timeout: 120_000 });
+ const step = async (label: string, body: () => Promise) => {
+ await body();
+ stepIndex += 1;
+ const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-");
+ await page.screenshot({
+ path: join(runDir, `${String(stepIndex).padStart(2, "0")}-${slug}.png`),
+ });
+ };
+
+ await step("app boots into the web console", async () => {
+ await page.getByText("Settings").first().waitFor({ timeout: 120_000 });
+ });
+
+ let sidecarPid = 0;
+ await step("the local server is killed (SIGKILL)", async () => {
+ const manifestPath = join(home, ".executor/server-control/server.json");
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { pid: number };
+ sidecarPid = manifest.pid;
+ expect(sidecarPid, "sidecar pid recorded in the server manifest").toBeGreaterThan(0);
+ process.kill(sidecarPid, "SIGKILL");
+ await page.getByText("stopped unexpectedly").waitFor({ timeout: 30_000 });
+ });
+
+ await step("crash screen offers restart and diagnostics", async () => {
+ await page.locator("#restart").waitFor({ timeout: 5_000 });
+ await page.locator("#export").waitFor({ timeout: 5_000 });
+ });
+
+ await step("restart server heals the app", async () => {
+ await page.locator("#restart").click();
+ await page.getByText("Settings").first().waitFor({ timeout: 120_000 });
+ });
+
+ const healedManifest = JSON.parse(
+ readFileSync(join(home, ".executor/server-control/server.json"), "utf8"),
+ ) as { pid: number };
+ expect(healedManifest.pid, "restarted sidecar is a new process").not.toBe(sidecarPid);
+ } finally {
+ const page = app.windows()[0];
+ const video = page?.video();
+ await app.close().catch(() => {});
+ const recordedPath = await video?.path().catch(() => undefined);
+ if (recordedPath && existsSync(recordedPath)) {
+ // mp4 plays everywhere (Safari/iOS don't do webm) — same treatment as
+ // the browser surface.
+ await promisify(execFile)("ffmpeg", [
+ "-y",
+ "-i",
+ recordedPath,
+ "-c:v",
+ "libx264",
+ "-preset",
+ "veryfast",
+ "-crf",
+ "26",
+ "-pix_fmt",
+ "yuv420p",
+ "-movflags",
+ "+faststart",
+ join(runDir, "session.mp4"),
+ ]).catch(() => {});
+ }
+ rmSync(videoTmp, { recursive: true, force: true });
+ rmSync(home, { recursive: true, force: true });
+ }
+};
diff --git a/e2e/package.json b/e2e/package.json
index c204e9a3a..6cd994567 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -10,7 +10,8 @@
"test:watch": "vitest",
"viewer:build": "bun scripts/rebuild-viewer.ts",
"serve": "bun scripts/rebuild-viewer.ts && bun scripts/serve.ts",
- "typecheck": "tsc --noEmit"
+ "typecheck": "tsc --noEmit",
+ "test:desktop": "vitest run --project desktop"
},
"dependencies": {
"@executor-js/api": "workspace:*",
diff --git a/e2e/setup/desktop.globalsetup.ts b/e2e/setup/desktop.globalsetup.ts
new file mode 100644
index 000000000..ab2d74a2d
--- /dev/null
+++ b/e2e/setup/desktop.globalsetup.ts
@@ -0,0 +1,21 @@
+// Desktop project setup: make sure the bits the Electron app loads at
+// runtime exist — the web UI bundle (served by the sidecar) and the
+// electron-vite main/preload output. Always rebuilt so a run never tests
+// stale code; both builds are incremental-fast. No server to boot: each
+// scenario launches its own app process against a throwaway HOME.
+import { execFileSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
+const appsDesktop = fileURLToPath(new URL("../../apps/desktop/", import.meta.url));
+
+export default function setup() {
+ execFileSync("bun", ["run", "--filter", "@executor-js/local", "build"], {
+ cwd: repoRoot,
+ stdio: "inherit",
+ });
+ execFileSync("bunx", ["--bun", "electron-vite", "build"], {
+ cwd: appsDesktop,
+ stdio: "inherit",
+ });
+}
diff --git a/e2e/targets/desktop.ts b/e2e/targets/desktop.ts
new file mode 100644
index 000000000..722ca08ac
--- /dev/null
+++ b/e2e/targets/desktop.ts
@@ -0,0 +1,17 @@
+// The Electron desktop app as a target. Unlike cloud/selfhost there is no
+// long-lived instance to point a browser at — each scenario launches its own
+// app process (Playwright's electron driver) against a throwaway HOME, so
+// the target carries no capabilities and scenarios under e2e/desktop/ drive
+// the app themselves. Identity is the local OS user; there is nothing to
+// mint.
+import { Effect } from "effect";
+
+import type { Target } from "../src/target";
+
+export const desktopTarget = (): Target => ({
+ name: "desktop",
+ baseUrl: "",
+ mcpUrl: "",
+ capabilities: new Set(),
+ newIdentity: () => Effect.succeed({ label: "desktop-local-user" }),
+});
diff --git a/e2e/targets/registry.ts b/e2e/targets/registry.ts
index d1bfb0df4..342c48513 100644
--- a/e2e/targets/registry.ts
+++ b/e2e/targets/registry.ts
@@ -3,11 +3,13 @@
// vitest.config.ts + a globalsetup that boots (or attaches to) the instance.
import type { Target } from "../src/target";
import { cloudTarget } from "./cloud";
+import { desktopTarget } from "./desktop";
import { selfhostTarget } from "./selfhost";
const factories: Record Target> = {
cloud: cloudTarget,
selfhost: selfhostTarget,
+ desktop: desktopTarget,
};
let current: Target | undefined;
diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts
index 47ab689c8..ae65a01f7 100644
--- a/e2e/vitest.config.ts
+++ b/e2e/vitest.config.ts
@@ -26,6 +26,16 @@ export default defineConfig({
// selfhost identities are the shared bootstrap admin for now — run files
// serially until per-test invite-signup isolation lands.
project("selfhost", { fileParallelism: false }),
+ // The Electron desktop app. Only desktop/** scenarios — the desktop
+ // target provides none of the standard surfaces (each scenario
+ // launches its own app via Playwright's electron driver), so running
+ // the cross-target suite here would just emit a page of skips. Needs
+ // a display; not part of the default `npm run test` chain.
+ project("desktop", {
+ include: ["desktop/**/*.test.ts"],
+ fileParallelism: false,
+ testTimeout: 300_000,
+ }),
],
},
});
From 87c472ad13b2dfeb62d6d88fdffb2005eec8749d Mon Sep 17 00:00:00 2001
From: Rhys Sullivan
Date: Thu, 11 Jun 2026 16:56:18 -0700
Subject: [PATCH 4/5] Rename the desktop crash-report DSN variable to
DESKTOP_SENTRY_DSN
Repo-level SENTRY_DSN was ambiguous next to cloud's own Sentry config;
the variable now names its surface.
---
.github/workflows/publish-desktop.yml | 2 +-
apps/desktop/electron.vite.config.ts | 2 +-
apps/desktop/src/main/diagnostics.ts | 2 +-
apps/desktop/src/sidecar/server.ts | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml
index 19dc4af89..49ad7affc 100644
--- a/.github/workflows/publish-desktop.yml
+++ b/.github/workflows/publish-desktop.yml
@@ -111,7 +111,7 @@ jobs:
# Crash-report DSN baked into the main bundle (see the define in
# electron.vite.config.ts). Unset (forks, local) → crash reporting
# is compiled out and dumps stay local.
- SENTRY_DSN: ${{ vars.SENTRY_DSN }}
+ DESKTOP_SENTRY_DSN: ${{ vars.DESKTOP_SENTRY_DSN }}
run: bunx --bun electron-vite build
working-directory: apps/desktop
diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts
index c24e348ed..9367117cc 100644
--- a/apps/desktop/electron.vite.config.ts
+++ b/apps/desktop/electron.vite.config.ts
@@ -24,7 +24,7 @@ export default defineConfig({
define: {
// Crash-report DSN baked in at build time (publish-desktop.yml).
// Empty in local/dev builds → Sentry stays fully disabled.
- __EXECUTOR_SENTRY_DSN__: JSON.stringify(process.env.SENTRY_DSN ?? ""),
+ __EXECUTOR_SENTRY_DSN__: JSON.stringify(process.env.DESKTOP_SENTRY_DSN ?? ""),
},
build: {
rollupOptions: {
diff --git a/apps/desktop/src/main/diagnostics.ts b/apps/desktop/src/main/diagnostics.ts
index 98cdb8b13..ea02eb2b4 100644
--- a/apps/desktop/src/main/diagnostics.ts
+++ b/apps/desktop/src/main/diagnostics.ts
@@ -2,7 +2,7 @@
* 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 SENTRY_DSN; see the define
+ * 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.
diff --git a/apps/desktop/src/sidecar/server.ts b/apps/desktop/src/sidecar/server.ts
index 9c5b5119a..99baac858 100644
--- a/apps/desktop/src/sidecar/server.ts
+++ b/apps/desktop/src/sidecar/server.ts
@@ -40,7 +40,7 @@ if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) {
}
// Crash reporting — only when the Electron main process handed us a DSN
-// (desktop builds with SENTRY_DSN baked in). `executor web` and self-host
+// (desktop builds with DESKTOP_SENTRY_DSN baked in). `executor web` and self-host
// never set these env vars, so this stays inert everywhere else. Captures
// uncaught exceptions / unhandled rejections in the server process; the
// shared runId ties events to the main process and diagnostics zip.
From ce1cc451670384c1ace26d5c37ef86b0c5cd3ffe Mon Sep 17 00:00:00 2001
From: Rhys Sullivan
Date: Thu, 11 Jun 2026 16:59:35 -0700
Subject: [PATCH 5/5] Crash screen can check for updates
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A recurring sidecar crash may already be fixed upstream — the crash
screen gets a Check for updates button (reuses the menu update flow),
and showing the screen quietly stages any available update so the
install prompt appears on its own, mirroring the fatal-startup
self-heal.
---
apps/desktop/src/main/crash-screen.ts | 12 ++++++++++++
apps/desktop/src/main/index.ts | 8 ++++++++
apps/desktop/src/preload/index.ts | 8 ++++++++
e2e/desktop/sidecar-crash-screen.test.ts | 3 ++-
4 files changed, 30 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/main/crash-screen.ts b/apps/desktop/src/main/crash-screen.ts
index fd64c95cd..a208e1876 100644
--- a/apps/desktop/src/main/crash-screen.ts
+++ b/apps/desktop/src/main/crash-screen.ts
@@ -41,6 +41,7 @@ export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `<
font: inherit;
font-size: 0.875rem;
cursor: pointer;
+ white-space: nowrap;
}
button.secondary { background: transparent; color: #fafafa; border-color: #3f3f46; }
#status { margin-top: 1.25rem; min-height: 1.2em; font-size: 0.8rem; color: #a1a1aa; }
@@ -56,6 +57,7 @@ export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `<
+
@@ -71,6 +73,16 @@ export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `<
status.textContent = "Restart failed \\u2014 try quitting and reopening Executor.";
}
});
+ document.getElementById("update").addEventListener("click", async () => {
+ status.textContent = "Checking for updates\\u2026";
+ try {
+ // Outcomes surface as native dialogs (install prompt / no updates).
+ await window.executor.checkForUpdates();
+ status.textContent = "";
+ } catch {
+ status.textContent = "Update check failed \\u2014 check your network.";
+ }
+ });
document.getElementById("export").addEventListener("click", async () => {
status.textContent = "Exporting\\u2026";
try {
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index 7afb623b1..9c91efa01 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -329,6 +329,10 @@ const registerIpcHandlers = () => {
ipcMain.handle("executor:server:restart", () => restartSidecarAndReload());
ipcMain.handle("executor:diagnostics:export", () => exportDiagnostics());
ipcMain.handle("executor:crash-reporting:get", () => getCrashReportingConfig());
+ // Crash-screen escape hatch: a recurring sidecar crash may already be
+ // fixed upstream. Reuses the menu flow — staged updates prompt to install,
+ // "no updates" / failures surface in their own dialogs.
+ ipcMain.handle("executor:updates:check", () => runUpdateCheck({ alertOnFail: true }));
ipcMain.handle("executor:shell:open-external", async (_evt, rawUrl: unknown) => {
if (typeof rawUrl !== "string") return;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: untrusted renderer string, URL ctor throws on malformed input
@@ -535,6 +539,10 @@ const boot = async () => {
if (!window) return;
const html = sidecarCrashHtml({ reported: errorReportingEnabled });
void window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
+ // A crashing sidecar may be a broken release — quietly stage any
+ // available update so the install prompt appears on its own (same
+ // self-heal as the fatal startup path).
+ void runUpdateCheck({ alertOnFail: false });
});
connection = await startWithCurrentSettings();
if (!connection) {
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index cbad3f85d..85ccc551c 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -47,6 +47,14 @@ const api = {
exportDiagnostics(): Promise {
return ipcRenderer.invoke("executor:diagnostics:export");
},
+ /**
+ * Run an interactive update check (menu-flow semantics: native dialogs
+ * for "update ready", "no updates", and failures). Used by the crash
+ * screen so a broken release can heal itself.
+ */
+ checkForUpdates(): Promise {
+ return ipcRenderer.invoke("executor:updates:check");
+ },
/**
* Crash-reporting config for the renderer. Null unless this desktop build
* shipped with a DSN baked in — the shared web UI only initializes its
diff --git a/e2e/desktop/sidecar-crash-screen.test.ts b/e2e/desktop/sidecar-crash-screen.test.ts
index 4991953f7..66433064c 100644
--- a/e2e/desktop/sidecar-crash-screen.test.ts
+++ b/e2e/desktop/sidecar-crash-screen.test.ts
@@ -79,8 +79,9 @@ const run = async (runDir: string) => {
await page.getByText("stopped unexpectedly").waitFor({ timeout: 30_000 });
});
- await step("crash screen offers restart and diagnostics", async () => {
+ await step("crash screen offers restart, update, and diagnostics", async () => {
await page.locator("#restart").waitFor({ timeout: 5_000 });
+ await page.locator("#update").waitFor({ timeout: 5_000 });
await page.locator("#export").waitFor({ timeout: 5_000 });
});