Skip to content

Commit 30812c8

Browse files
committed
feat(desktop): add "Email us" (support@open-design.ai) to the crash screen
Alongside "Report a problem" / "Save logs…", the recovery screen now offers a prefilled mailto to our support address for users who'd rather email than open a GitHub account. It reuses the existing openExternal bridge; the shell:open-external handler now also allows a mailto addressed strictly to the support address (isSupportMailtoUrl, unit-tested) — no other scheme or address opens, so widening past http can't be abused into arbitrary-scheme launches. The link hides itself if the bridge is absent, like the other actions.
1 parent 23c6601 commit 30812c8

2 files changed

Lines changed: 81 additions & 7 deletions

File tree

apps/desktop/src/main/runtime.ts

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,20 @@ interface RendererCrashScreenContext {
989989
}
990990

991991
const CRASH_REPORT_ISSUES_URL = "https://github.com/nexu-io/open-design/issues/new";
992+
const SUPPORT_EMAIL = "support@open-design.ai";
993+
994+
// Narrow allowlist for the crash screen's "Email us" action: only a mailto
995+
// addressed to our own support address opens, so widening `shell:open-external`
996+
// past http can't be abused into arbitrary-scheme launches. The ?subject/&body
997+
// query is ignored for the check (URL parses the address into `pathname`).
998+
export function isSupportMailtoUrl(url: string): boolean {
999+
try {
1000+
const parsed = new URL(url);
1001+
return parsed.protocol === "mailto:" && parsed.pathname.toLowerCase() === SUPPORT_EMAIL;
1002+
} catch {
1003+
return false;
1004+
}
1005+
}
9921006

9931007
function osLabelForReport(platform: NodeJS.Platform): string {
9941008
if (platform === "darwin") return "macOS";
@@ -1030,8 +1044,25 @@ function buildCrashReportUrl(ctx: RendererCrashScreenContext): string {
10301044
return `${CRASH_REPORT_ISSUES_URL}?${new URLSearchParams({ title, body }).toString()}`;
10311045
}
10321046

1047+
// Prefilled mailto for the "Email us" action — same auto-filled diagnostics as
1048+
// the issue, for users who'd rather email than open a GitHub account.
1049+
function buildCrashMailtoUrl(ctx: RendererCrashScreenContext): string {
1050+
const subject = `Open Design keeps crashing (renderer ${ctx.reason})`;
1051+
const body = [
1052+
"The Open Design desktop app crashed several times in a row on my device.",
1053+
"",
1054+
"(If possible, attach the diagnostics file you saved with the “Save logs…” button.)",
1055+
"",
1056+
`App version: ${ctx.appVersion}`,
1057+
`OS: ${osLabelForReport(ctx.platform)} ${ctx.osVersion}`,
1058+
`Renderer exit: ${ctx.reason}, code ${formatRendererExitCode(ctx.exitCode)}`,
1059+
].join("\n");
1060+
return `mailto:${SUPPORT_EMAIL}?${new URLSearchParams({ subject, body }).toString()}`;
1061+
}
1062+
10331063
function createRendererCrashHtml(ctx: RendererCrashScreenContext): string {
10341064
const issueUrl = buildCrashReportUrl(ctx);
1065+
const mailtoUrl = buildCrashMailtoUrl(ctx);
10351066
return `data:text/html;charset=utf-8,${encodeURIComponent(`<!doctype html>
10361067
<html>
10371068
<head>
@@ -1099,6 +1130,14 @@ function createRendererCrashHtml(ctx: RendererCrashScreenContext): string {
10991130
margin: 12px 0 0;
11001131
min-height: 16px;
11011132
}
1133+
.email {
1134+
color: #8a929a;
1135+
font-size: 13px;
1136+
line-height: 1.5;
1137+
margin: 14px 0 0;
1138+
}
1139+
.email a { color: #2b6cb0; text-decoration: none; }
1140+
.email a:hover { text-decoration: underline; }
11021141
.hint {
11031142
color: #8a929a;
11041143
font-size: 13px;
@@ -1108,7 +1147,8 @@ function createRendererCrashHtml(ctx: RendererCrashScreenContext): string {
11081147
@media (prefers-color-scheme: dark) {
11091148
html, body { background: #1c2024; color: #e6e9ec; }
11101149
.body { color: #aeb6bd; }
1111-
.hint, .status { color: #7c848b; }
1150+
.hint, .status, .email { color: #7c848b; }
1151+
.email a { color: #6ea8e0; }
11121152
.secondary { color: #e6e9ec; border-color: rgba(174, 182, 189, 0.35); }
11131153
.secondary:hover { border-color: rgba(174, 182, 189, 0.6); }
11141154
}
@@ -1124,24 +1164,35 @@ function createRendererCrashHtml(ctx: RendererCrashScreenContext): string {
11241164
<button id="logs" class="secondary">Save logs…</button>
11251165
</div>
11261166
<p class="status" id="status" aria-live="polite"></p>
1167+
<p class="email" id="email-line">Prefer email? <a href="#" id="email">Contact ${SUPPORT_EMAIL}</a></p>
11271168
<p class="hint">If this keeps happening, quitting and reinstalling Open Design usually resolves it.</p>
11281169
</div>
11291170
<script>
11301171
(function () {
11311172
var issueUrl = ${JSON.stringify(issueUrl)};
1173+
var mailtoUrl = ${JSON.stringify(mailtoUrl)};
11321174
var host = window.__od__;
11331175
var diag = window.openDesignDesktop;
11341176
var report = document.getElementById("report");
11351177
var logs = document.getElementById("logs");
1178+
var emailLine = document.getElementById("email-line");
1179+
var email = document.getElementById("email");
11361180
var status = document.getElementById("status");
11371181
function say(t) { if (status) status.textContent = t; }
1138-
// Buttons reuse IPC the preload already exposes; if a bridge is missing
1139-
// (preload failed to load) hide the dead control instead of a no-op.
1182+
var canOpen = host && typeof host.openExternal === "function";
1183+
// Actions reuse IPC the preload already exposes; if the bridge is
1184+
// missing (preload failed to load) hide the dead control instead of a
1185+
// no-op.
11401186
if (report) {
1141-
if (host && typeof host.openExternal === "function") {
1187+
if (canOpen) {
11421188
report.addEventListener("click", function () { host.openExternal(issueUrl); });
11431189
} else { report.style.display = "none"; }
11441190
}
1191+
if (email) {
1192+
if (canOpen) {
1193+
email.addEventListener("click", function (e) { e.preventDefault(); host.openExternal(mailtoUrl); });
1194+
} else if (emailLine) { emailLine.style.display = "none"; }
1195+
}
11451196
if (logs) {
11461197
if (diag && typeof diag.exportDiagnostics === "function") {
11471198
logs.addEventListener("click", function () {
@@ -1806,7 +1857,9 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
18061857
ipcMain.removeHandler(channel);
18071858
}
18081859
ipcMain.handle("shell:open-external", async (_event, url: string) => {
1809-
if (!isHttpUrl(url)) return false;
1860+
// http(s) as before, plus a mailto strictly to our support address (the
1861+
// crash screen's "Email us"); no other scheme opens.
1862+
if (!isHttpUrl(url) && !isSupportMailtoUrl(url)) return false;
18101863
try {
18111864
await shell.openExternal(url);
18121865
return true;

apps/desktop/tests/main/renderer-crash-loop.test.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
RENDERER_CRASH_LOOP_WINDOW_MS,
99
RendererCrashLoopBreaker,
1010
} from "../../src/main/renderer-crash-loop.js";
11+
import { isSupportMailtoUrl } from "../../src/main/runtime.js";
1112

1213
describe("RendererCrashLoopBreaker", () => {
1314
test("stays closed while crashes are below the limit inside the window", () => {
@@ -128,16 +129,36 @@ describe("renderer crash-loop breaker wiring", () => {
128129
expect(runtimeSource).toContain("recovery_attempt:");
129130
});
130131

131-
test("the crash screen offers report + save-logs actions via already-exposed IPC", () => {
132+
test("the crash screen offers report + save-logs + email actions via already-exposed IPC", () => {
132133
// Reuse the preload's existing bridges (no new surface): openExternal for a
133-
// prefilled GitHub issue, exportDiagnostics for the log bundle.
134+
// prefilled GitHub issue / support mailto, exportDiagnostics for the bundle.
134135
expect(runtimeSource).toContain("issues/new");
135136
expect(runtimeSource).toContain("window.__od__");
136137
expect(runtimeSource).toContain("openExternal");
137138
expect(runtimeSource).toContain("window.openDesignDesktop");
138139
expect(runtimeSource).toContain("exportDiagnostics");
140+
expect(runtimeSource).toContain("support@open-design.ai");
141+
expect(runtimeSource).toContain("buildCrashMailtoUrl");
139142
// Report body prefilled with the version/OS/exit code a triager needs.
140143
expect(runtimeSource).toContain("buildCrashReportUrl");
141144
expect(runtimeSource).toContain("formatRendererExitCode");
142145
});
143146
});
147+
148+
describe("isSupportMailtoUrl", () => {
149+
test("allows a mailto strictly to the support address (with or without a query)", () => {
150+
expect(isSupportMailtoUrl("mailto:support@open-design.ai")).toBe(true);
151+
expect(isSupportMailtoUrl("mailto:support@open-design.ai?subject=Crash&body=hi")).toBe(true);
152+
// Address comparison is case-insensitive.
153+
expect(isSupportMailtoUrl("mailto:Support@Open-Design.AI")).toBe(true);
154+
});
155+
156+
test("rejects any other address or scheme so widening open-external can't be abused", () => {
157+
expect(isSupportMailtoUrl("mailto:attacker@evil.com")).toBe(false);
158+
expect(isSupportMailtoUrl("mailto:support@evil.com")).toBe(false);
159+
expect(isSupportMailtoUrl("https://open-design.ai")).toBe(false);
160+
expect(isSupportMailtoUrl("javascript:alert(1)")).toBe(false);
161+
expect(isSupportMailtoUrl("file:///etc/passwd")).toBe(false);
162+
expect(isSupportMailtoUrl("not a url")).toBe(false);
163+
});
164+
});

0 commit comments

Comments
 (0)