From cbc66de4f9d9a74255d7cfc783bd732f26d16217 Mon Sep 17 00:00:00 2001 From: Fatima Nur Date: Mon, 24 Aug 2026 18:50:28 +0500 Subject: [PATCH 1/2] fix(desktop): enable native Windows toast notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The show_native_notification command only handled Linux and macOS. On Windows it returned an error, forcing the frontend to fall through to window.Notification (WebKit API). WebView2's Notification.permission reports 'denied' even when the WinRT toast API is available, so the app never appeared in Windows Settings > System > Notifications and the settings toggle was stuck showing 'Desktop notifications are blocked.' - Add tauri-winrt-notification as a Windows-specific dependency - Add a windows module in notifications.rs that posts WinRT toasts using the app's Tauri identifier as AppUserModelID (this is what registers the app with Windows notification settings) - Handle click actions through WinRT Activated handler, forwarding to the same native-notification-activated event that Linux uses - Add isWindowsPlatform() helper to platform.ts - Skip WebView2 Notification.permission check on Windows in getDesktopNotificationPermissionState() — use the Tauri plugin's isPermissionGranted() which queries native WinRT status - Route Windows through the native show_native_notification path in sendDesktopNotification() - Skip the Tauri plugin's onAction listener on Windows (click actions come through the native WinRT event instead) Fixes #6377 Signed-off-by: Fatima Nur --- desktop/src-tauri/Cargo.toml | 3 + .../src-tauri/src/commands/notifications.rs | 58 +++++++++++- .../src/features/notifications/lib/desktop.ts | 24 ++++- desktop/src/shared/lib/platform.test.mjs | 92 +++++++++++++++++++ desktop/src/shared/lib/platform.ts | 9 ++ 5 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 desktop/src/shared/lib/platform.test.mjs diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index db089fc13fa..35fbe7d93b1 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -66,6 +66,9 @@ plist = "1" windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true } user-idle = { version = "0.6", default-features = false } +# Native Windows toast notifications so the app registers with Windows Settings +# > System > Notifications and click actions work through WinRT. +tauri-winrt-notification = "0.7" [dependencies] atomic-write-file = "0.3" diff --git a/desktop/src-tauri/src/commands/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs index 79aa15f969a..1c16ad9d39e 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -39,10 +39,16 @@ pub async fn show_native_notification( crate::macos_notifications::show(title, body, target).await } - #[cfg(not(any(target_os = "linux", target_os = "macos")))] + #[cfg(target_os = "windows")] + { + windows::show(app, title, body, target); + Ok(()) + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { let _ = (&app, &title, &body, &target); - Err("show_native_notification is only supported on Linux and macOS".to_string()) + Err("show_native_notification is not supported on this platform".to_string()) } } @@ -106,3 +112,51 @@ mod linux { }); } } + +// ── Windows ──────────────────────────────────────────────────────────────── +// +// Uses `tauri-winrt-notification` to post Windows toast notifications. This +// registers the app with Windows Settings > System > Notifications (so the +// user can control per-app notification preferences) and surfaces click +// actions through the WinRT `Activated` handler, which we forward to the +// frontend via the same `native-notification-activated` event that Linux uses. + +#[cfg(target_os = "windows")] +mod windows { + use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; + use tauri::Emitter; + use tauri_winrt_notification::{Duration, Toast}; + + pub fn show( + app: tauri::AppHandle, + title: String, + body: Option, + target: Option, + ) { + // The Tauri identifier (e.g. "xyz.block.buzz.app") is the + // AppUserModelID that Windows uses to group notifications and + // surface the app in Settings > Notifications. + let app_id = app.config().identifier.clone(); + + std::thread::spawn(move || { + let app_clone = app.clone(); + let result = Toast::new(&app_id) + .title(&title) + .text1(body.as_deref().unwrap_or("")) + .sound(None) + .duration(Duration::Short) + .on_activated(move |_action| { + // _action is None for the default (body) click and + // Some(arg) for button clicks. We only use the default + // click, matching the Linux behaviour. + let _ = app_clone.emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, &target); + Ok(()) + }) + .show(); + + if let Err(error) = result { + eprintln!("buzz-desktop: failed to post Windows notification: {error}"); + } + }); + } +} diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 45716de2d94..5e6e466a74e 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -6,7 +6,7 @@ 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. @@ -146,6 +146,17 @@ export async function getDesktopNotificationPermissionState(): Promise { pendingPermissionRequest = null; }); @@ -214,7 +228,7 @@ export async function listenForDesktopNotificationActions( if (isTauri()) { const usesMacActivationQueue = isMacPlatform(); - if (!isLinuxPlatform() && !usesMacActivationQueue) { + if (!isLinuxPlatform() && !isWindowsPlatform() && !usesMacActivationQueue) { try { pluginListener = await onAction((notification) => { const target = parseNotificationTarget( @@ -362,8 +376,10 @@ export async function sendDesktopNotification( // Linux needs a retained D-Bus connection. macOS needs a native notification // center delegate because the Tauri plugin does not deliver desktop clicks. + // Windows needs WinRT toast notifications so the app registers with + // Settings > System > Notifications and click actions work. // See src-tauri/src/commands/notifications.rs. - if (isTauri() && (isLinuxPlatform() || isMacPlatform())) { + if (isTauri() && (isLinuxPlatform() || isMacPlatform() || isWindowsPlatform())) { try { await invoke("show_native_notification", { title: payload.title, diff --git a/desktop/src/shared/lib/platform.test.mjs b/desktop/src/shared/lib/platform.test.mjs new file mode 100644 index 00000000000..d61bdd00913 --- /dev/null +++ b/desktop/src/shared/lib/platform.test.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Save the original navigator so we can restore it after each test. +const originalNavigator = globalThis.navigator; + +function withNavigator(platform, userAgent) { + Object.defineProperty(globalThis, "navigator", { + value: { platform, userAgent }, + configurable: true, + }); +} + +function restoreNavigator() { + Object.defineProperty(globalThis, "navigator", { + value: originalNavigator, + configurable: true, + }); +} + +const { isMacPlatform, isLinuxPlatform, isWindowsPlatform } = await import( + "./platform.ts" +); + +// ── isWindowsPlatform ────────────────────────────────────────────────────── + +test("isWindowsPlatform returns true for Win32", () => { + withNavigator("Win32", "Mozilla/5.0"); + assert.equal(isWindowsPlatform(), true); + restoreNavigator(); +}); + +test("isWindowsPlatform returns true for Win64", () => { + withNavigator("Win64", "Mozilla/5.0"); + assert.equal(isWindowsPlatform(), true); + restoreNavigator(); +}); + +test("isWindowsPlatform returns false for macOS", () => { + withNavigator("MacIntel", "Mozilla/5.0"); + assert.equal(isWindowsPlatform(), false); + restoreNavigator(); +}); + +test("isWindowsPlatform returns false for Linux", () => { + withNavigator("Linux x86_64", "Mozilla/5.0"); + assert.equal(isWindowsPlatform(), false); + restoreNavigator(); +}); + +test("isWindowsPlatform returns false when navigator is undefined", () => { + Object.defineProperty(globalThis, "navigator", { + value: undefined, + configurable: true, + }); + assert.equal(isWindowsPlatform(), false); + restoreNavigator(); +}); + +// ── isMacPlatform ────────────────────────────────────────────────────────── + +test("isMacPlatform returns true for MacIntel", () => { + withNavigator("MacIntel", "Mozilla/5.0"); + assert.equal(isMacPlatform(), true); + restoreNavigator(); +}); + +test("isMacPlatform returns false for Win32", () => { + withNavigator("Win32", "Mozilla/5.0"); + assert.equal(isMacPlatform(), false); + restoreNavigator(); +}); + +// ── isLinuxPlatform ──────────────────────────────────────────────────────── + +test("isLinuxPlatform returns true for Linux", () => { + withNavigator("Linux x86_64", "Mozilla/5.0"); + assert.equal(isLinuxPlatform(), true); + restoreNavigator(); +}); + +test("isLinuxPlatform returns false for Android", () => { + withNavigator("Linux armv81", "Mozilla/5.0 (Linux; Android 14)"); + assert.equal(isLinuxPlatform(), false); + restoreNavigator(); +}); + +test("isLinuxPlatform returns false for Win32", () => { + withNavigator("Win32", "Mozilla/5.0"); + assert.equal(isLinuxPlatform(), false); + restoreNavigator(); +}); diff --git a/desktop/src/shared/lib/platform.ts b/desktop/src/shared/lib/platform.ts index 42e7f5b944f..e4b3a9cc377 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) From b8bd0da8480bc3bb006e8251dbc45b994c10dab6 Mon Sep 17 00:00:00 2001 From: Fatima Nur Date: Wed, 26 Aug 2026 00:45:15 +0500 Subject: [PATCH 2/2] docs: clarify Windows uses WinRT exclusively, not Tauri plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add explicit comment in sendDesktopNotification() stating that the Tauri notification plugin's sendNotification() must not be used on Windows — the native WinRT path handles delivery and click actions exclusively. This clarifies the design intent for reviewers and prevents future contributors from adding a duplicate Tauri plugin path on Windows. Signed-off-by: Fatima Nur --- desktop/src/features/notifications/lib/desktop.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 5e6e466a74e..ea783b32726 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -378,6 +378,8 @@ export async function sendDesktopNotification( // center delegate because the Tauri plugin does not deliver desktop clicks. // Windows needs WinRT toast notifications so the app registers with // Settings > System > Notifications and click actions work. + // Do NOT use the Tauri notification plugin's sendNotification() on Windows — + // the native WinRT path handles delivery and click actions exclusively. // See src-tauri/src/commands/notifications.rs. if (isTauri() && (isLinuxPlatform() || isMacPlatform() || isWindowsPlatform())) { try {