From 88945070137cebe889dd76fb441241ca34904f92 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:50:53 -0500 Subject: [PATCH 1/2] fix(desktop): recover Windows notification permission Co-authored-by: Brad Groux Signed-off-by: Brad Groux --- desktop/src/features/notifications/hooks.ts | 13 ++-- .../notifications/lib/permission.test.mjs | 60 +++++++++++++++++++ .../features/notifications/lib/permission.ts | 26 ++++++++ desktop/src/shared/lib/platform.test.mjs | 34 +++++++++++ desktop/src/shared/lib/platform.ts | 9 +++ desktop/tests/e2e/profile.spec.ts | 48 +++++++++++++++ desktop/tests/helpers/bridge.ts | 22 +++++++ 7 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 desktop/src/features/notifications/lib/permission.test.mjs create mode 100644 desktop/src/features/notifications/lib/permission.ts create mode 100644 desktop/src/shared/lib/platform.test.mjs diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index d70ac60b22..8cda78bb76 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -1,14 +1,17 @@ import * as React from "react"; +import { isTauri } from "@tauri-apps/api/core"; import { useHomeFeedQuery } from "@/features/home/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types"; +import { isWindowsPlatform } from "@/shared/lib/platform"; import { getDesktopNotificationPermissionState, requestDesktopNotificationAccess, type DesktopNotificationPermissionState, } from "./lib/desktop"; +import { ensureDesktopNotificationPermission } from "./lib/permission"; import { COMING_SOON_SLOTS, DEFAULT_SLOT_ALERTS_ENABLED, @@ -248,10 +251,12 @@ export function useNotificationSettings(pubkey?: string) { try { let nextPermission = await refreshPermission(); - if (nextPermission === "default") { - nextPermission = await requestDesktopNotificationAccess(); - setPermission(nextPermission); - } + nextPermission = await ensureDesktopNotificationPermission({ + currentPermission: nextPermission, + isWindowsTauri: isWindowsPlatform() && isTauri(), + requestAccess: requestDesktopNotificationAccess, + }); + setPermission(nextPermission); if (nextPermission !== "granted") { setSettings((current) => ({ diff --git a/desktop/src/features/notifications/lib/permission.test.mjs b/desktop/src/features/notifications/lib/permission.test.mjs new file mode 100644 index 0000000000..d57f809777 --- /dev/null +++ b/desktop/src/features/notifications/lib/permission.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ensureDesktopNotificationPermission } from "./permission.ts"; + +test("Windows Tauri retries a false denied permission and accepts the granted result", async () => { + let requestCount = 0; + + const permission = await ensureDesktopNotificationPermission({ + currentPermission: "denied", + isWindowsTauri: true, + requestAccess: async () => { + requestCount += 1; + return "granted"; + }, + }); + + assert.equal(permission, "granted"); + assert.equal(requestCount, 1); +}); + +test("non-Windows-Tauri environments keep denied permission without requesting again", async () => { + for (const environment of [ + "Windows web", + "non-Windows Tauri", + "non-Windows web", + ]) { + let requestCount = 0; + + const permission = await ensureDesktopNotificationPermission({ + currentPermission: "denied", + isWindowsTauri: false, + requestAccess: async () => { + requestCount += 1; + return "granted"; + }, + }); + + assert.equal(permission, "denied", environment); + assert.equal(requestCount, 0, environment); + } +}); + +test("default permission still requests access on every platform", async () => { + for (const isWindowsTauri of [false, true]) { + let requestCount = 0; + + const permission = await ensureDesktopNotificationPermission({ + currentPermission: "default", + isWindowsTauri, + requestAccess: async () => { + requestCount += 1; + return "granted"; + }, + }); + + assert.equal(permission, "granted"); + assert.equal(requestCount, 1); + } +}); diff --git a/desktop/src/features/notifications/lib/permission.ts b/desktop/src/features/notifications/lib/permission.ts new file mode 100644 index 0000000000..f9f3a6a1a5 --- /dev/null +++ b/desktop/src/features/notifications/lib/permission.ts @@ -0,0 +1,26 @@ +import type { DesktopNotificationPermissionState } from "./desktop"; + +type EnsureDesktopNotificationPermissionOptions = { + currentPermission: DesktopNotificationPermissionState; + isWindowsTauri: boolean; + requestAccess: () => Promise; +}; + +/** + * Requests access for the normal default state and retries the Windows Tauri + * notification shim's known false-denied state. + */ +export async function ensureDesktopNotificationPermission({ + currentPermission, + isWindowsTauri, + requestAccess, +}: EnsureDesktopNotificationPermissionOptions): Promise { + if ( + currentPermission === "default" || + (currentPermission === "denied" && isWindowsTauri) + ) { + return requestAccess(); + } + + return currentPermission; +} diff --git a/desktop/src/shared/lib/platform.test.mjs b/desktop/src/shared/lib/platform.test.mjs new file mode 100644 index 0000000000..099bb132da --- /dev/null +++ b/desktop/src/shared/lib/platform.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isWindowsPlatform } from "./platform.ts"; + +function withNavigatorPlatform(platform, callback) { + const originalNavigator = Object.getOwnPropertyDescriptor( + globalThis, + "navigator", + ); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { platform, userAgent: "" }, + }); + + try { + callback(); + } finally { + if (originalNavigator) { + Object.defineProperty(globalThis, "navigator", originalNavigator); + } else { + delete globalThis.navigator; + } + } +} + +test("Windows platform detection accepts Win32 without matching Darwin", () => { + withNavigatorPlatform("Win32", () => { + assert.equal(isWindowsPlatform(), true); + }); + withNavigatorPlatform("Darwin", () => { + assert.equal(isWindowsPlatform(), false); + }); +}); diff --git a/desktop/src/shared/lib/platform.ts b/desktop/src/shared/lib/platform.ts index 42e7f5b944..b8c2c3ecfc 100644 --- a/desktop/src/shared/lib/platform.ts +++ b/desktop/src/shared/lib/platform.ts @@ -23,6 +23,15 @@ export function isLinuxPlatform(): boolean { ); } +/** Returns true on Windows desktops. */ +export function isWindowsPlatform(): boolean { + if (typeof navigator === "undefined") { + return false; + } + + return /^win/i.test(navigator.platform); +} + /** * The platform's normal application-shortcut modifier: * - macOS: Command (Meta) diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index eefdef1fdd..95e7af6ee0 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1313,6 +1313,54 @@ test("notification settings drive the Inbox badge and desktop alerts", async ({ await expect.poll(getAppBadgeCount).toBe(baseline); }); +test("Windows retries a false denied notification permission from settings", async ({ + page, +}) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: "Win32", + }); + (window as Window & { isTauri?: boolean }).isTauri = true; + }); + await page.goto("/"); + + await page.evaluate(() => { + ( + window as Window & { + __BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?: ( + permission: NotificationPermission, + requestResult?: NotificationPermission, + ) => void; + } + ).__BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?.("denied", "granted"); + }); + + await openSettings(page, "notifications"); + const desktopToggle = page.getByTestId("notifications-desktop-toggle"); + const desktopState = page.getByTestId("notifications-desktop-state"); + + await desktopToggle.click(); + await expect(desktopToggle).not.toBeChecked(); + await expect(desktopState).toContainText("Blocked"); + + await desktopToggle.click(); + await expect(desktopToggle).toBeChecked(); + await expect(desktopState).toContainText("On"); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__?: () => number; + } + ).__BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__?.() ?? 0, + ), + ) + .toBe(1); +}); + test("desktop notification clicks open the matching forum thread", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index f7a6c4ccec..4dfe15e297 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -826,11 +826,18 @@ export async function installBridge(page: Page, options: BridgeOptions) { title: string; }> = []; const notificationInstances: MockNotification[] = []; + let notificationPermissionRequestCount = 0; + let notificationPermissionRequestResult: NotificationPermission | null = + null; class MockNotification extends EventTarget { static permission: NotificationPermission = "granted"; static async requestPermission(): Promise { + notificationPermissionRequestCount += 1; + if (notificationPermissionRequestResult) { + MockNotification.permission = notificationPermissionRequestResult; + } return MockNotification.permission; } @@ -863,10 +870,15 @@ export async function installBridge(page: Page, options: BridgeOptions) { __BUZZ_E2E_APP_BADGE_COUNT__?: number; __BUZZ_E2E_APP_BADGE_STATE__?: string; __BUZZ_E2E_CLICK_NOTIFICATION__?: (index: number) => boolean; + __BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__?: () => number; __BUZZ_E2E_NOTIFICATIONS__?: Array<{ body: string | null; title: string; }>; + __BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?: ( + permission: NotificationPermission, + requestResult?: NotificationPermission, + ) => void; }; const currentConfig = testWindow.__BUZZ_E2E__ ?? {}; @@ -893,7 +905,17 @@ export async function installBridge(page: Page, options: BridgeOptions) { notification.onclick?.(event); return true; }; + testWindow.__BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__ = () => + notificationPermissionRequestCount; testWindow.__BUZZ_E2E_NOTIFICATIONS__ = notificationLog; + testWindow.__BUZZ_E2E_SET_NOTIFICATION_PERMISSION__ = ( + permission, + requestResult, + ) => { + MockNotification.permission = permission; + notificationPermissionRequestCount = 0; + notificationPermissionRequestResult = requestResult ?? null; + }; }, { identity, From 07207659f7c824de8896dc51c22a9ad7cb513f64 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 7 Aug 2026 17:07:19 -0600 Subject: [PATCH 2/2] test(desktop): fix Windows permission recovery expectation The permission request succeeds on the first enable attempt, so assert the toggle transitions directly to On instead of expecting an intermediate blocked state and clicking twice. Co-authored-by: Carl Signed-off-by: Wes --- desktop/tests/e2e/profile.spec.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 95e7af6ee0..7031e73e5c 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1340,10 +1340,6 @@ test("Windows retries a false denied notification permission from settings", asy const desktopToggle = page.getByTestId("notifications-desktop-toggle"); const desktopState = page.getByTestId("notifications-desktop-state"); - await desktopToggle.click(); - await expect(desktopToggle).not.toBeChecked(); - await expect(desktopState).toContainText("Blocked"); - await desktopToggle.click(); await expect(desktopToggle).toBeChecked(); await expect(desktopState).toContainText("On");