Skip to content

Commit 68b4bb9

Browse files
BradGrouxdm-builder
authored andcommitted
fix(desktop): recover Windows notification permission
Co-authored-by: Brad Groux <bradgroux@hotmail.com> Signed-off-by: Brad Groux <bradgroux@hotmail.com>
1 parent 752cbfc commit 68b4bb9

7 files changed

Lines changed: 305 additions & 5 deletions

File tree

desktop/src/features/notifications/hooks.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import * as React from "react";
22

3+
import { isTauri } from "@tauri-apps/api/core";
34
import { useHomeFeedQuery } from "@/features/home/hooks";
45
import { useUsersBatchQuery } from "@/features/profile/hooks";
56
import type { UserProfileLookup } from "@/features/profile/lib/identity";
67
import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types";
78
import { scheduleAfterForegroundReady } from "@/shared/lib/foregroundReady";
9+
import { isWindowsPlatform } from "@/shared/lib/platform";
810
import {
911
getDesktopNotificationPermissionState,
1012
requestDesktopNotificationAccess,
1113
type DesktopNotificationPermissionState,
1214
} from "./lib/desktop";
15+
import { ensureDesktopNotificationPermission } from "./lib/permission";
1316
import {
1417
COMING_SOON_SLOTS,
1518
DEFAULT_SLOT_ALERTS_ENABLED,
@@ -200,7 +203,16 @@ export function useNotificationSettings(pubkey?: string) {
200203
}, [normalizedPubkey, settings]);
201204

202205
const refreshPermission = React.useEffectEvent(async () => {
203-
const nextPermission = await getDesktopNotificationPermissionState();
206+
let nextPermission = await getDesktopNotificationPermissionState();
207+
// Windows Tauri boots with a false "denied" from the init shim before the
208+
// app is registered as a notification sender. Apply the same one-shot
209+
// recovery the toggle uses so the mount-time read does not write off a
210+
// persisted desktopEnabled=true before the user touches anything.
211+
nextPermission = await ensureDesktopNotificationPermission({
212+
currentPermission: nextPermission,
213+
isWindowsTauri: isWindowsPlatform() && isTauri(),
214+
requestAccess: requestDesktopNotificationAccess,
215+
});
204216
setPermission(nextPermission);
205217
return nextPermission;
206218
});
@@ -258,10 +270,12 @@ export function useNotificationSettings(pubkey?: string) {
258270

259271
try {
260272
let nextPermission = await refreshPermission();
261-
if (nextPermission === "default") {
262-
nextPermission = await requestDesktopNotificationAccess();
263-
setPermission(nextPermission);
264-
}
273+
nextPermission = await ensureDesktopNotificationPermission({
274+
currentPermission: nextPermission,
275+
isWindowsTauri: isWindowsPlatform() && isTauri(),
276+
requestAccess: requestDesktopNotificationAccess,
277+
});
278+
setPermission(nextPermission);
265279

266280
if (nextPermission !== "granted") {
267281
setSettings((current) => ({
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { ensureDesktopNotificationPermission } from "./permission.ts";
5+
6+
test("Windows Tauri retries a false denied permission and accepts the granted result", async () => {
7+
let requestCount = 0;
8+
9+
const permission = await ensureDesktopNotificationPermission({
10+
currentPermission: "denied",
11+
isWindowsTauri: true,
12+
requestAccess: async () => {
13+
requestCount += 1;
14+
return "granted";
15+
},
16+
});
17+
18+
assert.equal(permission, "granted");
19+
assert.equal(requestCount, 1);
20+
});
21+
22+
test("non-Windows-Tauri environments keep denied permission without requesting again", async () => {
23+
for (const environment of [
24+
"Windows web",
25+
"non-Windows Tauri",
26+
"non-Windows web",
27+
]) {
28+
let requestCount = 0;
29+
30+
const permission = await ensureDesktopNotificationPermission({
31+
currentPermission: "denied",
32+
isWindowsTauri: false,
33+
requestAccess: async () => {
34+
requestCount += 1;
35+
return "granted";
36+
},
37+
});
38+
39+
assert.equal(permission, "denied", environment);
40+
assert.equal(requestCount, 0, environment);
41+
}
42+
});
43+
44+
test("default permission still requests access on every platform", async () => {
45+
for (const isWindowsTauri of [false, true]) {
46+
let requestCount = 0;
47+
48+
const permission = await ensureDesktopNotificationPermission({
49+
currentPermission: "default",
50+
isWindowsTauri,
51+
requestAccess: async () => {
52+
requestCount += 1;
53+
return "granted";
54+
},
55+
});
56+
57+
assert.equal(permission, "granted");
58+
assert.equal(requestCount, 1);
59+
}
60+
});
61+
62+
test("Windows Tauri recovers a false denied at boot so persisted desktopEnabled survives relaunch", async () => {
63+
// Simulates the mount-time refreshPermission path: the init shim stamps
64+
// "denied" before the app is registered as a notification sender, but a
65+
// single requestPermission() returns "granted". Without this recovery the
66+
// mount-time effect writes desktopEnabled=false before the user touches
67+
// anything, requiring re-enabling after every restart.
68+
let requestCount = 0;
69+
70+
const permission = await ensureDesktopNotificationPermission({
71+
currentPermission: "denied",
72+
isWindowsTauri: true,
73+
requestAccess: async () => {
74+
requestCount += 1;
75+
return "granted";
76+
},
77+
});
78+
79+
assert.equal(permission, "granted");
80+
assert.equal(requestCount, 1, "boot-time recovery fires exactly once");
81+
});
82+
83+
test("Windows Tauri does not recover a genuine granted permission at boot", async () => {
84+
// If the OS already grants permission, the boot-time read should not
85+
// trigger an unnecessary requestPermission() call.
86+
let requestCount = 0;
87+
88+
const permission = await ensureDesktopNotificationPermission({
89+
currentPermission: "granted",
90+
isWindowsTauri: true,
91+
requestAccess: async () => {
92+
requestCount += 1;
93+
return "granted";
94+
},
95+
});
96+
97+
assert.equal(permission, "granted");
98+
assert.equal(requestCount, 0, "granted does not trigger a re-request");
99+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { DesktopNotificationPermissionState } from "./desktop";
2+
3+
type EnsureDesktopNotificationPermissionOptions = {
4+
currentPermission: DesktopNotificationPermissionState;
5+
isWindowsTauri: boolean;
6+
requestAccess: () => Promise<DesktopNotificationPermissionState>;
7+
};
8+
9+
/**
10+
* Requests access for the normal default state and retries the Windows Tauri
11+
* notification shim's known false-denied state.
12+
*/
13+
export async function ensureDesktopNotificationPermission({
14+
currentPermission,
15+
isWindowsTauri,
16+
requestAccess,
17+
}: EnsureDesktopNotificationPermissionOptions): Promise<DesktopNotificationPermissionState> {
18+
if (
19+
currentPermission === "default" ||
20+
(currentPermission === "denied" && isWindowsTauri)
21+
) {
22+
return requestAccess();
23+
}
24+
25+
return currentPermission;
26+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { isWindowsPlatform } from "./platform.ts";
5+
6+
function withNavigatorPlatform(platform, callback) {
7+
const originalNavigator = Object.getOwnPropertyDescriptor(
8+
globalThis,
9+
"navigator",
10+
);
11+
Object.defineProperty(globalThis, "navigator", {
12+
configurable: true,
13+
value: { platform, userAgent: "" },
14+
});
15+
16+
try {
17+
callback();
18+
} finally {
19+
if (originalNavigator) {
20+
Object.defineProperty(globalThis, "navigator", originalNavigator);
21+
} else {
22+
delete globalThis.navigator;
23+
}
24+
}
25+
}
26+
27+
test("Windows platform detection accepts Win32 without matching Darwin", () => {
28+
withNavigatorPlatform("Win32", () => {
29+
assert.equal(isWindowsPlatform(), true);
30+
});
31+
withNavigatorPlatform("Darwin", () => {
32+
assert.equal(isWindowsPlatform(), false);
33+
});
34+
});

desktop/src/shared/lib/platform.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ export function isLinuxPlatform(): boolean {
2323
);
2424
}
2525

26+
/** Returns true on Windows desktops. */
27+
export function isWindowsPlatform(): boolean {
28+
if (typeof navigator === "undefined") {
29+
return false;
30+
}
31+
32+
return /^win/i.test(navigator.platform);
33+
}
34+
2635
/**
2736
* The platform's normal application-shortcut modifier:
2837
* - macOS: Command (Meta)

desktop/tests/e2e/profile.spec.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2363,6 +2363,102 @@ test("notification settings drive the Inbox badge and desktop alerts", async ({
23632363
await expect.poll(getAppBadgeCount).toBe(baseline);
23642364
});
23652365

2366+
test("Windows retries a false denied notification permission from settings", async ({
2367+
page,
2368+
}) => {
2369+
await page.addInitScript(() => {
2370+
Object.defineProperty(navigator, "platform", {
2371+
configurable: true,
2372+
value: "Win32",
2373+
});
2374+
(window as Window & { isTauri?: boolean }).isTauri = true;
2375+
});
2376+
await page.goto("/");
2377+
2378+
await page.evaluate(() => {
2379+
(
2380+
window as Window & {
2381+
__BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?: (
2382+
permission: NotificationPermission,
2383+
requestResult?: NotificationPermission,
2384+
) => void;
2385+
}
2386+
).__BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?.("denied", "granted");
2387+
});
2388+
2389+
await openSettings(page, "notifications");
2390+
const desktopToggle = page.getByTestId("notifications-desktop-toggle");
2391+
const desktopState = page.getByTestId("notifications-desktop-state");
2392+
2393+
await desktopToggle.click();
2394+
await expect(desktopToggle).not.toBeChecked();
2395+
await expect(desktopState).toContainText("Blocked");
2396+
2397+
await desktopToggle.click();
2398+
await expect(desktopToggle).toBeChecked();
2399+
await expect(desktopState).toContainText("On");
2400+
await expect
2401+
.poll(() =>
2402+
page.evaluate(
2403+
() =>
2404+
(
2405+
window as Window & {
2406+
__BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__?: () => number;
2407+
}
2408+
).__BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__?.() ?? 0,
2409+
),
2410+
)
2411+
.toBe(1);
2412+
});
2413+
2414+
test("Windows boot-time recovery prevents false-denied from disabling persisted notifications", async ({
2415+
page,
2416+
}) => {
2417+
// Simulates the scenario Joxyko reported: a persisted desktopEnabled=true
2418+
// is written off on every relaunch because the boot-time read sees the
2419+
// init shim's false "denied" before the app is registered as a notification
2420+
// sender. With the boot-time recovery in refreshPermission, the mount-time
2421+
// read should request once and see "granted", so the toggle stays on.
2422+
await page.addInitScript(() => {
2423+
Object.defineProperty(navigator, "platform", {
2424+
configurable: true,
2425+
value: "Win32",
2426+
});
2427+
(window as Window & { isTauri?: boolean }).isTauri = true;
2428+
});
2429+
await page.goto("/");
2430+
2431+
// Pre-seed a persisted desktopEnabled=true and set the shim to false-deny
2432+
// then grant on request — exactly what happens on a clean Windows relaunch.
2433+
await page.evaluate(() => {
2434+
const pubkey = (window as Window & { __BUZZ_E2E_PUBKEY__?: string })
2435+
.__BUZZ_E2E_PUBKEY__;
2436+
if (pubkey) {
2437+
window.localStorage.setItem(
2438+
`buzz-notification-settings.v2:${pubkey}`,
2439+
JSON.stringify({ desktopEnabled: true, homeBadgeEnabled: true, notifyWhileViewing: false, sounds: {}, slotAlertsEnabled: {}, slotAlertsSnapshot: null }),
2440+
);
2441+
}
2442+
(
2443+
window as Window & {
2444+
__BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?: (
2445+
permission: NotificationPermission,
2446+
requestResult?: NotificationPermission,
2447+
) => void;
2448+
}
2449+
).__BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?.("denied", "granted");
2450+
});
2451+
2452+
await openSettings(page, "notifications");
2453+
const desktopToggle = page.getByTestId("notifications-desktop-toggle");
2454+
const desktopState = page.getByTestId("notifications-desktop-state");
2455+
2456+
// The boot-time recovery should have fired during mount, so the toggle
2457+
// stays on without the user touching it.
2458+
await expect(desktopState).toContainText("On");
2459+
await expect(desktopToggle).toBeChecked();
2460+
});
2461+
23662462
test("desktop notification clicks open the matching forum thread", async ({
23672463
page,
23682464
}) => {

desktop/tests/helpers/bridge.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,11 +891,18 @@ export async function installBridge(page: Page, options: BridgeOptions) {
891891
title: string;
892892
}> = [];
893893
const notificationInstances: MockNotification[] = [];
894+
let notificationPermissionRequestCount = 0;
895+
let notificationPermissionRequestResult: NotificationPermission | null =
896+
null;
894897

895898
class MockNotification extends EventTarget {
896899
static permission: NotificationPermission = "granted";
897900

898901
static async requestPermission(): Promise<NotificationPermission> {
902+
notificationPermissionRequestCount += 1;
903+
if (notificationPermissionRequestResult) {
904+
MockNotification.permission = notificationPermissionRequestResult;
905+
}
899906
return MockNotification.permission;
900907
}
901908

@@ -928,10 +935,15 @@ export async function installBridge(page: Page, options: BridgeOptions) {
928935
__BUZZ_E2E_APP_BADGE_COUNT__?: number;
929936
__BUZZ_E2E_APP_BADGE_STATE__?: string;
930937
__BUZZ_E2E_CLICK_NOTIFICATION__?: (index: number) => boolean;
938+
__BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__?: () => number;
931939
__BUZZ_E2E_NOTIFICATIONS__?: Array<{
932940
body: string | null;
933941
title: string;
934942
}>;
943+
__BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?: (
944+
permission: NotificationPermission,
945+
requestResult?: NotificationPermission,
946+
) => void;
935947
};
936948
const currentConfig = testWindow.__BUZZ_E2E__ ?? {};
937949

@@ -958,7 +970,17 @@ export async function installBridge(page: Page, options: BridgeOptions) {
958970
notification.onclick?.(event);
959971
return true;
960972
};
973+
testWindow.__BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__ = () =>
974+
notificationPermissionRequestCount;
961975
testWindow.__BUZZ_E2E_NOTIFICATIONS__ = notificationLog;
976+
testWindow.__BUZZ_E2E_SET_NOTIFICATION_PERMISSION__ = (
977+
permission,
978+
requestResult,
979+
) => {
980+
MockNotification.permission = permission;
981+
notificationPermissionRequestCount = 0;
982+
notificationPermissionRequestResult = requestResult ?? null;
983+
};
962984
},
963985
{
964986
identity,

0 commit comments

Comments
 (0)