From 192769ffd1f853d7b10b9d6f8e5e1c18d6e77941 Mon Sep 17 00:00:00 2001 From: Letark Date: Wed, 29 Jul 2026 20:20:55 -0600 Subject: [PATCH] feat: crash report dialog with pre-filled GitHub issue URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace bare dialog.showErrorBox in uncaughtException and unhandledRejection handlers with a two-button dialog offering to open a pre-filled GitHub issue. - New module: src/main/crash-report-dialog.ts - buildCrashReportUrl(): pre-fills template, title, platform, version, stack - showCrashReportDialog(): native sync dialog with Report/Dismiss buttons - 60s cooldown to prevent dialog spam from error loops - URL truncation to stay within browser/GitHub limits (~8000 chars) - No tokens, no telemetry — user controls submission via their own GH account - New issue template: .github/ISSUE_TEMPLATE/crash_report.md - Fields auto-populated by the crash reporter URL params - Includes privacy warning matching existing bug_report.md - Integration: src/main/index.ts - uncaughtException: showErrorBox → showCrashReportDialog - unhandledRejection: showErrorBox + inline cooldown → showCrashReportDialog - Keeps console.error + flushLogBeforeQuit before the dialog call - Tests: src/main/crash-report-dialog.test.ts (Vitest, node environment) - URL building, truncation, title limits - Dialog interaction (report, dismiss, cooldown, graceful failure) --- .github/ISSUE_TEMPLATE/crash_report.md | 41 +++++ src/main/crash-report-dialog.test.ts | 213 +++++++++++++++++++++++++ src/main/crash-report-dialog.ts | 193 ++++++++++++++++++++++ src/main/index.ts | 29 +--- 4 files changed, 451 insertions(+), 25 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/crash_report.md create mode 100644 src/main/crash-report-dialog.test.ts create mode 100644 src/main/crash-report-dialog.ts diff --git a/.github/ISSUE_TEMPLATE/crash_report.md b/.github/ISSUE_TEMPLATE/crash_report.md new file mode 100644 index 000000000..e2913e8e9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/crash_report.md @@ -0,0 +1,41 @@ +--- +name: Crash report +about: Automatically generated crash report from the app +title: '' +labels: 'crash' +assignees: '' +--- + + + +**Crash source:** `(filled by crash reporter)` + +**Desktop:** +- OS: +- App version: +- Packaged: + +**Error message:** +``` +(filled by crash reporter) +``` + +**Stack trace:** +``` +(filled by crash reporter) +``` + +--- + +**Diagnostic bundle:** +Please attach the zip from **App → Support / Bug reports → Export for GitHub** if the app is still responsive. + +**Do not** attach **Export for Developer** or `mesh-client.db` to this public issue — the database may contain saved passwords. + +**Steps to reproduce (please fill in):** +1. +2. +3. + +**Additional context:** + diff --git a/src/main/crash-report-dialog.test.ts b/src/main/crash-report-dialog.test.ts new file mode 100644 index 000000000..d544dcc29 --- /dev/null +++ b/src/main/crash-report-dialog.test.ts @@ -0,0 +1,213 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + buildCrashReportUrl, + type CrashContext, + resetCrashDialogCooldownForTests, + showCrashReportDialog, +} from './crash-report-dialog'; + +const mockShowMessageBoxSync = vi.fn().mockReturnValue(1); // default: Dismiss +const mockOpenExternal = vi.fn().mockResolvedValue(undefined); + +vi.mock('electron', () => ({ + app: { + getVersion: () => '5.24.1', + isPackaged: true, + }, + dialog: { + showMessageBoxSync: (...args: unknown[]) => mockShowMessageBoxSync(...args), + }, + shell: { + openExternal: (...args: unknown[]) => mockOpenExternal(...args), + }, +})); + +vi.mock('./sanitize-log-message', () => ({ + sanitizeLogMessage: (msg: string) => msg, +})); + +afterEach(() => { + vi.clearAllMocks(); + resetCrashDialogCooldownForTests(); +}); + +describe('buildCrashReportUrl', () => { + it('builds a valid GitHub issue URL with crash context', () => { + const ctx: CrashContext = { + source: 'uncaughtException', + error: new Error('Cannot read properties of null'), + }; + + const url = buildCrashReportUrl(ctx); + + expect(url).toContain('https://github.com/Colorado-Mesh/mesh-client/issues/new'); + expect(url).toContain('template=crash_report.md'); + expect(url).toContain('Cannot+read+properties+of+null'); + expect(url).toContain('uncaughtException'); + expect(url).toContain('5.24.1'); + }); + + it('includes platform and architecture info', () => { + const ctx: CrashContext = { + source: 'unhandledRejection', + error: new Error('ENOENT'), + }; + + const url = decodeURIComponent(buildCrashReportUrl(ctx)); + + expect(url).toContain(`App version: 5.24.1`); + expect(url).toContain('Packaged: yes'); + expect(url).toContain(process.arch); + }); + + it('handles string errors', () => { + const ctx: CrashContext = { + source: 'uncaughtException', + error: 'raw string error', + }; + + const url = buildCrashReportUrl(ctx); + + expect(url).toContain('raw+string+error'); + expect(url).toContain('no+stack+trace'); + }); + + it('truncates URLs exceeding the max length', () => { + const longMessage = 'x'.repeat(10_000); + const ctx: CrashContext = { + source: 'uncaughtException', + error: new Error(longMessage), + }; + + const url = buildCrashReportUrl(ctx); + + expect(url.length).toBeLessThanOrEqual(8200); // allow slight encoding overhead + expect(url).toContain('truncated'); + }); + + it('truncates title to 80 chars', () => { + const longMessage = 'A'.repeat(200); + const ctx: CrashContext = { + source: 'uncaughtException', + error: new Error(longMessage), + }; + + const url = buildCrashReportUrl(ctx); + const params = new URLSearchParams(url.split('?')[1]); + const title = params.get('title') ?? ''; + + // [Crash] + space + 80 chars = 88 max + expect(title.length).toBeLessThanOrEqual(88); + }); +}); + +describe('showCrashReportDialog', () => { + it('shows dialog and returns false when user dismisses', () => { + mockShowMessageBoxSync.mockReturnValue(1); + + const ctx: CrashContext = { + source: 'uncaughtException', + error: new Error('test error'), + }; + + const result = showCrashReportDialog(ctx); + + expect(result).toBe(false); + expect(mockShowMessageBoxSync).toHaveBeenCalledOnce(); + expect(mockOpenExternal).not.toHaveBeenCalled(); + }); + + it('opens browser and returns true when user clicks Report', () => { + mockShowMessageBoxSync.mockReturnValue(0); + + const ctx: CrashContext = { + source: 'uncaughtException', + error: new Error('test error'), + }; + + const result = showCrashReportDialog(ctx); + + expect(result).toBe(true); + expect(mockOpenExternal).toHaveBeenCalledOnce(); + expect(mockOpenExternal.mock.calls[0][0]).toContain( + 'https://github.com/Colorado-Mesh/mesh-client/issues/new', + ); + }); + + it('respects 60s cooldown between dialogs', () => { + mockShowMessageBoxSync.mockReturnValue(1); + + const ctx: CrashContext = { + source: 'uncaughtException', + error: new Error('first'), + }; + + showCrashReportDialog(ctx); + const secondResult = showCrashReportDialog(ctx); + + expect(secondResult).toBe(false); + expect(mockShowMessageBoxSync).toHaveBeenCalledOnce(); + }); + + it('shows dialog again after cooldown resets', () => { + mockShowMessageBoxSync.mockReturnValue(1); + + const ctx: CrashContext = { + source: 'uncaughtException', + error: new Error('test'), + }; + + showCrashReportDialog(ctx); + resetCrashDialogCooldownForTests(); + showCrashReportDialog(ctx); + + expect(mockShowMessageBoxSync).toHaveBeenCalledTimes(2); + }); + + it('passes error type in dialog options', () => { + mockShowMessageBoxSync.mockReturnValue(1); + + const ctx: CrashContext = { + source: 'uncaughtException', + error: new Error('something broke'), + }; + + showCrashReportDialog(ctx); + + const options = mockShowMessageBoxSync.mock.calls[0][0] as Record; + expect(options.type).toBe('error'); + expect(options.buttons).toEqual(['Report on GitHub', 'Dismiss']); + expect(options.detail).toContain('something broke'); + expect(options.detail).toContain('uncaughtException'); + }); + + it('handles dialog unavailable gracefully', () => { + mockShowMessageBoxSync.mockImplementation(() => { + throw new Error('dialog unavailable'); + }); + + const ctx: CrashContext = { + source: 'uncaughtException', + error: new Error('test'), + }; + + expect(() => showCrashReportDialog(ctx)).not.toThrow(); + expect(showCrashReportDialog(ctx)).toBe(false); + }); + + it('handles string errors in dialog detail', () => { + mockShowMessageBoxSync.mockReturnValue(1); + + const ctx: CrashContext = { + source: 'unhandledRejection', + error: 'non-Error rejection value', + }; + + showCrashReportDialog(ctx); + + const options = mockShowMessageBoxSync.mock.calls[0][0] as Record; + expect(options.detail).toContain('non-Error rejection value'); + }); +}); diff --git a/src/main/crash-report-dialog.ts b/src/main/crash-report-dialog.ts new file mode 100644 index 000000000..1c6c4e153 --- /dev/null +++ b/src/main/crash-report-dialog.ts @@ -0,0 +1,193 @@ +/** + * Crash report dialog — offers to open a pre-filled GitHub issue on fatal errors. + * + * Replaces bare `dialog.showErrorBox` in the uncaughtException / unhandledRejection handlers + * with a two-button dialog: "Report on GitHub" (opens browser) or "Dismiss". + * + * Design: + * - No tokens, proxies, or telemetry — user controls submission via their own GitHub account + * - Pre-filled issue URL with platform, version, error, and stack trace + * - 60s cooldown prevents dialog spam from error loops + * - `showMessageBoxSync` for synchronous uncaughtException context + */ +import { app, dialog, shell } from 'electron'; +import { release as osRelease } from 'node:os'; + +import { sanitizeLogMessage } from './sanitize-log-message'; + +const REPO_OWNER = 'Colorado-Mesh'; +const REPO_NAME = 'mesh-client'; +const ISSUE_TEMPLATE = 'crash_report.md'; + +/** Max URL length safe for most browsers and GitHub's server. */ +const MAX_URL_LENGTH = 8000; +/** Max stack trace chars to include in the issue body. */ +const MAX_STACK_LENGTH = 1500; +/** Cooldown between crash dialogs to avoid spam from error loops. */ +const CRASH_DIALOG_COOLDOWN_MS = 60_000; + +export interface CrashContext { + /** 'uncaughtException' | 'unhandledRejection' | 'render-process-gone' */ + source: string; + error: Error | string; +} + +function getAppVersion(): string { + try { + return app.getVersion(); + } catch { + return 'unknown'; + } +} + +function getPlatformLabel(): string { + const labels: Record = { + darwin: 'macOS', + linux: 'Linux', + win32: 'Windows', + }; + return labels[process.platform] ?? process.platform; +} + +function formatErrorForTitle(ctx: CrashContext): string { + const msg = ctx.error instanceof Error ? ctx.error.message : String(ctx.error); + const cleaned = sanitizeLogMessage(msg).replace(/\n/g, ' ').slice(0, 80); + return `[Crash] ${cleaned}`; +} + +function formatErrorForBody(ctx: CrashContext): string { + const msg = ctx.error instanceof Error ? ctx.error.message : String(ctx.error); + const stack = + ctx.error instanceof Error && ctx.error.stack + ? ctx.error.stack.slice(0, MAX_STACK_LENGTH) + : '(no stack trace)'; + + const platform = getPlatformLabel(); + const version = getAppVersion(); + const arch = process.arch; + const os = osRelease(); + const packaged = app.isPackaged ? 'yes' : 'no (dev)'; + + return [ + '**Crash source:** `' + ctx.source + '`', + '', + '**Desktop:**', + `- OS: ${platform} ${os} (${arch})`, + `- App version: ${version}`, + `- Packaged: ${packaged}`, + '', + '**Error message:**', + '```', + sanitizeLogMessage(msg), + '```', + '', + '**Stack trace:**', + '```', + sanitizeLogMessage(stack), + '```', + '', + '---', + '', + '**Diagnostic bundle:**', + 'Please also attach the zip from **App → Support / Bug reports → Export for GitHub** if the app is still responsive.', + '', + '**Steps to reproduce (please fill in):**', + '1. ', + '2. ', + '3. ', + '', + '**Additional context:**', + '', + ].join('\n'); +} + +/** + * Build a GitHub new-issue URL pre-filled with crash context. + * Truncates body if the URL exceeds safe browser limits. + */ +export function buildCrashReportUrl(ctx: CrashContext): string { + const title = formatErrorForTitle(ctx); + let body = formatErrorForBody(ctx); + + const baseUrl = `https://github.com/${REPO_OWNER}/${REPO_NAME}/issues/new`; + const params = new URLSearchParams({ + template: ISSUE_TEMPLATE, + title, + body, + }); + + let url = `${baseUrl}?${params.toString()}`; + + if (url.length > MAX_URL_LENGTH) { + const overhead = url.length - body.length; + const maxBody = MAX_URL_LENGTH - overhead - 100; + body = + body.slice(0, maxBody) + '\n\n_(truncated — attach Export for GitHub zip for full details)_'; + const truncatedParams = new URLSearchParams({ + template: ISSUE_TEMPLATE, + title, + body, + }); + url = `${baseUrl}?${truncatedParams.toString()}`; + } + + return url; +} + +let lastCrashDialogAt = 0; + +/** + * Show a crash dialog with "Report on GitHub" and "Dismiss" buttons. + * + * Uses `dialog.showMessageBoxSync` (synchronous) because the uncaughtException handler + * is a sync context — the dialog must block before the process potentially exits. + * + * Returns true if the user chose to report. + */ +export function showCrashReportDialog(ctx: CrashContext): boolean { + const now = Date.now(); + if (now - lastCrashDialogAt < CRASH_DIALOG_COOLDOWN_MS) { + return false; + } + lastCrashDialogAt = now; + + const msg = ctx.error instanceof Error ? ctx.error.message : String(ctx.error); + const detail = [ + `Source: ${ctx.source}`, + '', + sanitizeLogMessage(msg).slice(0, 500), + '', + 'Would you like to report this crash on GitHub?', + '(Opens your browser with a pre-filled issue. No data is sent automatically.)', + ].join('\n'); + + try { + const response = dialog.showMessageBoxSync({ + type: 'error', + title: 'Mesh-Client — Unexpected Error', + message: 'An unexpected error occurred.', + detail, + buttons: ['Report on GitHub', 'Dismiss'], + defaultId: 0, + cancelId: 1, + noLink: true, + }); + + if (response === 0) { + const url = buildCrashReportUrl(ctx); + void shell.openExternal(url).catch(() => { + // catch-no-log-ok openExternal failure; crash already logged by caller + }); + return true; + } + } catch { + // catch-no-log-ok dialog unavailable during early startup or after app quit + } + + return false; +} + +/** Reset cooldown timer (exported for testing only). */ +export function resetCrashDialogCooldownForTests(): void { + lastCrashDialogAt = 0; +} diff --git a/src/main/index.ts b/src/main/index.ts index 53524d180..289afb406 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -54,6 +54,7 @@ import { } from './ble-coexistence-coordinator'; import { ensureCameraAccess, isAllowedCameraPrivacySettingsUrl } from './cameraAccess'; import { formatChatExportLines } from './chatExportFormat'; +import { showCrashReportDialog } from './crash-report-dialog'; import { addContactToGroup, closeDatabase, @@ -463,39 +464,17 @@ process.on('uncaughtException', (error) => { sanitizeLogMessage(error?.stack ?? error?.message ?? String(error)), ); void flushLogBeforeQuit(); - try { - dialog.showErrorBox( - 'Mesh-Client — Unexpected Error', - `${error.message}\n\n${error.stack ?? ''}`, - ); - } catch { - // catch-no-log-ok dialog unavailable during early startup; error already logged above - } + showCrashReportDialog({ source: 'uncaughtException', error }); }); -// Throttle user-visible dialog so a tight loop of rejections does not spam the user -let lastUnhandledRejectionDialogAt = 0; -const UNHANDLED_REJECTION_DIALOG_COOLDOWN_MS = 60_000; - process.on('unhandledRejection', (reason) => { console.error( '[main] Unhandled rejection:', sanitizeLogMessage(reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)), ); void flushLogBeforeQuit(); - const now = Date.now(); - if (now - lastUnhandledRejectionDialogAt < UNHANDLED_REJECTION_DIALOG_COOLDOWN_MS) return; - lastUnhandledRejectionDialogAt = now; - const message = - reason instanceof Error ? `${reason.message}\n\n${reason.stack ?? ''}` : String(reason); - try { - dialog.showErrorBox( - 'Mesh-Client — Unhandled Promise Rejection', - `A promise rejected without a handler. Check the main process terminal for full details.\n\n${message.slice(0, 1500)}${message.length > 1500 ? '…' : ''}`, - ); - } catch { - // catch-no-log-ok dialog unavailable during early startup; rejection already logged above - } + const error = reason instanceof Error ? reason : new Error(String(reason)); + showCrashReportDialog({ source: 'unhandledRejection', error }); }); // ─── Bluetooth pairing handler (Linux only) ──────────────────────────