Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion desktop/src/features/notifications/lib/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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");
}
Expand All @@ -147,6 +166,18 @@ export async function getDesktopNotificationPermissionState(): Promise<DesktopNo
}

if (window.Notification.permission !== "default") {
// block/buzz#2445 — tauri-plugin-notification replaces
// `window.Notification` with a shim whose init script, on Windows only,
// compares its own freshly initialised "default" against "granted" instead
// of asking the backend, and so stamps "denied" on every launch. The
// desktop backend grants unconditionally, so a "denied" read from the shim
// carries no information on Windows. Report "default" instead: the
// 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" back into its cached state.
if (isWindowsShimDeniedPlaceholder()) {
return "default";
Comment on lines +178 to +179
}
return window.Notification.permission;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import test from "node:test";

// Mirrors the `window.Notification` shim that tauri-plugin-notification's
// init script installs. On Windows the shim's own `isPermissionGranted()`
// compares its freshly initialised "default" against "granted" instead of
// asking the backend, so it stamps "denied" on every launch
// (block/buzz#2445). `requestPermission()` is the only path that writes the
// backend's real answer ("granted" on desktop) back into the shim.
function installShim({ permission }) {
let current = permission;
const Shim = function ShimNotification() {};
Shim.requestPermission = async () => {
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;
}
});
10 changes: 10 additions & 0 deletions desktop/src/shared/lib/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down