From 7817e6722bec055471ed8808e9bd82bfa3456f93 Mon Sep 17 00:00:00 2001 From: gnuskim <334340792+gnuskim@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:00:14 +0900 Subject: [PATCH] fix(desktop): request notification permission on first alert Desktop alerts default to on, but the OS permission was only requested when the Settings toggle is switched on. On a fresh install the toggle already reads as on, so the request never happened: macOS never registered Buzz in Notification Center, and channel, thread-reply, and DM alerts were silently dropped by sendDesktopNotification. Request the permission once per process at the first alert Buzz tries to deliver while it is still undetermined. Concurrent alerts share the single prompt, and once it settles we never prompt again from this path. Co-Authored-By: Claude Opus 5.5 Signed-off-by: gnuskim <334340792+gnuskim@users.noreply.github.com> --- .../src/features/notifications/lib/desktop.ts | 36 +++++++++- .../lib/desktopPermissionAutoRequest.test.mjs | 66 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/notifications/lib/desktopPermissionAutoRequest.test.mjs diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 0844c24de51..617f1bb0b47 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -415,10 +415,44 @@ export async function revealDesktopAppWindow(): Promise { } } +let autoPermissionRequest: Promise | null = + null; +let autoPermissionRequestSettled = false; + +/** + * Desktop alerts default to on, but the OS permission is only requested when + * the Settings toggle is switched on — which never happens for a fresh install + * because the toggle already reads as on. Without this, channel, thread, and DM + * alerts are silently dropped forever while the OS (e.g. macOS Notification + * Center) has never even registered the app. Ask once per process, at the + * first alert Buzz actually tries to deliver, mirroring the home-feed path. + */ +async function resolvePermissionForDelivery(): Promise { + const permission = await getDesktopNotificationPermissionState(); + if (permission !== "default" || !isTauri() || autoPermissionRequestSettled) { + return permission; + } + + // Alerts that arrive while the prompt is open share its answer; once it has + // settled, never prompt again from here (Settings can still re-request). + autoPermissionRequest ??= requestDesktopNotificationAccess() + .catch((error) => { + console.warn( + "[desktop] notification permission request failed — notification dropped:", + error, + ); + return "default" as const; + }) + .finally(() => { + autoPermissionRequestSettled = true; + }); + return autoPermissionRequest; +} + export async function sendDesktopNotification( payload: DesktopNotificationPayload, ): Promise { - if ((await getDesktopNotificationPermissionState()) !== "granted") { + if ((await resolvePermissionForDelivery()) !== "granted") { return false; } diff --git a/desktop/src/features/notifications/lib/desktopPermissionAutoRequest.test.mjs b/desktop/src/features/notifications/lib/desktopPermissionAutoRequest.test.mjs new file mode 100644 index 00000000000..e6016711d0b --- /dev/null +++ b/desktop/src/features/notifications/lib/desktopPermissionAutoRequest.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// A fresh macOS install: Desktop alerts default to on in Settings, but the +// native authorization has never been requested (UNAuthorizationStatus +// NotDetermined → "default"). +let nativePermission = "default"; +let requestResult = "granted"; +const calls = []; + +globalThis.isTauri = true; +globalThis.window = { Notification: class {} }; +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { platform: "MacIntel", userAgent: "Macintosh" }, +}); +globalThis.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + calls.push({ command, args }); + if (command === "notification_permission_state") { + return nativePermission; + } + if (command === "request_notification_access") { + nativePermission = requestResult; + return requestResult; + } + if (command === "show_native_notification") { + return null; + } + throw new Error(`unmocked Tauri command: ${command}`); + }, + transformCallback: () => 1, +}; +window.__TAURI_INTERNALS__ = globalThis.__TAURI_INTERNALS__; + +const { sendDesktopNotification } = await import("./desktop.ts"); + +function commandsCalled(command) { + return calls.filter((call) => call.command === command); +} + +test("first alert requests the never-asked OS permission once, then delivers", async () => { + const [first, second] = await Promise.all([ + sendDesktopNotification({ title: "DM", body: "hey" }), + sendDesktopNotification({ title: "Thread reply", body: "yo" }), + ]); + + assert.equal(first, true); + assert.equal(second, true); + assert.equal(commandsCalled("request_notification_access").length, 1); + assert.deepEqual( + commandsCalled("show_native_notification").map((call) => call.args.title), + ["DM", "Thread reply"], + ); +}); + +test("does not prompt again later in the same process", async () => { + // Simulate the user resetting the permission while Buzz keeps running. + nativePermission = "default"; + requestResult = "denied"; + + const delivered = await sendDesktopNotification({ title: "Mention" }); + + assert.equal(delivered, false); + assert.equal(commandsCalled("request_notification_access").length, 1); +});