From 8478e7d673938e5ec0244f28fb54a77219f1a178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tup=C3=BD?= Date: Thu, 3 Sep 2026 11:23:56 +0000 Subject: [PATCH] fix(desktop): stop treating the Windows notification shim's startup "denied" as final MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, tauri-plugin-notification's init script replaces `window.Notification` with a shim and then decides the initial permission by comparing its own freshly initialised "default" against "granted" instead of asking the backend. The comparison is always false, so every launch stamps `permission = "denied"` without any OS decision behind it. Buzz trusted that value: the settings card showed "Desktop notifications are blocked. Enable them in your system settings.", `requestPermission()` was never called (the hooks only request from "default"), no toast was ever sent, and Windows never listed the app under Settings > Notifications. Reinstalling cannot help because the value is recomputed on each start. The desktop backend grants unconditionally on every platform, so a shim "denied" carries no information on Windows. Report it as "default" there: the existing auto-request in `use-feed-desktop-notifications` (and the settings toggle) then call `requestPermission()`, which the shim answers by writing the backend's real "granted" into its cached state. Linux/macOS and non-Tauri browsers are untouched — a real "denied" stays authoritative. Adds `isWindowsPlatform()` (anchored so "Darwin" never matches) and a regression test against the production seam; the Windows case fails without the guard. Fixes #2445 Signed-off-by: Jakub Tupý --- .../src/features/notifications/lib/desktop.ts | 33 +++++++- .../lib/desktopWindowsPermission.test.mjs | 77 +++++++++++++++++++ desktop/src/shared/lib/platform.ts | 10 +++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/notifications/lib/desktopWindowsPermission.test.mjs diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 0844c24de51..859b1866672 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -6,7 +6,11 @@ import { onAction, requestPermission, } from "@tauri-apps/plugin-notification"; -import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform"; +import { + isLinuxPlatform, + isMacPlatform, + isWindowsPlatform, +} from "@/shared/lib/platform"; // Backend event emitted when a native Linux notification is clicked or a // queued macOS activation becomes available. See src-tauri notification code. @@ -123,6 +127,21 @@ function dispatchDesktopNotificationTarget(target: DesktopNotificationTarget) { ); } +/** + * True when the "denied" currently reported by `window.Notification` is the + * placeholder tauri-plugin-notification's Windows init script writes without + * consulting the backend (block/buzz#2445), rather than a real OS decision. + * Windows has no per-app notification prompt for the desktop backend to lose, + * so on Windows under Tauri a shim "denied" is never authoritative. + */ +function isWindowsShimDeniedPlaceholder(): boolean { + return ( + isTauri() && + isWindowsPlatform() && + window.Notification.permission === "denied" + ); +} + function shouldUseMacDevelopmentFallback(error: unknown): boolean { return String(error).includes("not running from an app bundle"); } @@ -147,6 +166,18 @@ export async function getDesktopNotificationPermissionState(): Promise { + current = "granted"; + return "granted"; + }; + Object.defineProperty(Shim, "permission", { + enumerable: true, + get: () => current, + }); + return Shim; +} + +function setPlatform(platform) { + // Node ships a getter-only global `navigator`; redefine it per test. + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { platform, userAgent: `Mozilla/5.0 (${platform})` }, + }); +} + +// `isTauri()` from @tauri-apps/api/core reads `globalThis.isTauri`. +globalThis.isTauri = true; +globalThis.window = { Notification: installShim({ permission: "denied" }) }; +setPlatform("Win32"); + +const { + getDesktopNotificationPermissionState, + requestDesktopNotificationAccess, +} = await import("./desktop.ts"); + +test("Windows: the shim's startup 'denied' is reported as 'default' so permission can still be requested", async () => { + setPlatform("Win32"); + window.Notification = installShim({ permission: "denied" }); + + assert.equal(await getDesktopNotificationPermissionState(), "default"); + + // Requesting flips the shim to the backend's real answer, and from then on + // the state is authoritative. + assert.equal(await requestDesktopNotificationAccess(), "granted"); + assert.equal(await getDesktopNotificationPermissionState(), "granted"); +}); + +test("Windows: a real 'granted' passes through unchanged", async () => { + setPlatform("Win32"); + window.Notification = installShim({ permission: "granted" }); + + assert.equal(await getDesktopNotificationPermissionState(), "granted"); +}); + +test("Linux: 'denied' stays authoritative — the placeholder rewrite is Windows-only", async () => { + setPlatform("Linux x86_64"); + window.Notification = installShim({ permission: "denied" }); + + assert.equal(await getDesktopNotificationPermissionState(), "denied"); +}); + +test("browser (non-Tauri) on Windows: 'denied' is a real user decision and stays 'denied'", async () => { + setPlatform("Win32"); + globalThis.isTauri = false; + try { + window.Notification = installShim({ permission: "denied" }); + assert.equal(await getDesktopNotificationPermissionState(), "denied"); + } finally { + globalThis.isTauri = true; + } +}); diff --git a/desktop/src/shared/lib/platform.ts b/desktop/src/shared/lib/platform.ts index 42e7f5b944f..d5125d41760 100644 --- a/desktop/src/shared/lib/platform.ts +++ b/desktop/src/shared/lib/platform.ts @@ -12,6 +12,16 @@ export function isMacPlatform(): boolean { return /mac|iphone|ipad|ipod/i.test(navigator.platform); } +/** Returns true on Windows. */ +export function isWindowsPlatform(): boolean { + if (typeof navigator === "undefined") { + return false; + } + + // Anchored so "Darwin" (which contains "win") never matches. + return /^win/i.test(navigator.platform); +} + /** Returns true on Linux desktops (excludes Android). */ export function isLinuxPlatform(): boolean { if (typeof navigator === "undefined") {