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..ea783b32726 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,12 @@ 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. + // 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())) { + 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)