diff --git a/desktop/src/features/notifications/lib/desktop.test.mjs b/desktop/src/features/notifications/lib/desktop.test.mjs new file mode 100644 index 0000000000..7fb8830683 --- /dev/null +++ b/desktop/src/features/notifications/lib/desktop.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const notifications = []; + +class WorkingNotification { + static permission = "granted"; + + constructor(title, options) { + notifications.push({ title, options }); + } + + close() {} +} + +class ThrowingNotification { + static permission = "granted"; + + constructor() { + throw new Error("notification backend unavailable"); + } +} + +globalThis.window = { Notification: ThrowingNotification }; + +const { sendDesktopNotification } = await import("./desktop.ts"); + +test("constructor failure is a delivery miss and does not prevent a later notification", async (t) => { + const warnings = []; + t.mock.method(console, "warn", (...args) => warnings.push(args)); + + const failed = await sendDesktopNotification({ title: "First" }); + + assert.equal(failed, false); + assert.equal(warnings.length, 1); + assert.match(String(warnings[0][1]), /notification backend unavailable/); + + window.Notification = WorkingNotification; + + const delivered = await sendDesktopNotification({ + title: "Second", + body: "Recovered", + }); + + assert.equal(delivered, true); + assert.deepEqual(notifications, [ + { + title: "Second", + options: { body: "Recovered", silent: true, extra: undefined }, + }, + ]); +}); diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index dbc21d9d23..45716de2d9 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -381,19 +381,32 @@ export async function sendDesktopNotification( } } - const notification = new window.Notification(payload.title, { - body: payload.body, - silent: true, - extra: notificationExtra(payload.target), - } as DesktopNotificationOptions); - - const target = payload.target; - if (!isTauri() && target) { - notification.onclick = () => { - dispatchDesktopNotificationTarget(target); - notification.close(); - }; - } + // block/buzz#5081 — WebKit throws `NotificationError` from the constructor + // when the notification backend becomes temporarily unavailable. Callers + // discard the returned promise without a rejection handler, so an + // un-guarded throw becomes an unhandled rejection. Treat constructor failure + // as a delivery miss (return false) and log the failed delivery. + try { + const notification = new window.Notification(payload.title, { + body: payload.body, + silent: true, + extra: notificationExtra(payload.target), + } as DesktopNotificationOptions); + + const target = payload.target; + if (!isTauri() && target) { + notification.onclick = () => { + dispatchDesktopNotificationTarget(target); + notification.close(); + }; + } - return true; + return true; + } catch (error) { + console.warn( + "[desktop] window.Notification constructor threw — notification dropped:", + error, + ); + return false; + } }