diff --git a/.github/workflows/peekaboo-e2e.yml b/.github/workflows/peekaboo-e2e.yml
index 70e649af..8f9fbdc5 100644
--- a/.github/workflows/peekaboo-e2e.yml
+++ b/.github/workflows/peekaboo-e2e.yml
@@ -243,7 +243,7 @@ jobs:
for ((attempt = 1; attempt <= BUILD_ATTEMPTS; attempt += 1)); do
if bun install --frozen-lockfile && \
cd apps/native && \
- VITE_NIXMAC_SKIP_PERMISSIONS=true ./node_modules/.bin/tauri build \
+ VITE_NIXMAC_SKIP_PERMISSIONS=true VITE_NIXMAC_E2E_MODE=true ./node_modules/.bin/tauri build \
--debug \
--bundles app \
--no-sign \
diff --git a/apps/native/src/App.tsx b/apps/native/src/App.tsx
index c23011e2..1bd74d58 100644
--- a/apps/native/src/App.tsx
+++ b/apps/native/src/App.tsx
@@ -1,5 +1,5 @@
import { DarwinWidget } from "@/components/widget/widget";
-import { bootBreadcrumb, clearBootStage, markBootStage } from "@/lib/e2e-boot-diagnostics";
+import { bootBreadcrumb, clearBootStage, markBootStage } from "@/lib/boot-diagnostics";
import { useEffect } from "react";
import { Toaster } from "sonner";
diff --git a/apps/native/src/components/widget/layout/AppFatalFallback.stories.tsx b/apps/native/src/components/widget/layout/AppFatalFallback.stories.tsx
new file mode 100644
index 00000000..f137ef63
--- /dev/null
+++ b/apps/native/src/components/widget/layout/AppFatalFallback.stories.tsx
@@ -0,0 +1,43 @@
+// @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`)
+import preview from "#storybook/preview";
+import { AppFatalFallback } from "./AppFatalFallback";
+
+const meta = preview.meta({
+ title: "App/AppFatalFallback",
+ component: AppFatalFallback,
+ parameters: { layout: "fullscreen" },
+ tags: ["autodocs"],
+});
+
+export default meta;
+
+export const Default = meta.story({
+ render: () => ,
+});
+
+export const LongMessage = meta.story({
+ render: () => (
+
+ ),
+});
+
+export const WithStack = meta.story({
+ render: () => {
+ const error = new Error("Render crashed in ");
+ error.stack = `Error: Render crashed in
+ at EvolveOverlayPanel (apps/native/src/components/widget/overlays/evolve-overlay-panel.tsx:42:11)
+ at DarwinWidget (apps/native/src/components/widget/widget.tsx:120:5)
+ at App (apps/native/src/App.tsx:14:3)`;
+ return ;
+ },
+});
+
+export const NoErrorObject = meta.story({
+ render: () => ,
+});
diff --git a/apps/native/src/components/widget/layout/AppFatalFallback.tsx b/apps/native/src/components/widget/layout/AppFatalFallback.tsx
new file mode 100644
index 00000000..06a8c5f9
--- /dev/null
+++ b/apps/native/src/components/widget/layout/AppFatalFallback.tsx
@@ -0,0 +1,44 @@
+import { RotateCcw } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+type AppFatalFallbackProps = {
+ error?: Error | null;
+};
+
+const RECOVERY_STORAGE_KEY = "nixmac:pending-error-report";
+
+function stashErrorForRecovery(error: Error | null | undefined): void {
+ try {
+ window.localStorage.setItem(
+ RECOVERY_STORAGE_KEY,
+ JSON.stringify({
+ name: error?.name ?? "Error",
+ message: error?.message ?? "Unknown error",
+ stack: error?.stack ?? "",
+ timestamp: new Date().toISOString(),
+ }),
+ );
+ } catch {
+ }
+}
+
+export function AppFatalFallback({ error }: AppFatalFallbackProps) {
+ const handleReload = () => {
+ stashErrorForRecovery(error);
+ window.location.reload();
+ };
+
+ return (
+
+

+
Something went wrong
+
+
+ );
+}
diff --git a/apps/native/src/components/widget/layout/__snapshots__/AppFatalFallback.stories.tsx.snap b/apps/native/src/components/widget/layout/__snapshots__/AppFatalFallback.stories.tsx.snap
new file mode 100644
index 00000000..cf97bbe4
--- /dev/null
+++ b/apps/native/src/components/widget/layout/__snapshots__/AppFatalFallback.stories.tsx.snap
@@ -0,0 +1,9 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Default 1`] = `"Something went wrong
"`;
+
+exports[`Long Message 1`] = `"Something went wrong
"`;
+
+exports[`No Error Object 1`] = `"Something went wrong
"`;
+
+exports[`With Stack 1`] = `"Something went wrong
"`;
diff --git a/apps/native/src/components/widget/widget.tsx b/apps/native/src/components/widget/widget.tsx
index 6965fc88..3cb66a31 100644
--- a/apps/native/src/components/widget/widget.tsx
+++ b/apps/native/src/components/widget/widget.tsx
@@ -24,6 +24,7 @@ import {
PermissionsStep,
SetupStep,
} from "@/components/widget/steps";
+import { surfaceRecoveryReport } from "@/hooks/use-feedback-on-recovery";
import { useGitOperations } from "@/hooks/use-git-operations";
import { useNixInstall } from "@/hooks/use-nix-install";
import { usePanicHandler } from "@/hooks/use-panic-handler";
@@ -35,7 +36,7 @@ import { useQueueSummarizer } from "@/hooks/use-queue-summarizer";
import { useWatcher } from "@/hooks/use-watcher";
import { loadConfig, loadHosts, loadEvolveState } from "@/hooks/use-widget-initialization";
import { useSummary } from "@/hooks/use-summary";
-import { markBootStage } from "@/lib/e2e-boot-diagnostics";
+import { markBootStage } from "@/lib/boot-diagnostics";
import { useCurrentStep, useWidgetStore } from "@/stores/widget-store";
import { UpdateBanner } from "@/components/widget/layout/update-banner";
import { setupErrorTestHelpers } from "@/utils/error-test-helpers";
@@ -121,7 +122,7 @@ export function DarwinWidget() {
useWidgetStore.getState().setError((e as Error)?.message || String(e));
}
- // Start watching for git changes and summarizer updates after initial load
+ surfaceRecoveryReport();
startWatching();
queueForSummaries();
})();
diff --git a/apps/native/src/e2e/boot-harness.ts b/apps/native/src/e2e/boot-harness.ts
new file mode 100644
index 00000000..9cb28df1
--- /dev/null
+++ b/apps/native/src/e2e/boot-harness.ts
@@ -0,0 +1,89 @@
+import { bootBreadcrumb } from "@/lib/boot-diagnostics";
+import { recordE2eDomSnapshot, scheduleE2eDomSnapshots } from "./dom-snapshots";
+
+const APP_MOUNT_RELOAD_TIMEOUT_MS = 12000;
+const APP_MOUNT_RELOAD_KEY = "nixmac:e2e-app-mount-reload-attempted";
+
+// Window-level error capture for boot diagnostics. Registered at module load
+// so it picks up errors that fire between import resolution and app mount.
+window.addEventListener("error", (event) => {
+ bootBreadcrumb("window error", event.error ?? event.message);
+});
+window.addEventListener("unhandledrejection", (event) => {
+ bootBreadcrumb("window unhandled rejection", event.reason);
+});
+
+bootBreadcrumb("boot-harness loaded");
+
+type AttachBootHarnessOptions = {
+ rootElement: Element;
+};
+
+export function attachBootHarness({ rootElement }: AttachBootHarnessOptions): void {
+ let heartbeatStopped = false;
+ let heartbeatTick = 0;
+ const heartbeat = window.setInterval(() => {
+ if (heartbeatStopped) {
+ window.clearInterval(heartbeat);
+ return;
+ }
+ heartbeatTick += 1;
+ bootBreadcrumb("boot heartbeat", { tick: heartbeatTick });
+ if (heartbeatTick >= 30) {
+ bootBreadcrumb("boot heartbeat upper bound reached", { tick: heartbeatTick });
+ window.clearInterval(heartbeat);
+ }
+ }, 1000);
+
+ const stopHeartbeat = () => {
+ if (!heartbeatStopped) {
+ heartbeatStopped = true;
+ window.clearInterval(heartbeat);
+ bootBreadcrumb("boot heartbeat stopped", { tick: heartbeatTick });
+ }
+ };
+
+ window.addEventListener(
+ "nixmac:app-mounted",
+ () => {
+ bootBreadcrumb("app mounted event received");
+ scheduleE2eDomSnapshots("post-mount");
+ window.sessionStorage.removeItem(APP_MOUNT_RELOAD_KEY);
+ stopHeartbeat();
+ },
+ { once: true },
+ );
+
+ window.setTimeout(() => {
+ if (heartbeatStopped) {
+ return;
+ }
+
+ if (window.sessionStorage.getItem(APP_MOUNT_RELOAD_KEY) === "true") {
+ bootBreadcrumb("E2E app-mounted watchdog exhausted", {
+ timeoutMs: APP_MOUNT_RELOAD_TIMEOUT_MS,
+ });
+ return;
+ }
+
+ window.sessionStorage.setItem(APP_MOUNT_RELOAD_KEY, "true");
+ recordE2eDomSnapshot("app-mounted-watchdog-before-reload", {
+ storagePrefix: "nixmac:e2e-dom-snapshot:watchdog-pre-reload",
+ });
+ bootBreadcrumb("E2E app-mounted watchdog reloading", {
+ timeoutMs: APP_MOUNT_RELOAD_TIMEOUT_MS,
+ rootChildCount: rootElement.childElementCount,
+ });
+ window.setTimeout(() => {
+ window.location.reload();
+ }, 250);
+ }, APP_MOUNT_RELOAD_TIMEOUT_MS);
+
+ window.setTimeout(() => {
+ bootBreadcrumb("post-render dom probe", {
+ rootChildCount: rootElement.childElementCount,
+ bodyWidth: document.body?.clientWidth ?? null,
+ bodyHeight: document.body?.clientHeight ?? null,
+ });
+ }, 0);
+}
diff --git a/apps/native/src/e2e/dom-snapshots.ts b/apps/native/src/e2e/dom-snapshots.ts
new file mode 100644
index 00000000..27c74d44
--- /dev/null
+++ b/apps/native/src/e2e/dom-snapshots.ts
@@ -0,0 +1,89 @@
+import { bootBreadcrumb } from "@/lib/boot-diagnostics";
+import { sanitizeDiagnosticText } from "@/lib/sentry/sanitize";
+
+const e2eBootDiagnosticsEnabled = import.meta.env.VITE_NIXMAC_E2E_MODE === "true";
+
+function setStorageValue(key: string, value: string) {
+ try {
+ window.localStorage.setItem(key, value);
+ } catch {
+ // localStorage can be unavailable in restricted WebView states; the title/data marker is enough.
+ }
+}
+
+function simpleHash(value: string) {
+ let hash = 2166136261;
+ for (let index = 0; index < value.length; index += 1) {
+ hash ^= value.charCodeAt(index);
+ hash = Math.imul(hash, 16777619);
+ }
+ return (hash >>> 0).toString(16).padStart(8, "0");
+}
+
+function excerpt(value: string, maxLength: number) {
+ const sanitized = sanitizeDiagnosticText(value).replace(/\s+/g, " ").trim();
+ if (sanitized.length <= maxLength) return sanitized;
+ return `${sanitized.slice(0, maxLength)}...`;
+}
+
+type DomSnapshotOptions = {
+ storagePrefix?: string;
+};
+
+export function recordE2eDomSnapshot(label: string, options: DomSnapshotOptions = {}) {
+ if (!e2eBootDiagnosticsEnabled) return;
+
+ const root = document.getElementById("root");
+ const rawBodyText = document.body?.innerText ?? "";
+ const rawRootHtml = root?.innerHTML ?? "";
+ const snapshot = {
+ label,
+ title: sanitizeDiagnosticText(document.title || ""),
+ bootStage: sanitizeDiagnosticText(document.documentElement.dataset.nixmacBootStage ?? ""),
+ rootChildCount: root?.childElementCount ?? null,
+ bodyTextLength: rawBodyText.length,
+ rootHtmlLength: rawRootHtml.length,
+ bodyTextHash: simpleHash(rawBodyText),
+ rootHtmlHash: simpleHash(rawRootHtml),
+ bodyWidth: document.body?.clientWidth ?? null,
+ bodyHeight: document.body?.clientHeight ?? null,
+ viewportWidth: window.innerWidth,
+ viewportHeight: window.innerHeight,
+ };
+ const textExcerpt = excerpt(rawBodyText, 360);
+ const htmlExcerpt = excerpt(rawRootHtml, 520);
+ const compact = JSON.stringify({
+ label: snapshot.label,
+ title: snapshot.title,
+ bootStage: snapshot.bootStage,
+ rootChildCount: snapshot.rootChildCount,
+ bodyTextLength: snapshot.bodyTextLength,
+ rootHtmlLength: snapshot.rootHtmlLength,
+ bodyTextHash: snapshot.bodyTextHash,
+ rootHtmlHash: snapshot.rootHtmlHash,
+ });
+
+ const storagePrefix = options.storagePrefix ?? "nixmac:e2e-dom-snapshot";
+ document.documentElement.dataset.nixmacE2eDomSnapshot = compact.slice(0, 900);
+ setStorageValue(`${storagePrefix}:last`, compact);
+ setStorageValue(`${storagePrefix}:text`, textExcerpt);
+ setStorageValue(`${storagePrefix}:html`, htmlExcerpt);
+
+ bootBreadcrumb(`E2E DOM snapshot ${label} summary`, snapshot);
+ bootBreadcrumb(`E2E DOM snapshot ${label} text`, textExcerpt);
+ bootBreadcrumb(`E2E DOM snapshot ${label} html`, htmlExcerpt);
+}
+
+export function scheduleE2eDomSnapshots(prefix: string, count = 5, intervalMs = 2_000) {
+ if (!e2eBootDiagnosticsEnabled) return;
+
+ let emitted = 0;
+ const emit = () => {
+ emitted += 1;
+ recordE2eDomSnapshot(`${prefix}-${emitted}`);
+ if (emitted < count) {
+ window.setTimeout(emit, intervalMs);
+ }
+ };
+ window.setTimeout(emit, 0);
+}
diff --git a/apps/native/src/hooks/use-feedback-on-recovery.ts b/apps/native/src/hooks/use-feedback-on-recovery.ts
new file mode 100644
index 00000000..967058ce
--- /dev/null
+++ b/apps/native/src/hooks/use-feedback-on-recovery.ts
@@ -0,0 +1,70 @@
+import { useWidgetStore } from "@/stores/widget-store";
+
+const RECOVERY_STORAGE_KEY = "nixmac:pending-error-report";
+
+type StoredErrorReport = {
+ name: string;
+ message: string;
+ stack: string;
+ timestamp: string;
+};
+
+function readStoredReport(): StoredErrorReport | null {
+ let raw: string | null;
+ try {
+ raw = window.localStorage.getItem(RECOVERY_STORAGE_KEY);
+ } catch {
+ return null;
+ }
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw) as Partial;
+ if (typeof parsed.message !== "string" || parsed.message.length === 0) return null;
+ return {
+ name: typeof parsed.name === "string" && parsed.name.length > 0 ? parsed.name : "Error",
+ message: parsed.message,
+ stack: typeof parsed.stack === "string" ? parsed.stack : "",
+ timestamp:
+ typeof parsed.timestamp === "string" ? parsed.timestamp : new Date().toISOString(),
+ };
+ } catch {
+ return null;
+ }
+}
+
+function clearStoredReport(): void {
+ try {
+ window.localStorage.removeItem(RECOVERY_STORAGE_KEY);
+ } catch {
+ // localStorage may be unavailable in restricted webviews; the next boot
+ // will overwrite or replace the key anyway.
+ }
+}
+
+/**
+ * Picks up an error report stashed by `AppFatalFallback` before a recovery
+ * reload and surfaces it through the widget's normal error UI: the
+ * ErrorMessage panel shows the recovery notice with the existing Report and
+ * Dismiss actions, and the Header's feedback icon flashes via its standard
+ * error-change subscription. The Report button then opens the FeedbackDialog
+ * with the captured panic details pre-filled.
+ *
+ * Called inside the widget's initialization chain after the widget has
+ * reached a state where the ErrorMessage panel is visible.
+ */
+export function surfaceRecoveryReport(): void {
+ const report = readStoredReport();
+ if (!report) return;
+ clearStoredReport();
+
+ const { setError, setPanicDetails } = useWidgetStore.getState();
+
+ setPanicDetails({
+ message: report.message,
+ location: undefined,
+ backtrace: report.stack.length > 0 ? report.stack : undefined,
+ timestamp: report.timestamp,
+ });
+
+ setError(`Recovered from an unexpected error: ${report.message}`);
+}
diff --git a/apps/native/src/lib/boot-diagnostics.ts b/apps/native/src/lib/boot-diagnostics.ts
new file mode 100644
index 00000000..d1fee798
--- /dev/null
+++ b/apps/native/src/lib/boot-diagnostics.ts
@@ -0,0 +1,68 @@
+import { darwinAPI } from "@/tauri-api";
+
+const MAX_DETAIL_LENGTH = 1_000;
+const APP_TITLE = "nixmac";
+
+const e2eBootDiagnosticsEnabled = import.meta.env.VITE_NIXMAC_E2E_MODE === "true";
+let bootStageCleared = false;
+
+function setStorageValue(key: string, value: string) {
+ try {
+ window.localStorage.setItem(key, value);
+ } catch {
+ // localStorage can be unavailable in restricted WebView states; the title/data marker is enough.
+ }
+}
+
+function markNativeBootStage(stage: string) {
+ void darwinAPI.debug.markBootStage(stage, Date.now()).catch(() => {});
+}
+
+export function markBootStage(stage: string) {
+ if (!e2eBootDiagnosticsEnabled || bootStageCleared) return;
+
+ // E2E-only: intentionally callable from render bodies to expose pre-effect hangs.
+ const normalizedStage = stage.replace(/[^\w:.-]/g, "-").slice(0, 80);
+ document.documentElement.dataset.nixmacBootStage = normalizedStage;
+ document.title = `nixmac boot:${normalizedStage}`;
+ setStorageValue("nixmac:e2e-boot-stage", normalizedStage);
+ markNativeBootStage(normalizedStage);
+ console.info(`[nixmac boot-stage] ${normalizedStage}`);
+}
+
+export function clearBootStage() {
+ if (!e2eBootDiagnosticsEnabled) return;
+
+ bootStageCleared = true;
+ document.documentElement.dataset.nixmacBootStage = "mounted";
+ document.title = APP_TITLE;
+ setStorageValue("nixmac:e2e-boot-stage", "mounted");
+ markNativeBootStage("mounted");
+}
+
+function summarizeDetail(detail: unknown): string | undefined {
+ if (detail == null) return undefined;
+
+ let text: string;
+ if (detail instanceof Error) {
+ text = `${detail.name}: ${detail.message}`;
+ } else if (typeof detail === "string") {
+ text = detail;
+ } else {
+ try {
+ text = JSON.stringify(detail);
+ } catch {
+ text = String(detail);
+ }
+ }
+
+ return text.replace(/[^\t\x20-\x7e]/g, "").slice(0, MAX_DETAIL_LENGTH);
+}
+
+export function bootBreadcrumb(label: string, detail?: unknown) {
+ if (!e2eBootDiagnosticsEnabled) return;
+ const clientTimestampUnixMs = Date.now();
+ const summarized = summarizeDetail(detail);
+ console.info(`[nixmac boot] ${label}`, summarized ?? "");
+ void darwinAPI.debug.logBreadcrumb(label, summarized, clientTimestampUnixMs).catch(() => {});
+}
diff --git a/apps/native/src/lib/e2e-boot-diagnostics.ts b/apps/native/src/lib/e2e-boot-diagnostics.ts
deleted file mode 100644
index b35aacb3..00000000
--- a/apps/native/src/lib/e2e-boot-diagnostics.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-import { darwinAPI } from "@/tauri-api";
-
-const MAX_DETAIL_LENGTH = 1_000;
-const APP_TITLE = "nixmac";
-const REDACTED = "[REDACTED]";
-const EMAIL_PATTERN = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
-const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9\-._~+/]+=*/gi;
-const GITHUB_TOKEN_PATTERN = /\bgh[pousr]_[A-Za-z0-9]{20,}\b/gi;
-const OPENAI_TOKEN_PATTERN = /\bsk-[A-Za-z0-9]{20,}\b/g;
-const ANTHROPIC_TOKEN_PATTERN = /\bsk-ant-[A-Za-z0-9_-]{20,}\b/gi;
-const PRIVATE_KEY_BLOCK_PATTERN =
- /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
-const HOME_DIR_PATH_PATTERN = /\/Users\/[^/\s'"`]+/g;
-const NIX_SECRET_ASSIGNMENT_PATTERN =
- /\b(password|passwd|token|secret|api[-_]?key|private[-_]?key)\s*=\s*(".*?"|'.*?'|[^\s;]+)/gi;
-
-const e2eBootDiagnosticsEnabled = import.meta.env.VITE_NIXMAC_SKIP_PERMISSIONS === "true";
-let bootStageCleared = false;
-
-function setStorageValue(key: string, value: string) {
- try {
- window.localStorage.setItem(key, value);
- } catch {
- // localStorage can be unavailable in restricted WebView states; the title/data marker is enough.
- }
-}
-
-function markNativeBootStage(stage: string) {
- void darwinAPI.debug.markBootStage(stage, Date.now()).catch(() => {});
-}
-
-function simpleHash(value: string) {
- let hash = 2166136261;
- for (let index = 0; index < value.length; index += 1) {
- hash ^= value.charCodeAt(index);
- hash = Math.imul(hash, 16777619);
- }
- return (hash >>> 0).toString(16).padStart(8, "0");
-}
-
-function sanitizeUrl(value: string): string {
- if (!(value.startsWith("http://") || value.startsWith("https://"))) {
- return value;
- }
-
- try {
- const url = new URL(value);
- url.search = "";
- return url.toString();
- } catch {
- return value;
- }
-}
-
-export function sanitizeE2eDiagnosticText(value: string): string {
- let sanitized = value;
- sanitized = sanitized.replace(EMAIL_PATTERN, REDACTED);
- sanitized = sanitized.replace(BEARER_TOKEN_PATTERN, REDACTED);
- sanitized = sanitized.replace(GITHUB_TOKEN_PATTERN, REDACTED);
- sanitized = sanitized.replace(OPENAI_TOKEN_PATTERN, REDACTED);
- sanitized = sanitized.replace(ANTHROPIC_TOKEN_PATTERN, REDACTED);
- sanitized = sanitized.replace(PRIVATE_KEY_BLOCK_PATTERN, REDACTED);
- sanitized = sanitized.replace(HOME_DIR_PATH_PATTERN, "/Users/[REDACTED_USER]");
- sanitized = sanitized.replace(NIX_SECRET_ASSIGNMENT_PATTERN, (_, key: string) => {
- return `${key} = ${REDACTED}`;
- });
- return sanitizeUrl(sanitized).replace(/[^\t\x20-\x7e]/g, "");
-}
-
-function excerpt(value: string, maxLength: number) {
- const sanitized = sanitizeE2eDiagnosticText(value).replace(/\s+/g, " ").trim();
- if (sanitized.length <= maxLength) return sanitized;
- return `${sanitized.slice(0, maxLength)}...`;
-}
-
-export function markBootStage(stage: string) {
- if (!e2eBootDiagnosticsEnabled || bootStageCleared) return;
-
- // E2E-only: intentionally callable from render bodies to expose pre-effect hangs.
- const normalizedStage = stage.replace(/[^\w:.-]/g, "-").slice(0, 80);
- document.documentElement.dataset.nixmacBootStage = normalizedStage;
- document.title = `nixmac boot:${normalizedStage}`;
- setStorageValue("nixmac:e2e-boot-stage", normalizedStage);
- markNativeBootStage(normalizedStage);
- console.info(`[nixmac boot-stage] ${normalizedStage}`);
-}
-
-export function clearBootStage() {
- if (!e2eBootDiagnosticsEnabled) return;
-
- bootStageCleared = true;
- document.documentElement.dataset.nixmacBootStage = "mounted";
- document.title = APP_TITLE;
- setStorageValue("nixmac:e2e-boot-stage", "mounted");
- markNativeBootStage("mounted");
-}
-
-function summarizeDetail(detail: unknown): string | undefined {
- if (detail == null) return undefined;
-
- let text: string;
- if (detail instanceof Error) {
- text = `${detail.name}: ${detail.message}`;
- } else if (typeof detail === "string") {
- text = detail;
- } else {
- try {
- text = JSON.stringify(detail);
- } catch {
- text = String(detail);
- }
- }
-
- return text.replace(/[^\t\x20-\x7e]/g, "").slice(0, MAX_DETAIL_LENGTH);
-}
-
-export function bootBreadcrumb(label: string, detail?: unknown) {
- const clientTimestampUnixMs = Date.now();
- const summarized = summarizeDetail(detail);
- console.info(`[nixmac boot] ${label}`, summarized ?? "");
- void darwinAPI.debug.logBreadcrumb(label, summarized, clientTimestampUnixMs).catch(() => {});
-}
-
-type DomSnapshotOptions = {
- storagePrefix?: string;
-};
-
-export function recordE2eDomSnapshot(label: string, options: DomSnapshotOptions = {}) {
- if (!e2eBootDiagnosticsEnabled) return;
-
- const root = document.getElementById("root");
- const rawBodyText = document.body?.innerText ?? "";
- const rawRootHtml = root?.innerHTML ?? "";
- const snapshot = {
- label,
- title: sanitizeE2eDiagnosticText(document.title || ""),
- bootStage: sanitizeE2eDiagnosticText(document.documentElement.dataset.nixmacBootStage ?? ""),
- rootChildCount: root?.childElementCount ?? null,
- bodyTextLength: rawBodyText.length,
- rootHtmlLength: rawRootHtml.length,
- bodyTextHash: simpleHash(rawBodyText),
- rootHtmlHash: simpleHash(rawRootHtml),
- bodyWidth: document.body?.clientWidth ?? null,
- bodyHeight: document.body?.clientHeight ?? null,
- viewportWidth: window.innerWidth,
- viewportHeight: window.innerHeight,
- };
- const textExcerpt = excerpt(rawBodyText, 360);
- const htmlExcerpt = excerpt(rawRootHtml, 520);
- const compact = JSON.stringify({
- label: snapshot.label,
- title: snapshot.title,
- bootStage: snapshot.bootStage,
- rootChildCount: snapshot.rootChildCount,
- bodyTextLength: snapshot.bodyTextLength,
- rootHtmlLength: snapshot.rootHtmlLength,
- bodyTextHash: snapshot.bodyTextHash,
- rootHtmlHash: snapshot.rootHtmlHash,
- });
-
- const storagePrefix = options.storagePrefix ?? "nixmac:e2e-dom-snapshot";
- document.documentElement.dataset.nixmacE2eDomSnapshot = compact.slice(0, 900);
- setStorageValue(`${storagePrefix}:last`, compact);
- setStorageValue(`${storagePrefix}:text`, textExcerpt);
- setStorageValue(`${storagePrefix}:html`, htmlExcerpt);
-
- bootBreadcrumb(`E2E DOM snapshot ${label} summary`, snapshot);
- bootBreadcrumb(`E2E DOM snapshot ${label} text`, textExcerpt);
- bootBreadcrumb(`E2E DOM snapshot ${label} html`, htmlExcerpt);
-}
-
-export function scheduleE2eDomSnapshots(prefix: string, count = 5, intervalMs = 2_000) {
- if (!e2eBootDiagnosticsEnabled) return;
-
- let emitted = 0;
- const emit = () => {
- emitted += 1;
- recordE2eDomSnapshot(`${prefix}-${emitted}`);
- if (emitted < count) {
- window.setTimeout(emit, intervalMs);
- }
- };
- window.setTimeout(emit, 0);
-}
diff --git a/apps/native/src/lib/sentry/init.ts b/apps/native/src/lib/sentry/init.ts
new file mode 100644
index 00000000..3bbe7c50
--- /dev/null
+++ b/apps/native/src/lib/sentry/init.ts
@@ -0,0 +1,203 @@
+import * as Sentry from "@sentry/react";
+import { bootBreadcrumb } from "@/lib/boot-diagnostics";
+import { darwinAPI } from "@/tauri-api";
+import type { UiPrefs as DarwinPrefs } from "@/types/shared";
+import { sanitizeSentryEvent } from "./sanitize";
+
+const E2E_MODE = import.meta.env.VITE_NIXMAC_E2E_MODE === "true";
+
+const PREFS_BOOT_TIMEOUT_MS = 8000;
+const SENTRY_MOUNT_TIMEOUT_MS = 5000;
+
+let sentryReady = false;
+
+const loadPrefsForBoot = async (): Promise => {
+ bootBreadcrumb("ui_get_prefs invoke start");
+ let settled = false;
+ let timedOut = false;
+
+ const prefsPromise = darwinAPI.ui
+ .getPrefs()
+ .then((prefs) => {
+ settled = true;
+ bootBreadcrumb(
+ timedOut ? "ui_get_prefs invoke success after timeout" : "ui_get_prefs invoke success",
+ {
+ sendDiagnostics: prefs.sendDiagnostics,
+ },
+ );
+ return prefs;
+ })
+ .catch((error) => {
+ settled = true;
+ bootBreadcrumb(
+ timedOut ? "ui_get_prefs invoke error after timeout" : "ui_get_prefs invoke error",
+ error,
+ );
+ return null;
+ });
+
+ const timeoutPromise = new Promise((resolve) => {
+ window.setTimeout(() => {
+ if (!settled) {
+ timedOut = true;
+ bootBreadcrumb("ui_get_prefs invoke timeout", `${PREFS_BOOT_TIMEOUT_MS}ms`);
+ }
+ resolve(null);
+ }, PREFS_BOOT_TIMEOUT_MS);
+ });
+
+ return Promise.race([prefsPromise, timeoutPromise]);
+};
+
+// Resolves Sentry config from prefs + env and calls Sentry.init when enabled.
+// Wrapped in try/catch so the returned promise never rejects.
+const performSentryInit = async (): Promise => {
+ try {
+ const prefs = await loadPrefsForBoot();
+ const sendDiagnostics = prefs?.sendDiagnostics ?? false;
+ // Vite exposes environment variables at build time, so read the Sentry DSN and other config from there.
+ const sentryDsn = (import.meta.env.VITE_SENTRY_DSN || "").toString().trim();
+ const sentryEnabled = sendDiagnostics && sentryDsn.length > 0;
+
+ const release = (import.meta.env.VITE_NIXMAC_VERSION || "unknown").toString();
+ const environment = (
+ import.meta.env.VITE_NIXMAC_ENV ||
+ import.meta.env.MODE ||
+ "prod"
+ ).toString();
+ if (sentryEnabled) {
+ bootBreadcrumb("Sentry init enabled", { environment, release });
+ Sentry.init({
+ dsn: sentryDsn,
+ environment: environment,
+ release: release,
+ defaultIntegrations: false, // Disable default integrations to avoid issues in tauri
+ integrations: [Sentry.browserTracingIntegration()],
+ // Disable all breadcrumbs by returning `null`
+ beforeBreadcrumb: () => null,
+ beforeSend: (event) => {
+ const sanitized = sanitizeSentryEvent(event);
+ return sanitized as typeof event;
+ },
+ tracesSampleRate: 0.1,
+ });
+ sentryReady = true;
+ console.info("Sentry initialized.", {
+ environment: environment,
+ release: release,
+ });
+ } else {
+ bootBreadcrumb("Sentry init skipped", {
+ sendDiagnostics,
+ hasDsn: sentryDsn.length > 0,
+ });
+ console.info("Sentry not enabled.");
+ }
+ } catch (error) {
+ bootBreadcrumb("Sentry init error", error);
+ console.error("Sentry init error", error);
+ }
+};
+
+// E2E-mode deferred-init path. Only reached via attachSentry's E2E branch and
+// always early-returns because E2E builds do not generate Sentry events. The
+// breadcrumbs record the init lifecycle in the proof-report timeline.
+const initializeSentryAfterPostMountFrame = async () => {
+ if (E2E_MODE) {
+ bootBreadcrumb("Sentry init skipped for E2E boot", {
+ viteE2eMode: true,
+ });
+ console.info("Sentry not enabled during E2E boot.");
+ return;
+ }
+ await performSentryInit();
+};
+
+let sentryInitStarted = false;
+let sentryInitPromise: Promise | null = null;
+
+const scheduleAfterPostMountFrame = (callback: () => void) => {
+ bootBreadcrumb("post-mount init scheduled");
+ const run = () => {
+ bootBreadcrumb("post-mount first frame elapsed");
+ callback();
+ };
+
+ if (typeof window.requestAnimationFrame === "function") {
+ window.requestAnimationFrame(() => {
+ window.requestAnimationFrame(run);
+ });
+ return;
+ }
+
+ window.setTimeout(run, 0);
+};
+
+const startSentryInitOnce = (
+ reason: "app-mounted" | "mount-timeout" | "render-error" | "render-fatal",
+): Promise => {
+ if (sentryInitStarted) {
+ bootBreadcrumb("Sentry init start ignored", { reason });
+ return sentryInitPromise ?? Promise.resolve();
+ }
+
+ sentryInitStarted = true;
+ bootBreadcrumb("Sentry init start requested", { reason });
+ sentryInitPromise = new Promise((resolve) => {
+ scheduleAfterPostMountFrame(() => {
+ void initializeSentryAfterPostMountFrame()
+ .catch((error) => {
+ bootBreadcrumb("post-render Sentry init error", error);
+ })
+ .finally(resolve);
+ });
+ });
+
+ return sentryInitPromise;
+};
+
+export function captureRenderError(
+ reason: "render-error" | "render-fatal",
+ error: unknown,
+): void {
+ if (E2E_MODE) {
+ // E2E mode does not init Sentry; the .then is a no-op for capture but the
+ // startSentryInitOnce call records the render-error request in the
+ // proof-report breadcrumb timeline.
+ void startSentryInitOnce(reason).then(() => {
+ if (!E2E_MODE) {
+ Sentry.captureException(error);
+ }
+ });
+ return;
+ }
+ // Production: attachSentry was awaited before render, so Sentry is either
+ // ready or known-skipped by the time this fires.
+ if (sentryReady) {
+ Sentry.captureException(error);
+ }
+}
+
+export function attachSentry(): Promise {
+ if (E2E_MODE) {
+ // E2E mode: defer init via the app-mounted listener with a mount-timeout
+ // fallback. Returns an already-resolved promise so the awaiting bootstrap
+ // in main.tsx behaves the same shape across build modes.
+ window.addEventListener(
+ "nixmac:app-mounted",
+ () => {
+ startSentryInitOnce("app-mounted");
+ },
+ { once: true },
+ );
+ window.setTimeout(() => {
+ startSentryInitOnce("mount-timeout");
+ }, SENTRY_MOUNT_TIMEOUT_MS);
+ return Promise.resolve();
+ }
+ // Production: eager init. main.tsx awaits this so React begins rendering
+ // only after prefs are read and Sentry has either initialized or been
+ // skipped — no window where an early render error could outrun init.
+ return performSentryInit();
+}
diff --git a/apps/native/src/lib/sentry/sanitize.test.ts b/apps/native/src/lib/sentry/sanitize.test.ts
new file mode 100644
index 00000000..32279d12
--- /dev/null
+++ b/apps/native/src/lib/sentry/sanitize.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, test } from "vitest";
+import { sanitizeDiagnosticText, sanitizeSentryEvent } from "./sanitize";
+
+describe("sanitizeSentryEvent — string redaction", () => {
+ test("redacts emails inside message strings", () => {
+ const result = sanitizeSentryEvent({
+ message: "Failed for user@example.com during boot",
+ }) as { message: string };
+ expect(result.message).not.toMatch(/user@example/);
+ expect(result.message).toContain("[REDACTED]");
+ });
+
+ test("rewrites home-directory paths but keeps the remainder of the path", () => {
+ const result = sanitizeSentryEvent({
+ message: "Crashed in /Users/cas/projects/nixmac/apps/native/src/main.tsx",
+ }) as { message: string };
+ expect(result.message).not.toContain("/Users/cas/");
+ expect(result.message).toContain("/Users/[REDACTED_USER]/projects/nixmac/apps/native/src/main.tsx");
+ });
+
+ test("redacts Anthropic-shaped tokens", () => {
+ const result = sanitizeSentryEvent({
+ message: "Header had sk-ant-abcdefghijklmnopqrstuvwxyz at request time",
+ }) as { message: string };
+ expect(result.message).not.toMatch(/sk-ant-[a-z]{20}/);
+ expect(result.message).toContain("[REDACTED]");
+ });
+
+ test("redacts OpenAI-shaped tokens, GitHub tokens, and Bearer headers", () => {
+ const result = sanitizeSentryEvent({
+ message:
+ "openai=sk-abcdefghijklmnopqrstuvwxyz0123 gh=ghp_abcdefghijklmnopqrstuvwxyz auth=Bearer abc.def-ghi/jkl+mno=",
+ }) as { message: string };
+ expect(result.message).not.toMatch(/sk-[a-z0-9]{20}/);
+ expect(result.message).not.toMatch(/ghp_[a-z0-9]{20}/i);
+ expect(result.message).not.toMatch(/Bearer [a-z0-9]/i);
+ });
+
+ test("strips query strings from http(s) URLs but leaves non-URL strings unchanged", () => {
+ const result = sanitizeSentryEvent({
+ message: "https://example.com/path?token=abc&user=cas",
+ }) as { message: string };
+ expect(result.message).not.toContain("token=abc");
+ expect(result.message).not.toContain("user=cas");
+ expect(result.message).toContain("https://example.com/path");
+ });
+
+ test("redacts the value half of nix-style secret assignments", () => {
+ const result = sanitizeSentryEvent({
+ message: 'config had password = "hunter2" inside',
+ }) as { message: string };
+ expect(result.message).not.toContain("hunter2");
+ expect(result.message).toContain("password = [REDACTED]");
+ });
+});
+
+describe("sanitizeSentryEvent — key-based wholesale redaction", () => {
+ test("redacts wholesale on keys matching APP_CONTENT_KEY_PATTERN", () => {
+ const result = sanitizeSentryEvent({
+ extra: {
+ diff: "+ password = 'hunter2'",
+ stdout: "running...",
+ cwd: "/Users/cas/projects",
+ },
+ }) as { extra: { diff: string; stdout: string; cwd: string } };
+ expect(result.extra.diff).toBe("[REDACTED_APP_CONTENT]");
+ expect(result.extra.stdout).toBe("[REDACTED_APP_CONTENT]");
+ expect(result.extra.cwd).toBe("[REDACTED_APP_CONTENT]");
+ });
+
+ test("redacts sensitive-keyed values (email, token) to [REDACTED] regardless of contents", () => {
+ const result = sanitizeSentryEvent({
+ contexts: {
+ anything: {
+ email: "not-an-email-but-keyed-as-one",
+ token: "x",
+ },
+ },
+ }) as { contexts: { anything: { email: string; token: string } } };
+ expect(result.contexts.anything.email).toBe("[REDACTED]");
+ expect(result.contexts.anything.token).toBe("[REDACTED]");
+ });
+
+ test("leaves an empty app-content string alone (falls through to string sanitizer)", () => {
+ const result = sanitizeSentryEvent({
+ extra: { diff: "" },
+ }) as { extra: { diff: string } };
+ expect(result.extra.diff).toBe("");
+ });
+});
+
+describe("sanitizeSentryEvent — top-level scrubs", () => {
+ test("removes user and server_name from the top level", () => {
+ const result = sanitizeSentryEvent({
+ message: "ok",
+ user: { id: "abc", email: "a@b.c" },
+ server_name: "host.local",
+ }) as Record;
+ expect(result.user).toBeUndefined();
+ expect(result.server_name).toBeUndefined();
+ expect(result.message).toBe("ok");
+ });
+
+ test("returns primitives untouched (no top-level scrub when non-object)", () => {
+ expect(sanitizeSentryEvent("plain string")).toBe("plain string");
+ expect(sanitizeSentryEvent(null)).toBe(null);
+ expect(sanitizeSentryEvent(42)).toBe(42);
+ });
+});
+
+describe("sanitizeDiagnosticText", () => {
+ test("applies the same regex pipeline as Sentry strings", () => {
+ expect(sanitizeDiagnosticText("hi user@example.com")).toBe("hi [REDACTED]");
+ });
+
+ test("strips non-printables (control chars) while preserving tab and printable ASCII", () => {
+ expect(sanitizeDiagnosticText("hi\x07\x08there\tworld")).toBe("hithere\tworld");
+ });
+
+ test("rewrites home paths and strips non-printables in one pass", () => {
+ expect(sanitizeDiagnosticText("at \x01/Users/cas/x\x02")).toBe("at /Users/[REDACTED_USER]/x");
+ });
+});
diff --git a/apps/native/src/lib/sentry/sanitize.ts b/apps/native/src/lib/sentry/sanitize.ts
new file mode 100644
index 00000000..81a93215
--- /dev/null
+++ b/apps/native/src/lib/sentry/sanitize.ts
@@ -0,0 +1,119 @@
+const REDACTED = "[REDACTED]";
+const REDACTED_APP_CONTENT = "[REDACTED_APP_CONTENT]";
+
+// These are really more common to web apps, but we'll include them just in case.
+const SENSITIVE_KEY_PATTERN =
+ /password|passwd|pwd|secret|token|api[-_]?key|authorization|cookie|session|bearer|email|phone|ssn|credit|card|cvv|cvc|iban|account|address|ip|private[-_]?key|ssh|gpg/i;
+
+// These are the kinds of things we work with in nixmac, although it's extremely hard
+// to guess exactly how they might appear in the data - for example, a diff or config
+// file might be embedded in a larger string, or split across multiple fields, etc.
+// So we'll just broadly look for any keys that might indicate the value contains app
+// content and redact it wholesale, rather than trying to apply regexes to extract specific
+// secrets from within them.
+const APP_CONTENT_KEY_PATTERN =
+ /prompt|messages|conversation|completion|response|input|output|diff|patch|nix(?:[-_]?darwin)?(?:[-_]?config)?|configuration|config[-_]?text|file[-_]?content|command|args|stderr|stdout|path|cwd|home/i;
+
+const EMAIL_PATTERN = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
+const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9\-._~+/]+=*/gi;
+const GITHUB_TOKEN_PATTERN = /\bgh[pousr]_[A-Za-z0-9]{20,}\b/gi;
+const OPENAI_TOKEN_PATTERN = /\bsk-[A-Za-z0-9]{20,}\b/g;
+const ANTHROPIC_TOKEN_PATTERN = /\bsk-ant-[A-Za-z0-9_-]{20,}\b/gi;
+const PRIVATE_KEY_BLOCK_PATTERN =
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
+const HOME_DIR_PATH_PATTERN = /\/Users\/[^/\s'"`]+/g;
+const NIX_SECRET_ASSIGNMENT_PATTERN =
+ /\b(password|passwd|token|secret|api[-_]?key|private[-_]?key)\s*=\s*(".*?"|'.*?'|[^\s;]+)/gi;
+
+const sanitizeUrl = (value: string): string => {
+ if (!(value.startsWith("http://") || value.startsWith("https://"))) {
+ return value;
+ }
+
+ try {
+ const url = new URL(value);
+ if (url.search) {
+ url.search = "";
+ }
+ return url.toString();
+ } catch {
+ return value;
+ }
+};
+
+const sanitizeString = (value: string): string => {
+ let sanitized = value;
+ sanitized = sanitized.replace(EMAIL_PATTERN, REDACTED);
+ sanitized = sanitized.replace(BEARER_TOKEN_PATTERN, REDACTED);
+ sanitized = sanitized.replace(GITHUB_TOKEN_PATTERN, REDACTED);
+ sanitized = sanitized.replace(OPENAI_TOKEN_PATTERN, REDACTED);
+ sanitized = sanitized.replace(ANTHROPIC_TOKEN_PATTERN, REDACTED);
+ sanitized = sanitized.replace(PRIVATE_KEY_BLOCK_PATTERN, REDACTED);
+ sanitized = sanitized.replace(
+ HOME_DIR_PATH_PATTERN,
+ "/Users/[REDACTED_USER]",
+ );
+ sanitized = sanitized.replace(
+ NIX_SECRET_ASSIGNMENT_PATTERN,
+ (_, key: string) => {
+ return `${key} = ${REDACTED}`;
+ },
+ );
+
+ return sanitizeUrl(sanitized);
+};
+
+const sanitizeSentryValue = (value: unknown, keyName = ""): unknown => {
+ if (SENSITIVE_KEY_PATTERN.test(keyName)) {
+ return REDACTED;
+ }
+
+ if (APP_CONTENT_KEY_PATTERN.test(keyName)) {
+ if (typeof value === "string" && value.length > 0) {
+ return REDACTED_APP_CONTENT;
+ }
+ if (Array.isArray(value)) {
+ return REDACTED_APP_CONTENT;
+ }
+ if (value && typeof value === "object") {
+ return REDACTED_APP_CONTENT;
+ }
+ }
+
+ if (typeof value === "string") {
+ return sanitizeString(value);
+ }
+
+ if (Array.isArray(value)) {
+ return value.map((entry) => sanitizeSentryValue(entry));
+ }
+
+ if (value && typeof value === "object") {
+ const sanitizedObject: Record = {};
+ for (const [childKey, childValue] of Object.entries(value)) {
+ sanitizedObject[childKey] = sanitizeSentryValue(childValue, childKey);
+ }
+ return sanitizedObject;
+ }
+
+ return value;
+};
+
+export function sanitizeSentryEvent(event: unknown): unknown {
+ const sanitized = sanitizeSentryValue(event);
+ if (!sanitized || typeof sanitized !== "object") {
+ return sanitized;
+ }
+
+ const sanitizedRecord = sanitized as Record;
+ delete sanitizedRecord.user;
+ delete sanitizedRecord.server_name;
+
+ return sanitizedRecord;
+}
+
+// Used by boot diagnostics for text snapshots; same regex pipeline as the Sentry path
+// plus a non-printable strip suitable for embedding in JSON/HTML diagnostics output.
+export function sanitizeDiagnosticText(value: string): string {
+ return sanitizeString(value).replace(/[^\t\x20-\x7e]/g, "");
+}
diff --git a/apps/native/src/main.tsx b/apps/native/src/main.tsx
index c27a7ccf..63e541a9 100644
--- a/apps/native/src/main.tsx
+++ b/apps/native/src/main.tsx
@@ -1,450 +1,63 @@
+import { AppFatalFallback } from "@/components/widget/layout/AppFatalFallback";
+import { markBootStage } from "@/lib/boot-diagnostics";
+import { attachSentry, captureRenderError } from "@/lib/sentry/init";
+import * as Sentry from "@sentry/react";
import React from "react";
import ReactDOM from "react-dom/client";
-import * as Sentry from "@sentry/react";
import App from "./App";
import "./index.css";
-import { darwinAPI } from "@/tauri-api";
-import type { UiPrefs as DarwinPrefs } from "@/types/shared";
-import {
- bootBreadcrumb,
- markBootStage,
- recordE2eDomSnapshot,
- scheduleE2eDomSnapshots,
-} from "@/lib/e2e-boot-diagnostics";
-
-function FallbackComponent() {
- return (
-
-
-
nixmac could not render
-
- The app shell hit a startup error. Diagnostic breadcrumbs were recorded for this run.
-
-
-
- );
-}
const rootElement = document.getElementById("root");
-
-bootBreadcrumb("main.tsx loaded");
markBootStage("main-loaded");
if (!rootElement) {
- bootBreadcrumb("root element missing");
throw new Error("Root element not found");
}
-bootBreadcrumb("root element found");
markBootStage("root-found");
-const E2E_APP_MOUNT_RELOAD_TIMEOUT_MS = 12000;
-const E2E_APP_MOUNT_RELOAD_KEY = "nixmac:e2e-app-mount-reload-attempted";
-// This existing flag marks E2E/dev permission bypass builds. In that mode, boot must not
-// introduce another preference IPC before the app shell has rendered.
-const E2E_BOOT_PREFS_DISABLED = import.meta.env.VITE_NIXMAC_SKIP_PERMISSIONS === "true";
-
-let bootHeartbeatStopped = false;
-let bootHeartbeatTick = 0;
-let bootHeartbeat: number | null = null;
-
-if (E2E_BOOT_PREFS_DISABLED) {
- bootHeartbeat = window.setInterval(() => {
- if (bootHeartbeatStopped) {
- if (bootHeartbeat !== null) {
- window.clearInterval(bootHeartbeat);
- }
- return;
- }
- bootHeartbeatTick += 1;
- bootBreadcrumb("boot heartbeat", { tick: bootHeartbeatTick });
- if (bootHeartbeatTick >= 30) {
- bootBreadcrumb("boot heartbeat upper bound reached", { tick: bootHeartbeatTick });
- if (bootHeartbeat !== null) {
- window.clearInterval(bootHeartbeat);
- }
- }
- }, 1000);
+// Dropped from production, e2e harness
+if (import.meta.env.VITE_NIXMAC_E2E_MODE === "true") {
+ void import("@/e2e/boot-harness").then((m) =>
+ m.attachBootHarness({ rootElement }),
+ );
}
-const stopBootHeartbeat = () => {
- if (!bootHeartbeatStopped) {
- bootHeartbeatStopped = true;
- if (bootHeartbeat !== null) {
- window.clearInterval(bootHeartbeat);
- }
- bootBreadcrumb("boot heartbeat stopped", { tick: bootHeartbeatTick });
- }
-};
-
-window.addEventListener(
- "nixmac:app-mounted",
- () => {
- bootBreadcrumb("app mounted event received");
- scheduleE2eDomSnapshots("post-mount");
- window.sessionStorage.removeItem(E2E_APP_MOUNT_RELOAD_KEY);
- stopBootHeartbeat();
- },
- { once: true },
-);
-
-window.addEventListener("error", (event) => {
- bootBreadcrumb("window error", event.error ?? event.message);
-});
-
-window.addEventListener("unhandledrejection", (event) => {
- bootBreadcrumb("window unhandled rejection", event.reason);
-});
-
-const REDACTED = "[REDACTED]";
-const REDACTED_APP_CONTENT = "[REDACTED_APP_CONTENT]";
-
-// These are really more common to web apps, but we'll include them just in case.
-const SENSITIVE_KEY_PATTERN =
- /password|passwd|pwd|secret|token|api[-_]?key|authorization|cookie|session|bearer|email|phone|ssn|credit|card|cvv|cvc|iban|account|address|ip|private[-_]?key|ssh|gpg/i;
-
-// These are the kinds of things we work with in nixmac, although it's extremely hard
-// to guess exactly how they might appear in the data - for example, a diff or config
-// file might be embedded in a larger string, or split across multiple fields, etc.
-// So we'll just broadly look for any keys that might indicate the value contains app
-// content and redact it wholesale, rather than trying to apply regexes to extract specific
-// secrets from within them.
-const APP_CONTENT_KEY_PATTERN =
- /prompt|messages|conversation|completion|response|input|output|diff|patch|nix(?:[-_]?darwin)?(?:[-_]?config)?|configuration|config[-_]?text|file[-_]?content|command|args|stderr|stdout|path|cwd|home/i;
-
-const EMAIL_PATTERN = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
-const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9\-._~+/]+=*/gi;
-const GITHUB_TOKEN_PATTERN = /\bgh[pousr]_[A-Za-z0-9]{20,}\b/gi;
-const OPENAI_TOKEN_PATTERN = /\bsk-[A-Za-z0-9]{20,}\b/g;
-const ANTHROPIC_TOKEN_PATTERN = /\bsk-ant-[A-Za-z0-9_-]{20,}\b/gi;
-const PRIVATE_KEY_BLOCK_PATTERN =
- /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
-const HOME_DIR_PATH_PATTERN = /\/Users\/[^/\s'"`]+/g;
-const NIX_SECRET_ASSIGNMENT_PATTERN =
- /\b(password|passwd|token|secret|api[-_]?key|private[-_]?key)\s*=\s*(".*?"|'.*?'|[^\s;]+)/gi;
-
-const sanitizeUrl = (value: string): string => {
- if (!(value.startsWith("http://") || value.startsWith("https://"))) {
- return value;
- }
-
- try {
- const url = new URL(value);
- if (url.search) {
- url.search = "";
- }
- return url.toString();
- } catch {
- return value;
- }
-};
-
-const sanitizeString = (value: string): string => {
- let sanitized = value;
- sanitized = sanitized.replace(EMAIL_PATTERN, REDACTED);
- sanitized = sanitized.replace(BEARER_TOKEN_PATTERN, REDACTED);
- sanitized = sanitized.replace(GITHUB_TOKEN_PATTERN, REDACTED);
- sanitized = sanitized.replace(OPENAI_TOKEN_PATTERN, REDACTED);
- sanitized = sanitized.replace(ANTHROPIC_TOKEN_PATTERN, REDACTED);
- sanitized = sanitized.replace(PRIVATE_KEY_BLOCK_PATTERN, REDACTED);
- sanitized = sanitized.replace(HOME_DIR_PATH_PATTERN, "/Users/[REDACTED_USER]");
- sanitized = sanitized.replace(NIX_SECRET_ASSIGNMENT_PATTERN, (_, key: string) => {
- return `${key} = ${REDACTED}`;
- });
-
- return sanitizeUrl(sanitized);
-};
-
-const sanitizeSentryValue = (value: unknown, keyName = ""): unknown => {
- if (SENSITIVE_KEY_PATTERN.test(keyName)) {
- return REDACTED;
- }
-
- if (APP_CONTENT_KEY_PATTERN.test(keyName)) {
- if (typeof value === "string" && value.length > 0) {
- return REDACTED_APP_CONTENT;
- }
- if (Array.isArray(value)) {
- return REDACTED_APP_CONTENT;
- }
- if (value && typeof value === "object") {
- return REDACTED_APP_CONTENT;
- }
- }
-
- if (typeof value === "string") {
- return sanitizeString(value);
- }
-
- if (Array.isArray(value)) {
- return value.map((entry) => sanitizeSentryValue(entry));
- }
-
- if (value && typeof value === "object") {
- const sanitizedObject: Record = {};
- for (const [childKey, childValue] of Object.entries(value)) {
- sanitizedObject[childKey] = sanitizeSentryValue(childValue, childKey);
- }
- return sanitizedObject;
- }
-
- return value;
-};
-
-const sanitizeSentryEvent = (event: unknown): unknown => {
- const sanitized = sanitizeSentryValue(event);
- if (!sanitized || typeof sanitized !== "object") {
- return sanitized;
- }
-
- const sanitizedRecord = sanitized as Record;
- delete sanitizedRecord.user;
- delete sanitizedRecord.server_name;
-
- return sanitizedRecord;
-};
-
-const PREFS_BOOT_TIMEOUT_MS = 8000;
-const SENTRY_MOUNT_TIMEOUT_MS = 5000;
-
-const loadPrefsForBoot = async (): Promise => {
- bootBreadcrumb("ui_get_prefs invoke start");
- let settled = false;
- let timedOut = false;
-
- const prefsPromise = darwinAPI.ui
- .getPrefs()
- .then((prefs) => {
- settled = true;
- bootBreadcrumb(
- timedOut ? "ui_get_prefs invoke success after timeout" : "ui_get_prefs invoke success",
- {
- sendDiagnostics: prefs.sendDiagnostics,
- },
- );
- return prefs;
- })
- .catch((error) => {
- settled = true;
- bootBreadcrumb(
- timedOut ? "ui_get_prefs invoke error after timeout" : "ui_get_prefs invoke error",
- error,
- );
- return null;
- });
-
- const timeoutPromise = new Promise((resolve) => {
- window.setTimeout(() => {
- if (!settled) {
- timedOut = true;
- bootBreadcrumb("ui_get_prefs invoke timeout", `${PREFS_BOOT_TIMEOUT_MS}ms`);
- }
- resolve(null);
- }, PREFS_BOOT_TIMEOUT_MS);
- });
-
- return Promise.race([prefsPromise, timeoutPromise]);
-};
-
-const initializeSentryAfterPostMountFrame = async () => {
- if (E2E_BOOT_PREFS_DISABLED) {
- bootBreadcrumb("Sentry init skipped for E2E boot", {
- viteSkipPermissions: true,
- });
- console.info("Sentry not enabled during E2E boot.");
- return;
- }
-
- const prefs = await loadPrefsForBoot();
- const sendDiagnostics = prefs?.sendDiagnostics ?? false;
- // Vite exposes environment variables at build time, so read the Sentry DSN and other config from there.
- const sentryDsn = (import.meta.env.VITE_SENTRY_DSN || "").toString().trim();
- const sentryEnabled = sendDiagnostics && sentryDsn.length > 0;
-
- const release = (import.meta.env.VITE_NIXMAC_VERSION || "unknown").toString();
- const environment = (
- import.meta.env.VITE_NIXMAC_ENV ||
- import.meta.env.MODE ||
- "prod"
- ).toString();
- if (sentryEnabled) {
- bootBreadcrumb("Sentry init enabled", { environment, release });
- Sentry.init({
- dsn: sentryDsn,
- environment: environment,
- release: release,
- defaultIntegrations: false, // Disable default integrations to avoid issues in tauri
- integrations: [Sentry.browserTracingIntegration()],
- // Disable all breadcrumbs by returning `null`
- beforeBreadcrumb: () => null,
- beforeSend: (event) => {
- const sanitized = sanitizeSentryEvent(event);
- // console.log("[Sentry beforeSend]", { original: event, sanitized });
- return sanitized as typeof event;
- },
- tracesSampleRate: 0.1,
- });
- console.info("Sentry initialized.", {
- environment: environment,
- release: release,
- });
- } else {
- bootBreadcrumb("Sentry init skipped", {
- sendDiagnostics,
- hasDsn: sentryDsn.length > 0,
- });
- console.info("Sentry not enabled.");
- }
-};
-
const root = ReactDOM.createRoot(rootElement);
-let sentryInitStarted = false;
-let sentryInitPromise: Promise | null = null;
-
-const scheduleAfterPostMountFrame = (callback: () => void) => {
- bootBreadcrumb("post-mount init scheduled");
- const run = () => {
- bootBreadcrumb("post-mount first frame elapsed");
- callback();
- };
-
- if (typeof window.requestAnimationFrame === "function") {
- window.requestAnimationFrame(() => {
- window.requestAnimationFrame(run);
- });
- return;
- }
-
- window.setTimeout(run, 0);
-};
-
-const startSentryInitOnce = (
- reason: "app-mounted" | "mount-timeout" | "render-error" | "render-fatal",
-): Promise => {
- if (sentryInitStarted) {
- bootBreadcrumb("Sentry init start ignored", { reason });
- return sentryInitPromise ?? Promise.resolve();
- }
-
- sentryInitStarted = true;
- bootBreadcrumb("Sentry init start requested", { reason });
- sentryInitPromise = new Promise((resolve) => {
- scheduleAfterPostMountFrame(() => {
- void initializeSentryAfterPostMountFrame()
- .catch((error) => {
- bootBreadcrumb("post-render Sentry init error", error);
- })
- .finally(resolve);
- });
- });
-
- return sentryInitPromise;
-};
-
-const captureRenderErrorAfterSentryInit = (
- reason: "render-error" | "render-fatal",
- error: unknown,
-) => {
- void startSentryInitOnce(reason).then(() => {
- if (!E2E_BOOT_PREFS_DISABLED) {
- Sentry.captureException(error);
- }
- });
-};
-
-window.addEventListener(
- "nixmac:app-mounted",
- () => {
- startSentryInitOnce("app-mounted");
- },
- { once: true },
-);
-
-window.setTimeout(() => {
- startSentryInitOnce("mount-timeout");
-}, SENTRY_MOUNT_TIMEOUT_MS);
-
-if (E2E_BOOT_PREFS_DISABLED) {
- window.setTimeout(() => {
- if (bootHeartbeatStopped) {
- return;
- }
-
- if (window.sessionStorage.getItem(E2E_APP_MOUNT_RELOAD_KEY) === "true") {
- bootBreadcrumb("E2E app-mounted watchdog exhausted", {
- timeoutMs: E2E_APP_MOUNT_RELOAD_TIMEOUT_MS,
- });
- return;
- }
-
- window.sessionStorage.setItem(E2E_APP_MOUNT_RELOAD_KEY, "true");
- recordE2eDomSnapshot("app-mounted-watchdog-before-reload", {
- storagePrefix: "nixmac:e2e-dom-snapshot:watchdog-pre-reload",
- });
- bootBreadcrumb("E2E app-mounted watchdog reloading", {
- timeoutMs: E2E_APP_MOUNT_RELOAD_TIMEOUT_MS,
- rootChildCount: rootElement.childElementCount,
- });
- window.setTimeout(() => {
- window.location.reload();
- }, 250);
- }, E2E_APP_MOUNT_RELOAD_TIMEOUT_MS);
-}
const renderApp = () => {
- bootBreadcrumb("React render start");
markBootStage("react-render-start");
root.render(
}
- onError={(error, componentStack) => {
- console.error("ErrorBoundary caught:", error, componentStack);
- bootBreadcrumb("ErrorBoundary caught render error", error);
- captureRenderErrorAfterSentryInit("render-error", error);
+ fallback={({ error }) => (
+
+ )}
+ onError={(error, _componentStack) => {
+ console.error("ErrorBoundary caught:", error);
+ captureRenderError("render-error", error);
}}
>
,
);
- bootBreadcrumb("React render scheduled");
markBootStage("react-render-scheduled");
};
-try {
- renderApp();
-} catch (error) {
- bootBreadcrumb("React render fatal error", error);
- markBootStage("react-render-fatal");
- captureRenderErrorAfterSentryInit("render-fatal", error);
- root.render();
-}
+const bootstrap = async () => {
+ // Awaiting attachSentry blocks render in production,
+ // in E2E_MODE resolves synchronously and the harness handles its own init lifecycle.
+ await attachSentry();
+
+ try {
+ renderApp();
+ } catch (error) {
+ markBootStage("react-render-fatal");
+ captureRenderError("render-fatal", error);
+ root.render(
+ ,
+ );
+ }
+};
-window.setTimeout(() => {
- bootBreadcrumb("post-render dom probe", {
- rootChildCount: rootElement.childElementCount,
- bodyWidth: document.body?.clientWidth ?? null,
- bodyHeight: document.body?.clientHeight ?? null,
- });
-}, 0);
+void bootstrap();
diff --git a/tools/computer-use-e2e/README.md b/tools/computer-use-e2e/README.md
index d3fb0593..f9667928 100644
--- a/tools/computer-use-e2e/README.md
+++ b/tools/computer-use-e2e/README.md
@@ -700,7 +700,7 @@ Then build and launch the real app as a debug `.app` bundle:
```bash
cd apps/native
-VITE_NIXMAC_SKIP_PERMISSIONS=true ./node_modules/.bin/tauri build \
+VITE_NIXMAC_SKIP_PERMISSIONS=true VITE_NIXMAC_E2E_MODE=true ./node_modules/.bin/tauri build \
--debug \
--bundles app \
--no-sign \
diff --git a/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs b/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs
index 971b1af6..dab35b63 100644
--- a/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs
+++ b/tools/computer-use-e2e/peekaboo-workflow-contract-self-test.mjs
@@ -22,7 +22,12 @@ const frontendMainPath = path.join(repoRoot, 'apps/native/src/main.tsx');
const frontendAppPath = path.join(repoRoot, 'apps/native/src/App.tsx');
const frontendWidgetPath = path.join(repoRoot, 'apps/native/src/components/widget/widget.tsx');
const frontendEditorPanelPath = path.join(repoRoot, 'apps/native/src/components/widget/overlays/editor-panel.tsx');
-const frontendBootDiagnosticsPath = path.join(repoRoot, 'apps/native/src/lib/e2e-boot-diagnostics.ts');
+const frontendBootDiagnosticsPath = path.join(repoRoot, 'apps/native/src/lib/boot-diagnostics.ts');
+const frontendDomSnapshotsPath = path.join(repoRoot, 'apps/native/src/e2e/dom-snapshots.ts');
+const frontendBootHarnessPath = path.join(repoRoot, 'apps/native/src/e2e/boot-harness.ts');
+const frontendSentryInitPath = path.join(repoRoot, 'apps/native/src/lib/sentry/init.ts');
+const frontendSentrySanitizePath = path.join(repoRoot, 'apps/native/src/lib/sentry/sanitize.ts');
+const frontendAppFatalFallbackPath = path.join(repoRoot, 'apps/native/src/components/widget/layout/AppFatalFallback.tsx');
const tauriApiPath = path.join(repoRoot, 'apps/native/src/tauri-api.ts');
const workflow = readFileSync(workflowPath, 'utf8');
const productProof = readFileSync(productProofPath, 'utf8');
@@ -41,6 +46,11 @@ const frontendApp = readFileSync(frontendAppPath, 'utf8');
const frontendWidget = readFileSync(frontendWidgetPath, 'utf8');
const frontendEditorPanel = readFileSync(frontendEditorPanelPath, 'utf8');
const frontendBootDiagnostics = readFileSync(frontendBootDiagnosticsPath, 'utf8');
+const frontendDomSnapshots = readFileSync(frontendDomSnapshotsPath, 'utf8');
+const frontendBootHarness = readFileSync(frontendBootHarnessPath, 'utf8');
+const frontendSentryInit = readFileSync(frontendSentryInitPath, 'utf8');
+const frontendSentrySanitize = readFileSync(frontendSentrySanitizePath, 'utf8');
+const frontendAppFatalFallback = readFileSync(frontendAppFatalFallbackPath, 'utf8');
const tauriApi = readFileSync(tauriApiPath, 'utf8');
function section(startPattern, endPattern = null) {
@@ -224,28 +234,37 @@ assert.match(frontendBootDiagnostics, /let bootStageCleared = false[\s\S]*export
assert.match(frontendBootDiagnostics, /function markNativeBootStage\(stage: string\)[\s\S]*darwinAPI\.debug\.markBootStage\(stage, Date\.now\(\)\)[\s\S]*markNativeBootStage\(normalizedStage\)/, 'Frontend boot diagnostics must mirror boot stages to the native debug command before the event-loop stall boundary');
assert.match(frontendBootDiagnostics, /export function clearBootStage[\s\S]*document\.title = APP_TITLE[\s\S]*nixmac:e2e-boot-stage", "mounted"/, 'Frontend boot diagnostics must restore the normal window title after mount');
assert.match(frontendBootDiagnostics, /export function clearBootStage[\s\S]*markNativeBootStage\("mounted"\)/, 'Frontend boot diagnostics must clear the native title marker after App mount');
-assert.match(frontendBootDiagnostics, /export function sanitizeE2eDiagnosticText[\s\S]*EMAIL_PATTERN[\s\S]*BEARER_TOKEN_PATTERN[\s\S]*OPENAI_TOKEN_PATTERN[\s\S]*HOME_DIR_PATH_PATTERN/, 'E2E DOM diagnostics must sanitize secret-shaped text before persisting report artifacts');
-assert.match(frontendBootDiagnostics, /export function recordE2eDomSnapshot[\s\S]*storagePrefix[\s\S]*nixmac:e2e-dom-snapshot[\s\S]*document\.documentElement\.dataset\.nixmacE2eDomSnapshot[\s\S]*`\$\{storagePrefix\}:last`[\s\S]*E2E DOM snapshot \$\{label\} summary[\s\S]*E2E DOM snapshot \$\{label\} text[\s\S]*E2E DOM snapshot \$\{label\} html/, 'E2E DOM diagnostics must persist bounded snapshots through both out-of-band DOM/localStorage state and breadcrumb artifacts');
-assert.match(frontendBootDiagnostics, /export function scheduleE2eDomSnapshots[\s\S]*count = 5[\s\S]*intervalMs = 2_000[\s\S]*emitted < count/, 'E2E DOM diagnostics must schedule a bounded post-mount snapshot series and self-stop');
-assert.match(frontendMain, /PREFS_BOOT_TIMEOUT_MS = 8000[\s\S]*ui_get_prefs invoke start[\s\S]*success after timeout[\s\S]*Promise\.race\(\[prefsPromise, timeoutPromise\]\)/, 'Frontend boot must log prefs IPC progress with clear after-timeout labels');
+assert.match(frontendSentrySanitize, /export function sanitizeDiagnosticText[\s\S]*sanitizeString/, 'E2E DOM diagnostics text sanitizer must be exported from the shared sanitize module');
+assert.match(frontendSentrySanitize, /EMAIL_PATTERN[\s\S]*BEARER_TOKEN_PATTERN[\s\S]*OPENAI_TOKEN_PATTERN[\s\S]*HOME_DIR_PATH_PATTERN/, 'Shared sanitize module must apply the secret-shaped text patterns used by both Sentry events and E2E diagnostics');
+assert.match(frontendDomSnapshots, /import \{ sanitizeDiagnosticText \} from "@\/lib\/sentry\/sanitize"/, 'E2E DOM snapshots must consume sanitization from the shared sentry/sanitize module rather than a duplicate regex set');
+assert.match(frontendDomSnapshots, /import \{ bootBreadcrumb \} from "@\/lib\/boot-diagnostics"/, 'E2E DOM snapshots must consume bootBreadcrumb from the split-out boot-diagnostics module');
+assert.match(frontendDomSnapshots, /export function recordE2eDomSnapshot[\s\S]*storagePrefix[\s\S]*nixmac:e2e-dom-snapshot[\s\S]*document\.documentElement\.dataset\.nixmacE2eDomSnapshot[\s\S]*`\$\{storagePrefix\}:last`[\s\S]*E2E DOM snapshot \$\{label\} summary[\s\S]*E2E DOM snapshot \$\{label\} text[\s\S]*E2E DOM snapshot \$\{label\} html/, 'E2E DOM snapshots must persist bounded snapshots through both out-of-band DOM/localStorage state and breadcrumb artifacts');
+assert.match(frontendDomSnapshots, /export function scheduleE2eDomSnapshots[\s\S]*count = 5[\s\S]*intervalMs = 2_000[\s\S]*emitted < count/, 'E2E DOM snapshots must schedule a bounded post-mount snapshot series and self-stop');
+assert.match(frontendSentryInit, /PREFS_BOOT_TIMEOUT_MS = 8000[\s\S]*ui_get_prefs invoke start[\s\S]*success after timeout[\s\S]*Promise\.race\(\[prefsPromise, timeoutPromise\]\)/, 'Sentry init module must log prefs IPC progress with clear after-timeout labels');
assert.match(frontendMain, /markBootStage\("main-loaded"\)[\s\S]*markBootStage\("root-found"\)[\s\S]*markBootStage\("react-render-start"\)[\s\S]*markBootStage\("react-render-scheduled"\)/, 'Frontend boot must synchronously mark module, root, and render-scheduling stages');
assert.match(frontendApp, /markBootStage\("app-render"\)[\s\S]*markBootStage\("app-effect"\)[\s\S]*clearBootStage\(\)/, 'App must synchronously mark render/effect stages and clear the E2E title marker after mount');
assert.match(frontendWidget, /markBootStage\("darwin-widget-render"\)/, 'DarwinWidget must mark when the product widget render body is reached');
assert.match(frontendEditorPanel, /const LazyNixEditor = lazy\(async \(\) => \{[\s\S]*import\("@\/components\/kibo-ui\/nix-editor"\)[\s\S]*default: module\.NixEditor/, 'EditorPanel must lazy-load the Monaco-backed Nix editor only when a file is opened');
assert.doesNotMatch(frontendEditorPanel, /import \{ NixEditor \}/, 'EditorPanel must not import the Monaco-backed editor in the first app boot bundle');
-assert.match(frontendMain, /if \(E2E_BOOT_PREFS_DISABLED\) \{[\s\S]*setInterval\(\(\) => \{[\s\S]*boot heartbeat[\s\S]*boot heartbeat upper bound reached[\s\S]*stopBootHeartbeat[\s\S]*boot heartbeat stopped[\s\S]*nixmac:app-mounted/, 'Frontend boot must emit bounded E2E-only heartbeat breadcrumbs until App mounted and record when the bound is reached');
-assert.match(frontendMain, /E2E_BOOT_PREFS_DISABLED = import\.meta\.env\.VITE_NIXMAC_SKIP_PERMISSIONS === "true"[\s\S]*Sentry init skipped for E2E boot[\s\S]*return;/, 'Frontend boot must use the existing build-time E2E flag to skip boot-time Sentry prefs IPC without adding another IPC gate');
-assert.match(frontendMain, /E2E_APP_MOUNT_RELOAD_TIMEOUT_MS = 12000[\s\S]*E2E_APP_MOUNT_RELOAD_KEY[\s\S]*E2E app-mounted watchdog reloading[\s\S]*window\.location\.reload\(\)/, 'Frontend E2E boot must request one reload when the page loads but App never mounts');
-assert.match(frontendMain, /scheduleE2eDomSnapshots\("post-mount"\)[\s\S]*recordE2eDomSnapshot\("app-mounted-watchdog-before-reload"[\s\S]*nixmac:e2e-dom-snapshot:watchdog-pre-reload[\s\S]*window\.setTimeout\(\(\) => \{[\s\S]*window\.location\.reload\(\)[\s\S]*250/, 'Frontend E2E boot must capture post-mount DOM snapshots and a durable watchdog snapshot before forced reload');
-assert.match(frontendMain, /const renderApp = \(\) => \{[\s\S]*React render start[\s\S]*Sentry\.ErrorBoundary[\s\S]*[\s\S]*React render scheduled/, 'Frontend boot must render the app immediately with an error boundary');
-assert.match(frontendMain, /startSentryInitOnce[\s\S]*render-error[\s\S]*render-fatal[\s\S]*Sentry init start requested[\s\S]*scheduleAfterPostMountFrame[\s\S]*initializeSentryAfterPostMountFrame\(\)/, 'Frontend boot must start preference-backed Sentry initialization only after App mounted, render error, render fatal, or the mount-timeout fallback requests it');
-assert.match(frontendMain, /captureRenderErrorAfterSentryInit[\s\S]*startSentryInitOnce\(reason\)[\s\S]*Sentry\.captureException\(error\)/, 'Frontend boot must explicitly capture render errors after Sentry init is requested');
-assert.match(frontendMain, /SENTRY_MOUNT_TIMEOUT_MS = 5000[\s\S]*window\.setTimeout\(\(\) => \{[\s\S]*startSentryInitOnce\("mount-timeout"\)[\s\S]*SENTRY_MOUNT_TIMEOUT_MS/, 'Frontend boot must retain a production mount-timeout Sentry fallback for failed-render sessions without a long early-boot observability gap');
-assertOrder(frontendMain, 'window.addEventListener(\n "nixmac:app-mounted"', 'renderApp();', 'Frontend boot must register the app-mounted listener before rendering the app');
+assert.match(frontendBootHarness, /setInterval\(\(\) => \{[\s\S]*boot heartbeat[\s\S]*boot heartbeat upper bound reached[\s\S]*stopHeartbeat[\s\S]*boot heartbeat stopped[\s\S]*nixmac:app-mounted/, 'E2E boot harness must emit bounded heartbeat breadcrumbs until App mounted and record when the bound is reached');
+assert.match(frontendSentryInit, /E2E_MODE = import\.meta\.env\.VITE_NIXMAC_E2E_MODE === "true"[\s\S]*Sentry init skipped for E2E boot[\s\S]*return;/, 'Sentry init module must use the build-time E2E mode flag to skip boot-time Sentry prefs IPC without adding another IPC gate');
+assert.match(frontendMain, /import\.meta\.env\.VITE_NIXMAC_E2E_MODE === "true"[\s\S]*void import\("@\/e2e\/boot-harness"\)[\s\S]*attachBootHarness\(\{ rootElement \}\)/, 'Frontend main must conditionally dynamic-import the E2E boot harness so it is tree-shaken from production builds');
+assert.match(frontendBootHarness, /APP_MOUNT_RELOAD_TIMEOUT_MS = 12000[\s\S]*APP_MOUNT_RELOAD_KEY[\s\S]*E2E app-mounted watchdog reloading[\s\S]*window\.location\.reload\(\)/, 'E2E boot harness must request one reload when the page loads but App never mounts');
+assert.match(frontendBootHarness, /scheduleE2eDomSnapshots\("post-mount"\)[\s\S]*recordE2eDomSnapshot\("app-mounted-watchdog-before-reload"[\s\S]*nixmac:e2e-dom-snapshot:watchdog-pre-reload[\s\S]*window\.setTimeout\(\(\) => \{[\s\S]*window\.location\.reload\(\)[\s\S]*250/, 'E2E boot harness must capture post-mount DOM snapshots and a durable watchdog snapshot before forced reload');
+assert.match(frontendMain, /const renderApp = \(\) => \{[\s\S]*markBootStage\("react-render-start"\)[\s\S]*Sentry\.ErrorBoundary[\s\S]*[\s\S]*markBootStage\("react-render-scheduled"\)/, 'Frontend boot must render the app inside an error boundary, bracketed by the render-start and render-scheduled boot stages');
+assert.match(frontendSentryInit, /startSentryInitOnce[\s\S]*render-error[\s\S]*render-fatal[\s\S]*Sentry init start requested[\s\S]*scheduleAfterPostMountFrame[\s\S]*initializeSentryAfterPostMountFrame\(\)/, 'Sentry init module must start preference-backed Sentry initialization only after App mounted, render error, render fatal, or the mount-timeout fallback requests it');
+assert.match(frontendSentryInit, /export function captureRenderError[\s\S]*startSentryInitOnce\(reason\)[\s\S]*Sentry\.captureException\(error\)/, 'Sentry init module must export captureRenderError that lazily initializes Sentry and then captures the render error');
+assert.match(frontendSentryInit, /SENTRY_MOUNT_TIMEOUT_MS = 5000[\s\S]*window\.setTimeout\(\(\) => \{[\s\S]*startSentryInitOnce\("mount-timeout"\)[\s\S]*SENTRY_MOUNT_TIMEOUT_MS/, 'Sentry init module must retain a production mount-timeout Sentry fallback for failed-render sessions without a long early-boot observability gap');
+assert.match(frontendSentryInit, /export function attachSentry[\s\S]*window\.addEventListener\(\s*"nixmac:app-mounted"[\s\S]*startSentryInitOnce\("app-mounted"\)/, 'Sentry init module must register the app-mounted Sentry init trigger inside attachSentry');
+assert.match(frontendMain, /import \{ attachSentry, captureRenderError \} from "@\/lib\/sentry\/init"/, 'Frontend main must consume Sentry attach + capture from the extracted module');
+assertOrder(frontendMain, 'await attachSentry();', 'renderApp();', 'Frontend boot must await attachSentry before rendering so production blocks render on prefs+Sentry init (closing the pre-init render-error window)');
+assertOrder(frontendMain, 'import("@/e2e/boot-harness")', 'renderApp();', 'Frontend boot must queue the harness dynamic import before rendering so the heartbeat-stop listener runs in time for the App mount event');
assert.doesNotMatch(frontendMain, /renderApp\(\);\s*void initializeSentry/, 'Frontend boot must not directly initialize preference-backed Sentry immediately after first render');
assert.doesNotMatch(frontendRenderApp, /\bawait\b/, 'Frontend renderApp must stay synchronous and never await prefs IPC before first render');
-assert.match(frontendMain, /role="alert"[\s\S]*background: "#27272a"[\s\S]*border: "1px solid #52525b"/, 'Frontend error fallback must include a visible central card with enough luminance range for screenshot signal diagnostics');
-assert.match(frontendMain, /window\.addEventListener\("unhandledrejection"[\s\S]*window unhandled rejection/, 'Frontend boot diagnostics must capture top-level unhandled rejections');
+assert.match(frontendAppFatalFallback, /role="alert"/, 'App fatal fallback must use role="alert" for accessibility');
+assert.match(frontendAppFatalFallback, /window\.location\.reload\(\)/, 'App fatal fallback must offer a Reload affordance');
+assert.match(frontendAppFatalFallback, /window\.localStorage\.setItem\(\s*RECOVERY_STORAGE_KEY/, 'App fatal fallback must stash error details to localStorage for the post-reload recovery handoff');
+assert.match(frontendBootHarness, /window\.addEventListener\("unhandledrejection"[\s\S]*window unhandled rejection/, 'E2E boot harness must capture top-level unhandled rejections');
assert.match(runnerShell, /E2E_TERMINAL_CLEANUP_MODE=kill recording_close_terminal_windows/, 'Runner preflight must kill stale recorder Terminal windows before each scenario');
assert.match(peekabooRunner, /for key in NIXMAC_E2E_MOCK_SYSTEM NIXMAC_E2E_SOLID_CAPTURE NIXMAC_E2E_OPAQUE_WINDOW NIXMAC_E2E_WEBVIEW_WATCHDOG NIXMAC_SKIP_PERMISSIONS/, 'Runner preflight must clear stale Peekaboo launchctl flags, including solid capture, opaque capture, and the independent WebView watchdog');
assert.match(e2eRuntime, /#\[cfg\(debug_assertions\)\][\s\S]*fn file_value[\s\S]*runtime\.schema_version != 1[\s\S]*runtime\.session_id\.trim\(\)\.is_empty\(\)[\s\S]*now_unix\(\)\? > runtime\.expires_at_unix/, 'Rust E2E runtime file reader must be debug-only and reject stale, malformed, or expired runtime files');
diff --git a/tools/computer-use-e2e/run-local.mjs b/tools/computer-use-e2e/run-local.mjs
index 131b919a..d5f73130 100644
--- a/tools/computer-use-e2e/run-local.mjs
+++ b/tools/computer-use-e2e/run-local.mjs
@@ -67,7 +67,7 @@ const TEST_DATA_DIR = path.join(REPO_ROOT, 'apps/native/e2e-tauri/tests/data');
const DEFAULT_FIXTURE = 'add-font.jsonl';
const DETERMINISTIC_APP_COMMAND = [
'cd apps/native',
- 'VITE_NIXMAC_SKIP_PERMISSIONS=true ./node_modules/.bin/tauri build --debug --bundles app --no-sign --config src-tauri/tauri.conf.dev.json',
+ 'VITE_NIXMAC_SKIP_PERMISSIONS=true VITE_NIXMAC_E2E_MODE=true ./node_modules/.bin/tauri build --debug --bundles app --no-sign --config src-tauri/tauri.conf.dev.json',
'open -n ../../target/debug/bundle/macos/nixmac.app',
].join(' && ');
const REAL_APP_PATH = process.env.NIXMAC_COMPUTER_USE_APP ?? '/Applications/nixmac.app';