From 3ee42cef5782f4f5b8fcadff1fb0f6713139153e Mon Sep 17 00:00:00 2001 From: ranjankai Date: Sun, 30 Aug 2026 16:29:40 +0530 Subject: [PATCH 1/2] feat(windows): implement native Windows toast notifications & opt-out alerting - Add native Windows WinRT Toast Notifications using tauri-winrt-notification and windows COM bindings. - Establish process-wide MTA and Start Menu shortcut AUMID registration for reliable Action Center delivery. - Direct desktop notification permission checks to native backend for Windows. - Decouple visual desktop toasts from sound-slot gating so DMs and channel messages toast reliably when desktop alerts are enabled. Signed-off-by: ranjankai --- desktop/src-tauri/Cargo.lock | 2 + desktop/src-tauri/Cargo.toml | 11 +- .../src-tauri/src/commands/notifications.rs | 598 +++++++++++++++++- .../app/useAppShellDesktopNotifications.ts | 83 ++- .../src/features/notifications/lib/desktop.ts | 36 +- .../notifications/lib/notificationFormat.ts | 10 +- desktop/src/shared/lib/platform.ts | 9 + 7 files changed, 724 insertions(+), 25 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 68b702431af..4aa6cb6fa29 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1158,6 +1158,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-window-state", "tauri-utils", + "tauri-winrt-notification", "tempfile", "tokio", "tokio-tungstenite 0.29.0", @@ -1169,6 +1170,7 @@ dependencies = [ "uuid", "webkit2gtk", "window-vibrancy", + "windows 0.61.3", "windows-sys 0.61.2", "zeroize", "zip 8.6.0", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index f41fa2d6e39..9af20a49b52 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -63,9 +63,18 @@ user-idle = { version = "0.6", default-features = false } plist = "1" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } +windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation", "Win32_UI_Shell"] } keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true } user-idle = { version = "0.6", default-features = false } +tauri-winrt-notification = "0.7.3" +windows = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Storage_EnhancedStorage", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", + "Win32_UI_Shell", + "Win32_UI_Shell_PropertiesSystem", +] } [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..4eb0ff77629 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -13,6 +13,7 @@ //! action, which we forward to the frontend so it can focus the window and //! route to the notification target. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub(crate) const NATIVE_NOTIFICATION_ACTIVATED_EVENT: &str = "native-notification-activated"; /// Show a desktop notification natively. @@ -39,10 +40,603 @@ 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()) + } +} + +/// Claim this app's notification identity at startup, where the platform has +/// one to claim. +/// +/// Exposed as a single cross-platform entry point so `lib.rs` can call it +/// unconditionally instead of carrying a `#[cfg]` block at the call site — +/// the same shape `mouse_nav.rs` uses for its macOS-only wiring. On Windows +/// this claims the AUMID and repairs the Start Menu shortcut so the OS +/// recognises Buzz as notification-capable; everywhere else it does nothing. +#[cfg(target_os = "windows")] +pub(crate) fn ensure_startup_registration(app: &tauri::AppHandle) { + windows::ensure_startup_registration(app); +} + +#[cfg(not(target_os = "windows"))] +pub(crate) fn ensure_startup_registration(_app: &tauri::AppHandle) {} + +#[cfg(target_os = "windows")] +pub(crate) mod windows { + use std::sync::mpsc; + use std::sync::Once; + + static AUMID_REGISTERED: Once = Once::new(); + static STARTUP_REGISTRATION_DONE: Once = Once::new(); + static PROCESS_MTA_READY: Once = Once::new(); + + /// Establishes a process-wide multi-threaded COM apartment (MTA) that lives + /// for the entire lifetime of the app, and blocks until it is ready. + /// + /// This is the load-bearing piece for Windows toast delivery. + /// `tauri-winrt-notification`'s `Toast::show()` calls into WinRT + /// (`XmlDocument::new`, `ToastNotificationManager::CreateToastNotifierWithId`, + /// `ToastNotifier::Show`) and does no COM apartment init of its own — + /// confirmed by reading the crate source. The upstream Tauri notification + /// plugin gets away with that only because it posts from the tokio runtime, + /// where some other thread has already created an MTA and the worker joins + /// it via COM's "implicit MTA" rule (Raymond Chen, + /// ). Buzz + /// posts toasts from a bare `std::thread`, so unless the process already + /// has a live MTA, every one of those WinRT calls fails with + /// `CO_E_NOTINITIALIZED`. `Toast::show()` maps that to `Err`, which the + /// caller only `eprintln!`s — and release builds set + /// `windows_subsystem = "windows"`, so there is no console and the error + /// vanishes. Net effect: no toast ever appears, silently, on every launch, + /// on every machine. That is exactly the bug this fixes. + /// + /// We make the dependency explicit and permanent: one dedicated thread + /// initializes an MTA and then parks forever. It must never + /// `CoUninitialize` or exit — holding the apartment open is the entire + /// point. This does two things a per-notification `CoInitializeEx`/ + /// `CoUninitialize` pair (the tempting shorter fix) cannot: + /// 1. Every notification thread joins the implicit MTA with zero per-call + /// init, so there is no `CoUninitialize`-then-exit race against the + /// Windows Notification Platform's async delivery + /// (microsoft/WindowsAppSDK#3437). + /// 2. The `on_activated` click handler stays deliverable. In an MTA, + /// WinRT delivers `Activated`/`Dismissed` callbacks on COM worker + /// threads rather than a per-thread message pump, so the short-lived + /// thread that showed the toast can exit while the click handler still + /// fires later — as long as the apartment itself is still alive, which + /// this permanent thread guarantees. A per-call `CoUninitialize` + + /// thread exit would tear the sink down and silently drop every click. + /// + /// MTA (not STA/`COINIT_APARTMENTTHREADED`) is correct here: STA would + /// require this thread to run a window message pump to deliver callbacks, + /// which a parked thread does not. Microsoft's own WinRT guidance is to use + /// MTA for non-UI threads and reserve STA for UI threads + /// (). + fn ensure_process_mta() { + PROCESS_MTA_READY.call_once(|| { + let (ready_tx, ready_rx) = mpsc::channel::<()>(); + std::thread::spawn(move || { + use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED}; + // SAFETY: this thread performs no other COM work and never + // uninitializes; the apartment is intentionally leaked for the + // process lifetime. On a fresh thread this returns S_OK (or + // S_FALSE if the process MTA already exists) — both are + // non-negative, so `is_err()` is false. It cannot return + // RPC_E_CHANGED_MODE because this thread has never been + // initialized with a different model. + let hresult = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; + if hresult.is_err() { + eprintln!( + "buzz-desktop: failed to establish process MTA for notifications: {hresult:?}" + ); + } + // Signal readiness regardless: even on the (unexpected) error + // path, blocking callers must not hang. + let _ = ready_tx.send(()); + // Park forever. Returning from this closure would end the + // thread and tear the apartment down — never do that. + loop { + std::thread::park(); + } + }); + // Block until the apartment is confirmed up, so the very first + // toast can never race ahead of it. + let _ = ready_rx.recv(); + }); + } + + /// Runs Buzz's full Windows toast-notification eligibility setup once, + /// early in app startup — call this from `lib.rs`'s `setup()`, not + /// lazily on first notification. See `ensure_start_menu_shortcut` for + /// why this needs to run regardless of whether the user has actually + /// triggered a notification yet: Windows' Settings > Notifications page + /// only lists an AUMID once it has a shell-visible identity (a Start + /// Menu shortcut carrying that AUMID), and users reasonably expect to + /// find Buzz there before the first notification would ever fire. + pub(crate) fn ensure_startup_registration(app: &tauri::AppHandle) { + STARTUP_REGISTRATION_DONE.call_once(|| { + let app = app.clone(); + // Shell/COM calls can be slow (first-run disk I/O for the + // Start Menu folder) — do this off the main setup thread so + // it never delays window creation. + std::thread::spawn(move || { + // Bring the process MTA up at launch so the first toast never + // has to wait on it. Idempotent and cheap after the first call. + ensure_process_mta(); + let app_id = app.config().identifier.clone(); + set_process_aumid(&app_id); + if let Err(error) = write_aumid_registry_entry(&app, &app_id) { + eprintln!( + "buzz-desktop: failed to register AUMID for Windows notifications: {error}" + ); + } + if let Err(error) = write_notification_settings_entry(&app_id) { + eprintln!( + "buzz-desktop: failed to pre-register Windows notification settings entry: {error}" + ); + } + if let Err(error) = ensure_start_menu_shortcut(&app, &app_id) { + eprintln!("buzz-desktop: failed to repair Start Menu shortcut AUMID: {error}"); + } + }); + }); + } + + pub fn show( + app: tauri::AppHandle, + title: String, + body: Option, + target: Option, + ) { + use tauri::Emitter; + + let app_id = app.config().identifier.clone(); + ensure_aumid_registered(&app, &app_id); + // `Toast::show()` below calls into WinRT, which requires the calling + // thread to be in a COM apartment. See `ensure_process_mta` — this + // guarantees a process-wide MTA is live before we spawn, so the bare + // thread below joins it implicitly and the WinRT calls succeed. + ensure_process_mta(); + + std::thread::spawn(move || { + let mut toast = tauri_winrt_notification::Toast::new(&app_id).text1(&title); + if let Some(body_text) = body.as_deref() { + toast = toast.text2(body_text); + } + + // Without this, clicking the toast does nothing — Linux and + // macOS both focus the window and route to `target` on click + // (see `linux::show`/`macos_notifications.rs`); Windows never + // did. `on_activated` fires with `None` for a plain click on + // the toast body (no button involved), which is the only case + // we handle today — the button-click `Some(action)` path is + // free for future per-notification actions (e.g. "Mark read"). + let activation_app = app.clone(); + toast = toast.on_activated(move |action| { + if action.is_none() { + let _ = activation_app.emit( + crate::commands::NATIVE_NOTIFICATION_ACTIVATED_EVENT, + target.clone(), + ); + } + Ok(()) + }); + + if let Err(error) = toast.show() { + eprintln!("buzz-desktop: failed to post Windows native notification: {error}"); + } + }); + } + + /// Registers Buzz's AUMID so Windows will accept native toast + /// notifications and list Buzz under Settings > Notifications. + /// + /// Buzz ships an unpackaged (non-MSIX) Win32 binary with no Start Menu + /// shortcut carrying a `System.AppUserModel.ID` property, so the shell + /// has no other way to learn Buzz's AUMID or icon. Without both of the + /// steps below, `ToastNotifier` silently drops every toast: + /// 1. The current process must explicitly claim the AUMID it will pass + /// to `Toast::new` (`SetCurrentProcessExplicitAppUserModelID`). + /// 2. That same AUMID must be registered under + /// `HKCU\Software\Classes\AppUserModelId\` with a display + /// name and icon, or Windows has nothing to show in the + /// notification settings list and rejects the toast. + /// + /// Runs at most once per process. + fn ensure_aumid_registered(app: &tauri::AppHandle, app_id: &str) { + AUMID_REGISTERED.call_once(|| { + set_process_aumid(app_id); + if let Err(error) = write_aumid_registry_entry(app, app_id) { + eprintln!( + "buzz-desktop: failed to register AUMID for Windows notifications: {error}" + ); + } + if let Err(error) = write_notification_settings_entry(app_id) { + eprintln!( + "buzz-desktop: failed to pre-register Windows notification settings entry: {error}" + ); + } + }); + } + + fn to_wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() + } + + fn set_process_aumid(app_id: &str) { + use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; + + let wide_app_id = to_wide(app_id); + // SAFETY: `wide_app_id` is a valid, NUL-terminated UTF-16 buffer that + // outlives this call. + let hresult = unsafe { SetCurrentProcessExplicitAppUserModelID(wide_app_id.as_ptr()) }; + if hresult < 0 { + eprintln!( + "buzz-desktop: SetCurrentProcessExplicitAppUserModelID failed: 0x{hresult:08X}" + ); + } + } + + /// Pre-seeds the per-app notification Settings key so Buzz appears in + /// Settings > System > Notifications immediately on a clean install, + /// instead of only after the first toast is delivered. + /// + /// Create-if-absent by design. Windows owns the `Enabled` value under this + /// key: once the key exists, its value reflects either Windows' own state + /// or a choice the user made in Settings > Notifications. Rewriting + /// `Enabled=1` on every launch (as the first version of this did) would + /// silently turn Buzz notifications back on every time the app starts, + /// overriding a user who had deliberately turned them off. So we only write + /// the seed values when we are the ones creating the key for the first + /// time; if it already exists we leave it entirely untouched. + fn write_notification_settings_entry(app_id: &str) -> Result<(), String> { + use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegCreateKeyExW, RegOpenKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, + KEY_READ, KEY_WRITE, REG_DWORD, REG_OPTION_NON_VOLATILE, + }; + + let subkey = to_wide(&format!( + "Software\\Microsoft\\Windows\\CurrentVersion\\Notifications\\Settings\\{app_id}" + )); + + // SAFETY: `subkey` is a NUL-terminated UTF-16 buffer that outlives + // every call below; each key handle we obtain is closed exactly once + // on every path. + unsafe { + // If the key already exists, respect whatever state is there — + // never re-seed. See the doc comment above. + let mut existing: HKEY = std::ptr::null_mut(); + let open_status = + RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut existing); + if open_status == 0 { + RegCloseKey(existing); + return Ok(()); + } + + let mut hkey: HKEY = std::ptr::null_mut(); + let create_status = RegCreateKeyExW( + HKEY_CURRENT_USER, + subkey.as_ptr(), + 0, + std::ptr::null(), + REG_OPTION_NON_VOLATILE, + KEY_WRITE, + std::ptr::null(), + &mut hkey, + std::ptr::null_mut(), + ); + if create_status != 0 { + return Err(format!( + "RegCreateKeyExW failed with status {create_status}" + )); + } + + let show_in_action_center_value = to_wide("ShowInActionCenter"); + let show_in_action_center_data: u32 = 1; + let enabled_value = to_wide("Enabled"); + let enabled_data: u32 = 1; + + let show_status = RegSetValueExW( + hkey, + show_in_action_center_value.as_ptr(), + 0, + REG_DWORD, + (&show_in_action_center_data as *const u32).cast::(), + std::mem::size_of::() as u32, + ); + let enabled_status = RegSetValueExW( + hkey, + enabled_value.as_ptr(), + 0, + REG_DWORD, + (&enabled_data as *const u32).cast::(), + std::mem::size_of::() as u32, + ); + + RegCloseKey(hkey); + + if enabled_status != 0 { + return Err(format!( + "RegSetValueExW(Enabled) failed with status {enabled_status}" + )); + } + if show_status != 0 { + return Err(format!( + "RegSetValueExW(ShowInActionCenter) failed with status {show_status}" + )); + } + } + + Ok(()) + } + + fn write_aumid_registry_entry(app: &tauri::AppHandle, app_id: &str) -> Result<(), String> { + use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegCreateKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, KEY_WRITE, + REG_OPTION_NON_VOLATILE, REG_SZ, + }; + + let display_name = app + .config() + .product_name + .clone() + .unwrap_or_else(|| "Buzz".to_string()); + // The exe's own icon resource doubles as the AUMID icon; Windows + // accepts a path to an executable here just as it does a .ico file. + let icon_path = std::env::current_exe() + .map_err(|error| format!("could not resolve current exe path: {error}"))? + .to_string_lossy() + .into_owned(); + + let subkey = to_wide(&format!("Software\\Classes\\AppUserModelId\\{app_id}")); + let display_name_value = to_wide("DisplayName"); + let display_name_data = to_wide(&display_name); + let icon_uri_value = to_wide("IconUri"); + let icon_uri_data = to_wide(&icon_path); + + // SAFETY: every buffer passed below is a NUL-terminated UTF-16 + // string that outlives the corresponding call, `hkey` is only used + // after a successful `RegCreateKeyExW`, and it is closed exactly + // once before returning. + unsafe { + let mut hkey: HKEY = std::ptr::null_mut(); + let create_status = RegCreateKeyExW( + HKEY_CURRENT_USER, + subkey.as_ptr(), + 0, + std::ptr::null(), + REG_OPTION_NON_VOLATILE, + KEY_WRITE, + std::ptr::null(), + &mut hkey, + std::ptr::null_mut(), + ); + if create_status != 0 { + return Err(format!( + "RegCreateKeyExW failed with status {create_status}" + )); + } + + let display_name_bytes = std::slice::from_raw_parts( + display_name_data.as_ptr().cast::(), + display_name_data.len() * 2, + ); + let display_status = RegSetValueExW( + hkey, + display_name_value.as_ptr(), + 0, + REG_SZ, + display_name_bytes.as_ptr(), + display_name_bytes.len() as u32, + ); + + let icon_uri_bytes = std::slice::from_raw_parts( + icon_uri_data.as_ptr().cast::(), + icon_uri_data.len() * 2, + ); + let icon_status = RegSetValueExW( + hkey, + icon_uri_value.as_ptr(), + 0, + REG_SZ, + icon_uri_bytes.as_ptr(), + icon_uri_bytes.len() as u32, + ); + + RegCloseKey(hkey); + + if display_status != 0 { + return Err(format!( + "RegSetValueExW(DisplayName) failed with status {display_status}" + )); + } + if icon_status != 0 { + return Err(format!( + "RegSetValueExW(IconUri) failed with status {icon_status}" + )); + } + } + + Ok(()) + } + + /// Creates or repairs the per-user Start Menu shortcut so it carries a + /// `System.AppUserModel.ID` property matching `app_id`. + /// + /// This is the piece the registry/process-AUMID calls above cannot + /// substitute for. For an unpackaged (non-MSIX) Win32 app, Windows only + /// treats an AUMID as a real, notification-capable app identity once + /// the shell can resolve it back to a Start Menu shortcut carrying that + /// same AUMID as a shell-link property — that's what makes the app show + /// up under Settings > Notifications and what makes `ToastNotifier` + /// accept toasts at all, not just the registry DisplayName/IconUri + /// metadata written above. + /// + /// The installer's NSIS template already sets this property on the + /// shortcut it creates — but only for a *fresh* install. Tauri's + /// generated NSIS `CreateOrUpdateStartMenuShortcut` macro skips + /// recreating the shortcut on an in-place update (which is what every + /// silent auto-update via the Tauri updater is), so any install whose + /// shortcut predates this AUMID work — or was ever silently updated + /// before this fix — never gets it patched by the installer. Repairing + /// it here, unconditionally, on every app launch, makes this self- + /// healing regardless of install history or update path. + fn ensure_start_menu_shortcut(app: &tauri::AppHandle, app_id: &str) -> Result<(), String> { + // NOTE: `IPersistFile` and `STGM_READWRITE` live under + // `Win32::System::Com` (general COM persistence/storage types), not + // `Win32::UI::Shell`, even though we only use `IPersistFile` here to + // persist a shell link — double-check this against the actual crate + // docs for the pinned `windows` version if this module fails to + // resolve. + use windows::core::{Interface, PCWSTR}; + // `PKEY_AppUserModel_ID` lives here, not under + // `Win32::UI::Shell::PropertiesSystem` — that module only carries + // `PKEY_PIDSTR_MAX`. Verified against the crate's own generated docs; + // the wrong path fails to compile rather than silently misbehaving. + use windows::Win32::Storage::EnhancedStorage::PKEY_AppUserModel_ID; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, IPersistFile, CLSCTX_INPROC_SERVER, + COINIT_APARTMENTTHREADED, STGM_READWRITE, + }; + // No `InitPropVariantFromStringW` here — it doesn't exist in this + // crate (or in any windows-rs version): the Win32 API of that name + // is a header-only inline wrapper in propvarutil.h, not a real + // exported symbol, so win32metadata never carries it (see + // microsoft/windows-rs#976, still open). `PROPVARIANT: From<&str>` + // is the crate's own replacement, and `PROPVARIANT: Drop` already + // clears it — do not additionally call `PropVariantClear` on one of + // these or it double-frees the string it owns. + use windows::Win32::System::Com::StructuredStorage::PROPVARIANT; + use windows::Win32::UI::Shell::{ + FOLDERID_Programs, IShellLinkW, PropertiesSystem::IPropertyStore, SHGetKnownFolderPath, + ShellLink, KF_FLAG_CREATE, + }; + + let product_name = app + .config() + .product_name + .clone() + .unwrap_or_else(|| "Buzz".to_string()); + let exe_path = std::env::current_exe() + .map_err(|error| format!("could not resolve current exe path: {error}"))?; + + // SAFETY: this thread does not otherwise touch COM; the apartment + // is torn down before returning from this function on every path. + // `CoInitializeEx` returns `S_OK` on first init and `S_FALSE` if this + // thread already has an apartment (both non-negative, so `is_err()` + // is false for either) — only a genuinely negative HRESULT here + // means initialization failed. + let init_result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; + if init_result.is_err() { + return Err(format!("CoInitializeEx failed: {init_result:?}")); + } + let result = (|| -> Result<(), String> { + let programs_dir_pwstr = unsafe { + SHGetKnownFolderPath(&FOLDERID_Programs, KF_FLAG_CREATE, None) + .map_err(|error| format!("SHGetKnownFolderPath failed: {error}"))? + }; + // `SHGetKnownFolderPath` hands back a COM-allocated buffer the + // caller owns and must free — `windows-rs` does not do this + // automatically for a raw returned `PWSTR`. + let programs_dir_result = unsafe { programs_dir_pwstr.to_string() }; + unsafe { + windows::Win32::System::Com::CoTaskMemFree(Some( + programs_dir_pwstr.0 as *const std::ffi::c_void, + )); + } + let programs_dir = + programs_dir_result.map_err(|error| format!("invalid Start Menu path: {error}"))?; + + // NSIS's `MUI_STARTMENU_GETFOLDER` (which Tauri's generated + // installer.nsi uses — see its `$AppStartMenuFolder` var) + // defaults to putting the shortcut in a subfolder named after + // the product, i.e. `$SMPROGRAMS\Buzz\Buzz.lnk`, NOT flat at + // `$SMPROGRAMS\Buzz.lnk`. Confirmed by reading the actual + // generated installer.nsi from a real local build — it only + // falls back to the flat path if the user explicitly cleared + // the Start Menu folder field during install (installer.nsi + // lines ~912-917). Try the subfolder path first, since it's the + // default nearly everyone gets; fall back to flat if that's not + // what's actually on disk; if neither exists yet, create at the + // subfolder path to match what a fresh install would produce. + let subfolder_path = format!("{programs_dir}\\{product_name}\\{product_name}.lnk"); + let flat_path = format!("{programs_dir}\\{product_name}.lnk"); + let shortcut_path = if std::path::Path::new(&subfolder_path).exists() { + subfolder_path + } else if std::path::Path::new(&flat_path).exists() { + flat_path + } else { + // Neither exists — this is a from-scratch repair (no prior + // install ever ran, or its shortcut was deleted). Ensure the + // subfolder exists, then create there, matching the + // installer's own default layout. + std::fs::create_dir_all(format!("{programs_dir}\\{product_name}")) + .map_err(|error| format!("could not create Start Menu folder: {error}"))?; + subfolder_path + }; + let shortcut_path_wide = to_wide(&shortcut_path); + let exe_path_wide = to_wide(&exe_path.to_string_lossy()); + + let shell_link: IShellLinkW = unsafe { + CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) + .map_err(|error| format!("CoCreateInstance(ShellLink) failed: {error}"))? + }; + let persist_file: IPersistFile = shell_link + .cast() + .map_err(|error| format!("IShellLinkW -> IPersistFile cast failed: {error}"))?; + + // If a shortcut already exists (the normal case — the installer + // made one), load it so we preserve whatever else it sets + // (working directory, description, etc.) and only touch the + // AUMID property. If it doesn't exist yet, fall through and + // build a fresh one below. + let existing = + unsafe { persist_file.Load(PCWSTR(shortcut_path_wide.as_ptr()), STGM_READWRITE) }; + if existing.is_err() { + unsafe { + shell_link + .SetPath(PCWSTR(exe_path_wide.as_ptr())) + .map_err(|error| format!("IShellLinkW::SetPath failed: {error}"))?; + shell_link + .SetIconLocation(PCWSTR(exe_path_wide.as_ptr()), 0) + .map_err(|error| format!("IShellLinkW::SetIconLocation failed: {error}"))?; + } + } + + let props: IPropertyStore = shell_link + .cast() + .map_err(|error| format!("IShellLinkW -> IPropertyStore cast failed: {error}"))?; + // `prop_value` owns the wide-string copy `SetValue` reads; it is + // freed automatically (via `PROPVARIANT`'s `Drop`) when it goes + // out of scope below, after `Commit`/`Save` are done with it. + let prop_value = PROPVARIANT::from(app_id); + unsafe { + props + .SetValue(&PKEY_AppUserModel_ID, &prop_value) + .map_err(|error| format!("IPropertyStore::SetValue failed: {error}"))?; + props + .Commit() + .map_err(|error| format!("IPropertyStore::Commit failed: {error}"))?; + persist_file + .Save(PCWSTR(shortcut_path_wide.as_ptr()), true) + .map_err(|error| format!("IPersistFile::Save failed: {error}"))?; + } + + Ok(()) + })(); + + if init_result.is_ok() { + unsafe { CoUninitialize() }; + } + result } } diff --git a/desktop/src/app/useAppShellDesktopNotifications.ts b/desktop/src/app/useAppShellDesktopNotifications.ts index b86b95363cd..2ea69c368cc 100644 --- a/desktop/src/app/useAppShellDesktopNotifications.ts +++ b/desktop/src/app/useAppShellDesktopNotifications.ts @@ -6,6 +6,7 @@ import { shouldBounceForChannelNotification, } from "@/app/AppShell.helpers"; import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts"; +import { isThreadReply } from "@/features/messages/lib/threading"; import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify"; import type { NotificationSettings } from "@/features/notifications/hooks"; import { @@ -59,24 +60,69 @@ export function useAppShellDesktopNotifications({ const resolveSenderName = useNotificationSenderName(); const handleChannelNotification = React.useEffectEvent( - (_channelId: string, event: RelayEvent) => { + (channelId: string, event: RelayEvent) => { if (!enabled) return; - if (!shouldBounceForChannelNotification(event.tags)) return; if (!notificationSettings.desktopEnabled) return; - void requestDockBounce(); + + const bounce = () => { + if (shouldBounceForChannelNotification(event.tags)) { + void requestDockBounce(); + } + }; + + // Thread replies and DMs each have their own desktop-notification path + // (thread-reply and DM). This handler owns every OTHER top-level channel + // message — WhatsApp-style: notify for every message in a channel until it + // is muted. Muted channels never reach here (shouldNotifyForEvent excludes + // them upstream, and only fires this callback for unmuted channels). + // Top-level @-mentions are notified here too (with mention-specific copy) + // rather than via the home-feed path, so a mention reliably toasts. + const normalizedPubkey = pubkey?.trim().toLowerCase() ?? ""; + if (isThreadReply(event.tags)) { + bounce(); + return; + } + const channel = channels.find((c) => c.id === channelId); + if (channel?.channelType === "dm") { + bounce(); + return; + } + + const isMention = hasMentionForEvent(event, normalizedPubkey); + const channelName = channel?.name?.trim() ?? null; + const { title, body } = formatMessageNotification({ + source: isMention ? "mention" : "channel", + senderName: resolveSenderName(event.pubkey), + channelName, + content: event.content, + }); + + void sendDesktopNotification({ + title, + body, + target: buildEventNotificationTarget(event, { + id: channelId, + name: channelName ?? "", + }), + }).then((didSend) => { + if (!didSend) return; + void requestDockBounce(); + }); }, ); const handleDmNotification = React.useEffectEvent( (event: RelayEvent, channel: Channel) => { if (!enabled) return; - if ( - !notificationSettings.desktopEnabled || - !notificationSettings.slotAlertsEnabled.dm - ) { - return; - } + if (!notificationSettings.desktopEnabled) return; + // The DM desktop toast follows the same rule as channel/mention toasts: + // deliver whenever desktop alerts are on (muted channels are excluded + // upstream). `slotAlertsEnabled.dm` is a per-category SOUND flag, surfaced + // only under Settings > Notifications > Sound, so it must NOT gate the + // toast — gating it here made DMs silently stop toasting whenever the DM + // sound row was off, unlike channels/mentions which never checked it. It + // now gates only the sound, below. const channelName = channel.name?.trim() || "Direct message"; const { title, body } = formatMessageNotification({ source: "dm", @@ -94,7 +140,10 @@ export function useAppShellDesktopNotifications({ }), }).then((didSend) => { if (!didSend) return; - if (shouldPlayNotificationSound(channel.id, silentChannelIds)) { + if ( + notificationSettings.slotAlertsEnabled.dm && + shouldPlayNotificationSound(channel.id, silentChannelIds) + ) { playNotificationSound(resolveSlotSound(notificationSettings, "dm")); } void requestDockBounce(); @@ -105,12 +154,9 @@ export function useAppShellDesktopNotifications({ const handleThreadReplyDesktopNotification = React.useEffectEvent( (channelId: string, event: RelayEvent) => { if (!enabled) return; - if ( - !notificationSettings.desktopEnabled || - !notificationSettings.slotAlertsEnabled.thread_reply - ) { - return; - } + if (!notificationSettings.desktopEnabled) return; + // As with DMs, `slotAlertsEnabled.thread_reply` is a per-category SOUND + // flag and must not gate the toast — it gates only the sound, below. // Replies that @-mention the user are owned by the home-feed mention // path — skip them here so they don't notify (and sound) twice. @@ -137,7 +183,10 @@ export function useAppShellDesktopNotifications({ }), }).then((didSend) => { if (!didSend) return; - if (shouldPlayNotificationSound(channelId, silentChannelIds)) { + if ( + notificationSettings.slotAlertsEnabled.thread_reply && + shouldPlayNotificationSound(channelId, silentChannelIds) + ) { playNotificationSound( resolveSlotSound(notificationSettings, "thread_reply"), ); diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 0844c24de51..d5a871f2564 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. @@ -146,6 +150,27 @@ export async function getDesktopNotificationPermissionState(): Promise( + "plugin:notification|is_permission_granted", + ); + return granted === null ? "default" : granted ? "granted" : "denied"; + } catch { + return "default"; + } + } + if (window.Notification.permission !== "default") { return window.Notification.permission; } @@ -418,14 +443,19 @@ export async function revealDesktopAppWindow(): Promise { export async function sendDesktopNotification( payload: DesktopNotificationPayload, ): Promise { - if ((await getDesktopNotificationPermissionState()) !== "granted") { + const permissionState = await getDesktopNotificationPermissionState(); + if (permissionState !== "granted") { return false; } // 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 native WinRT Toast Notifications bound to AUMID. // 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/features/notifications/lib/notificationFormat.ts b/desktop/src/features/notifications/lib/notificationFormat.ts index 04c951c33fc..5f2297dc50e 100644 --- a/desktop/src/features/notifications/lib/notificationFormat.ts +++ b/desktop/src/features/notifications/lib/notificationFormat.ts @@ -53,7 +53,10 @@ export type MessageNotificationSource = | "approval" | "needs_action" | "dm" - | "thread_reply"; + | "thread_reply" + // A plain new message in a channel (WhatsApp-style: notify for everything in + // a channel until it's muted). + | "channel"; const MESSAGE_BODY_FALLBACKS: Record = { mention: "Something in Buzz needs your attention.", @@ -61,6 +64,7 @@ const MESSAGE_BODY_FALLBACKS: Record = { needs_action: "Something in Buzz needs your attention.", dm: "New message", thread_reply: "New reply", + channel: "New message", }; /** @@ -106,7 +110,9 @@ export function formatMessageNotification(opts: { ? senderName ? `${senderName} replied` : "Reply" - : (senderName ?? "Needs Action"); + : source === "channel" + ? (senderName ?? "New message") + : (senderName ?? "Needs Action"); return { title: formatNotificationTitle({ prefix, channelLabel }), body }; } 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 a8ddb49fa54261d2ce62d1d99b02b0bbc770a0fe Mon Sep 17 00:00:00 2001 From: ranjankai Date: Thu, 17 Sep 2026 12:41:00 +0530 Subject: [PATCH 2/2] fix(windows): native WinRT toast notifications and startup registration - Query notification permission directly from backend to avoid Windows init shim latch. - Initialize process MTA and wire AUMID/shortcut registration into Tauri setup hook. - Gate first notification delivery on startup registration completion. - Propagate Toast::show() delivery errors across the IPC boundary. - Replace raw unsafe COM/shell-link calls with safe abstractions. - Preserve existing zero-by-default notification policy. Signed-off-by: ranjankai --- desktop/src-tauri/Cargo.lock | 51 +- desktop/src-tauri/Cargo.toml | 14 +- .../src-tauri/src/commands/notifications.rs | 965 +++++++----------- desktop/src-tauri/src/lib.rs | 5 + .../app/useAppShellDesktopNotifications.ts | 50 +- .../src/features/notifications/lib/desktop.ts | 62 +- .../notifications/lib/notificationFormat.ts | 10 +- 7 files changed, 475 insertions(+), 682 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 4aa6cb6fa29..ed920774aa4 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -210,7 +210,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -221,7 +221,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1172,6 +1172,7 @@ dependencies = [ "window-vibrancy", "windows 0.61.3", "windows-sys 0.61.2", + "winsafe", "zeroize", "zip 8.6.0", ] @@ -1642,7 +1643,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -2309,7 +2310,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -2487,7 +2488,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2768,7 +2769,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3286,7 +3287,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1", "windows-result 0.4.1", ] @@ -5744,7 +5745,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5903,7 +5904,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6416,7 +6417,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7120,7 +7121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -8146,7 +8147,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8877,7 +8878,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8947,7 +8948,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9214,7 +9215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9833,7 +9834,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10858,10 +10859,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10883,7 +10884,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook 0.3.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11625,7 +11626,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11726,7 +11727,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -12577,7 +12578,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -13180,6 +13181,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 9af20a49b52..7eecaa686d5 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -63,18 +63,12 @@ user-idle = { version = "0.6", default-features = false } plist = "1" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation", "Win32_UI_Shell"] } +windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "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 } -tauri-winrt-notification = "0.7.3" -windows = { version = "0.61", features = [ - "Win32_Foundation", - "Win32_Storage_EnhancedStorage", - "Win32_System_Com", - "Win32_System_Com_StructuredStorage", - "Win32_UI_Shell", - "Win32_UI_Shell_PropertiesSystem", -] } +tauri-winrt-notification = "0.7" +winsafe = { version = "0.0.29", features = ["advapi", "shell"] } +windows = { version = "0.61", features = ["UI_Notifications"] } [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 4eb0ff77629..1bba839e247 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -13,7 +13,8 @@ //! action, which we forward to the frontend so it can focus the window and //! route to the notification target. -#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#![forbid(unsafe_code)] + pub(crate) const NATIVE_NOTIFICATION_ACTIVATED_EVENT: &str = "native-notification-activated"; /// Show a desktop notification natively. @@ -42,8 +43,7 @@ pub async fn show_native_notification( #[cfg(target_os = "windows")] { - windows::show(app, title, body, target); - Ok(()) + windows::show(app, title, body, target).await } #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] @@ -62,133 +62,34 @@ pub async fn show_native_notification( /// this claims the AUMID and repairs the Start Menu shortcut so the OS /// recognises Buzz as notification-capable; everywhere else it does nothing. #[cfg(target_os = "windows")] -pub(crate) fn ensure_startup_registration(app: &tauri::AppHandle) { - windows::ensure_startup_registration(app); +pub(crate) fn ensure_startup_registration(app: &tauri::AppHandle) -> Result<(), String> { + windows::ensure_startup_registration(app) } #[cfg(not(target_os = "windows"))] -pub(crate) fn ensure_startup_registration(_app: &tauri::AppHandle) {} +pub(crate) fn ensure_startup_registration(_app: &tauri::AppHandle) -> Result<(), String> { + Ok(()) +} #[cfg(target_os = "windows")] -pub(crate) mod windows { - use std::sync::mpsc; - use std::sync::Once; - - static AUMID_REGISTERED: Once = Once::new(); - static STARTUP_REGISTRATION_DONE: Once = Once::new(); - static PROCESS_MTA_READY: Once = Once::new(); - - /// Establishes a process-wide multi-threaded COM apartment (MTA) that lives - /// for the entire lifetime of the app, and blocks until it is ready. - /// - /// This is the load-bearing piece for Windows toast delivery. - /// `tauri-winrt-notification`'s `Toast::show()` calls into WinRT - /// (`XmlDocument::new`, `ToastNotificationManager::CreateToastNotifierWithId`, - /// `ToastNotifier::Show`) and does no COM apartment init of its own — - /// confirmed by reading the crate source. The upstream Tauri notification - /// plugin gets away with that only because it posts from the tokio runtime, - /// where some other thread has already created an MTA and the worker joins - /// it via COM's "implicit MTA" rule (Raymond Chen, - /// ). Buzz - /// posts toasts from a bare `std::thread`, so unless the process already - /// has a live MTA, every one of those WinRT calls fails with - /// `CO_E_NOTINITIALIZED`. `Toast::show()` maps that to `Err`, which the - /// caller only `eprintln!`s — and release builds set - /// `windows_subsystem = "windows"`, so there is no console and the error - /// vanishes. Net effect: no toast ever appears, silently, on every launch, - /// on every machine. That is exactly the bug this fixes. - /// - /// We make the dependency explicit and permanent: one dedicated thread - /// initializes an MTA and then parks forever. It must never - /// `CoUninitialize` or exit — holding the apartment open is the entire - /// point. This does two things a per-notification `CoInitializeEx`/ - /// `CoUninitialize` pair (the tempting shorter fix) cannot: - /// 1. Every notification thread joins the implicit MTA with zero per-call - /// init, so there is no `CoUninitialize`-then-exit race against the - /// Windows Notification Platform's async delivery - /// (microsoft/WindowsAppSDK#3437). - /// 2. The `on_activated` click handler stays deliverable. In an MTA, - /// WinRT delivers `Activated`/`Dismissed` callbacks on COM worker - /// threads rather than a per-thread message pump, so the short-lived - /// thread that showed the toast can exit while the click handler still - /// fires later — as long as the apartment itself is still alive, which - /// this permanent thread guarantees. A per-call `CoUninitialize` + - /// thread exit would tear the sink down and silently drop every click. - /// - /// MTA (not STA/`COINIT_APARTMENTTHREADED`) is correct here: STA would - /// require this thread to run a window message pump to deliver callbacks, - /// which a parked thread does not. Microsoft's own WinRT guidance is to use - /// MTA for non-UI threads and reserve STA for UI threads - /// (). - fn ensure_process_mta() { - PROCESS_MTA_READY.call_once(|| { - let (ready_tx, ready_rx) = mpsc::channel::<()>(); - std::thread::spawn(move || { - use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED}; - // SAFETY: this thread performs no other COM work and never - // uninitializes; the apartment is intentionally leaked for the - // process lifetime. On a fresh thread this returns S_OK (or - // S_FALSE if the process MTA already exists) — both are - // non-negative, so `is_err()` is false. It cannot return - // RPC_E_CHANGED_MODE because this thread has never been - // initialized with a different model. - let hresult = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; - if hresult.is_err() { - eprintln!( - "buzz-desktop: failed to establish process MTA for notifications: {hresult:?}" - ); - } - // Signal readiness regardless: even on the (unexpected) error - // path, blocking callers must not hang. - let _ = ready_tx.send(()); - // Park forever. Returning from this closure would end the - // thread and tear the apartment down — never do that. - loop { - std::thread::park(); - } - }); - // Block until the apartment is confirmed up, so the very first - // toast can never race ahead of it. - let _ = ready_rx.recv(); - }); - } +#[tauri::command] +pub async fn windows_notification_permission_state( + app: tauri::AppHandle, +) -> Result { + windows::permission_state(app).await.map(str::to_string) +} - /// Runs Buzz's full Windows toast-notification eligibility setup once, - /// early in app startup — call this from `lib.rs`'s `setup()`, not - /// lazily on first notification. See `ensure_start_menu_shortcut` for - /// why this needs to run regardless of whether the user has actually - /// triggered a notification yet: Windows' Settings > Notifications page - /// only lists an AUMID once it has a shell-visible identity (a Start - /// Menu shortcut carrying that AUMID), and users reasonably expect to - /// find Buzz there before the first notification would ever fire. - pub(crate) fn ensure_startup_registration(app: &tauri::AppHandle) { - STARTUP_REGISTRATION_DONE.call_once(|| { - let app = app.clone(); - // Shell/COM calls can be slow (first-run disk I/O for the - // Start Menu folder) — do this off the main setup thread so - // it never delays window creation. - std::thread::spawn(move || { - // Bring the process MTA up at launch so the first toast never - // has to wait on it. Idempotent and cheap after the first call. - ensure_process_mta(); - let app_id = app.config().identifier.clone(); - set_process_aumid(&app_id); - if let Err(error) = write_aumid_registry_entry(&app, &app_id) { - eprintln!( - "buzz-desktop: failed to register AUMID for Windows notifications: {error}" - ); - } - if let Err(error) = write_notification_settings_entry(&app_id) { - eprintln!( - "buzz-desktop: failed to pre-register Windows notification settings entry: {error}" - ); - } - if let Err(error) = ensure_start_menu_shortcut(&app, &app_id) { - eprintln!("buzz-desktop: failed to repair Start Menu shortcut AUMID: {error}"); - } - }); - }); - } +#[cfg(target_os = "windows")] +#[tauri::command] +pub fn take_pending_windows_activations() -> Result, String> { + windows::take_pending_windows_activations() +} + +#[cfg(target_os = "linux")] +mod linux { + use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; + use notify_rust::Notification; + use tauri::Emitter; pub fn show( app: tauri::AppHandle, @@ -196,507 +97,429 @@ pub(crate) mod windows { body: Option, target: Option, ) { - use tauri::Emitter; - - let app_id = app.config().identifier.clone(); - ensure_aumid_registered(&app, &app_id); - // `Toast::show()` below calls into WinRT, which requires the calling - // thread to be in a COM apartment. See `ensure_process_mta` — this - // guarantees a process-wide MTA is live before we spawn, so the bare - // thread below joins it implicitly and the WinRT calls succeed. - ensure_process_mta(); - std::thread::spawn(move || { - let mut toast = tauri_winrt_notification::Toast::new(&app_id).text1(&title); + let mut notification = Notification::new(); + notification.summary(&title); if let Some(body_text) = body.as_deref() { - toast = toast.text2(body_text); + notification.body(body_text); } - // Without this, clicking the toast does nothing — Linux and - // macOS both focus the window and route to `target` on click - // (see `linux::show`/`macos_notifications.rs`); Windows never - // did. `on_activated` fires with `None` for a plain click on - // the toast body (no button involved), which is the only case - // we handle today — the button-click `Some(action)` path is - // free for future per-notification actions (e.g. "Mark read"). - let activation_app = app.clone(); - toast = toast.on_activated(move |action| { - if action.is_none() { - let _ = activation_app.emit( - crate::commands::NATIVE_NOTIFICATION_ACTIVATED_EVENT, - target.clone(), - ); + // A default action must be set so the desktop shell registers clickability. + notification.action("default", "default"); + + let handle = match notification.show() { + Ok(handle) => handle, + Err(error) => { + eprintln!("buzz-desktop: failed to post Linux native notification: {error}"); + return; } - Ok(()) - }); + }; - if let Err(error) = toast.show() { - eprintln!("buzz-desktop: failed to post Windows native notification: {error}"); - } + // Hold the D-Bus connection open until the user acts or dismisses. + handle.wait_for_action(move |action| { + if action == "default" { + let _ = app.emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, target); + } + }); }); } +} - /// Registers Buzz's AUMID so Windows will accept native toast - /// notifications and list Buzz under Settings > Notifications. - /// - /// Buzz ships an unpackaged (non-MSIX) Win32 binary with no Start Menu - /// shortcut carrying a `System.AppUserModel.ID` property, so the shell - /// has no other way to learn Buzz's AUMID or icon. Without both of the - /// steps below, `ToastNotifier` silently drops every toast: - /// 1. The current process must explicitly claim the AUMID it will pass - /// to `Toast::new` (`SetCurrentProcessExplicitAppUserModelID`). - /// 2. That same AUMID must be registered under - /// `HKCU\Software\Classes\AppUserModelId\` with a display - /// name and icon, or Windows has nothing to show in the - /// notification settings list and rejects the toast. - /// - /// Runs at most once per process. - fn ensure_aumid_registered(app: &tauri::AppHandle, app_id: &str) { - AUMID_REGISTERED.call_once(|| { - set_process_aumid(app_id); - if let Err(error) = write_aumid_registry_entry(app, app_id) { - eprintln!( - "buzz-desktop: failed to register AUMID for Windows notifications: {error}" - ); - } - if let Err(error) = write_notification_settings_entry(app_id) { - eprintln!( - "buzz-desktop: failed to pre-register Windows notification settings entry: {error}" - ); - } - }); - } +// ── 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. - fn to_wide(value: &str) -> Vec { - value.encode_utf16().chain(std::iter::once(0)).collect() +#[cfg(target_os = "windows")] +mod windows { + use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; + use std::collections::VecDeque; + use std::sync::{Mutex, OnceLock}; + use tauri::Emitter; + use tauri_winrt_notification::{Duration, Toast}; + use windows::{ + core::HSTRING, + UI::Notifications::{NotificationSetting, ToastNotificationManager}, + }; + use winsafe::{co, prelude::*, IPersistFile, IPropertyStore, IShellLink, RegistryValue, HKEY}; + + const MAX_PENDING_ACTIVATIONS: usize = 64; + static STARTUP_REGISTRATION: OnceLock> = OnceLock::new(); + static PENDING_ACTIVATIONS: OnceLock>> = OnceLock::new(); + + pub fn ensure_startup_registration(app: &tauri::AppHandle) -> Result<(), String> { + STARTUP_REGISTRATION + .get_or_init(|| { + let app_id = app.config().identifier.clone(); + set_process_aumid(&app_id)?; + write_aumid_registry_entry(app, &app_id)?; + write_notification_settings_entry(&app_id)?; + ensure_start_menu_shortcut(app, &app_id) + }) + .clone() } - fn set_process_aumid(app_id: &str) { - use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; - - let wide_app_id = to_wide(app_id); - // SAFETY: `wide_app_id` is a valid, NUL-terminated UTF-16 buffer that - // outlives this call. - let hresult = unsafe { SetCurrentProcessExplicitAppUserModelID(wide_app_id.as_ptr()) }; - if hresult < 0 { - eprintln!( - "buzz-desktop: SetCurrentProcessExplicitAppUserModelID failed: 0x{hresult:08X}" - ); - } + fn set_process_aumid(app_id: &str) -> Result<(), String> { + winsafe::SetCurrentProcessExplicitAppUserModelID(app_id) + .map_err(|error| format!("failed to set Windows process AUMID: {error}")) } - /// Pre-seeds the per-app notification Settings key so Buzz appears in - /// Settings > System > Notifications immediately on a clean install, - /// instead of only after the first toast is delivered. - /// - /// Create-if-absent by design. Windows owns the `Enabled` value under this - /// key: once the key exists, its value reflects either Windows' own state - /// or a choice the user made in Settings > Notifications. Rewriting - /// `Enabled=1` on every launch (as the first version of this did) would - /// silently turn Buzz notifications back on every time the app starts, - /// overriding a user who had deliberately turned them off. So we only write - /// the seed values when we are the ones creating the key for the first - /// time; if it already exists we leave it entirely untouched. fn write_notification_settings_entry(app_id: &str) -> Result<(), String> { - use windows_sys::Win32::System::Registry::{ - RegCloseKey, RegCreateKeyExW, RegOpenKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, - KEY_READ, KEY_WRITE, REG_DWORD, REG_OPTION_NON_VOLATILE, - }; - - let subkey = to_wide(&format!( + initialize_notification_settings(&format!( "Software\\Microsoft\\Windows\\CurrentVersion\\Notifications\\Settings\\{app_id}" - )); - - // SAFETY: `subkey` is a NUL-terminated UTF-16 buffer that outlives - // every call below; each key handle we obtain is closed exactly once - // on every path. - unsafe { - // If the key already exists, respect whatever state is there — - // never re-seed. See the doc comment above. - let mut existing: HKEY = std::ptr::null_mut(); - let open_status = - RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut existing); - if open_status == 0 { - RegCloseKey(existing); - return Ok(()); - } - - let mut hkey: HKEY = std::ptr::null_mut(); - let create_status = RegCreateKeyExW( - HKEY_CURRENT_USER, - subkey.as_ptr(), - 0, - std::ptr::null(), - REG_OPTION_NON_VOLATILE, - KEY_WRITE, - std::ptr::null(), - &mut hkey, - std::ptr::null_mut(), - ); - if create_status != 0 { - return Err(format!( - "RegCreateKeyExW failed with status {create_status}" - )); - } - - let show_in_action_center_value = to_wide("ShowInActionCenter"); - let show_in_action_center_data: u32 = 1; - let enabled_value = to_wide("Enabled"); - let enabled_data: u32 = 1; - - let show_status = RegSetValueExW( - hkey, - show_in_action_center_value.as_ptr(), - 0, - REG_DWORD, - (&show_in_action_center_data as *const u32).cast::(), - std::mem::size_of::() as u32, - ); - let enabled_status = RegSetValueExW( - hkey, - enabled_value.as_ptr(), - 0, - REG_DWORD, - (&enabled_data as *const u32).cast::(), - std::mem::size_of::() as u32, - ); - - RegCloseKey(hkey); + )) + } - if enabled_status != 0 { - return Err(format!( - "RegSetValueExW(Enabled) failed with status {enabled_status}" - )); - } - if show_status != 0 { - return Err(format!( - "RegSetValueExW(ShowInActionCenter) failed with status {show_status}" - )); - } + fn initialize_notification_settings(subkey: &str) -> Result<(), String> { + let (key, disposition) = HKEY::CURRENT_USER + .RegCreateKeyEx( + subkey, + None, + co::REG_OPTION::NON_VOLATILE, + co::KEY::WRITE, + None, + ) + .map_err(|error| format!("could not open Windows notification settings: {error}"))?; + if disposition == co::REG_DISPOSITION::OPENED_EXISTING_KEY { + return Ok(()); + } + for name in ["ShowInActionCenter", "Enabled"] { + key.RegSetValueEx(Some(name), RegistryValue::Dword(1)) + .map_err(|error| { + format!("could not initialize notification setting {name}: {error}") + })?; } - Ok(()) } fn write_aumid_registry_entry(app: &tauri::AppHandle, app_id: &str) -> Result<(), String> { - use windows_sys::Win32::System::Registry::{ - RegCloseKey, RegCreateKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, KEY_WRITE, - REG_OPTION_NON_VOLATILE, REG_SZ, - }; - let display_name = app .config() .product_name .clone() .unwrap_or_else(|| "Buzz".to_string()); - // The exe's own icon resource doubles as the AUMID icon; Windows - // accepts a path to an executable here just as it does a .ico file. let icon_path = std::env::current_exe() - .map_err(|error| format!("could not resolve current exe path: {error}"))? - .to_string_lossy() - .into_owned(); - - let subkey = to_wide(&format!("Software\\Classes\\AppUserModelId\\{app_id}")); - let display_name_value = to_wide("DisplayName"); - let display_name_data = to_wide(&display_name); - let icon_uri_value = to_wide("IconUri"); - let icon_uri_data = to_wide(&icon_path); - - // SAFETY: every buffer passed below is a NUL-terminated UTF-16 - // string that outlives the corresponding call, `hkey` is only used - // after a successful `RegCreateKeyExW`, and it is closed exactly - // once before returning. - unsafe { - let mut hkey: HKEY = std::ptr::null_mut(); - let create_status = RegCreateKeyExW( - HKEY_CURRENT_USER, - subkey.as_ptr(), - 0, - std::ptr::null(), - REG_OPTION_NON_VOLATILE, - KEY_WRITE, - std::ptr::null(), - &mut hkey, - std::ptr::null_mut(), - ); - if create_status != 0 { - return Err(format!( - "RegCreateKeyExW failed with status {create_status}" - )); - } - - let display_name_bytes = std::slice::from_raw_parts( - display_name_data.as_ptr().cast::(), - display_name_data.len() * 2, - ); - let display_status = RegSetValueExW( - hkey, - display_name_value.as_ptr(), - 0, - REG_SZ, - display_name_bytes.as_ptr(), - display_name_bytes.len() as u32, - ); - - let icon_uri_bytes = std::slice::from_raw_parts( - icon_uri_data.as_ptr().cast::(), - icon_uri_data.len() * 2, - ); - let icon_status = RegSetValueExW( - hkey, - icon_uri_value.as_ptr(), - 0, - REG_SZ, - icon_uri_bytes.as_ptr(), - icon_uri_bytes.len() as u32, - ); - - RegCloseKey(hkey); + .map_err(|error| format!("could not resolve current executable: {error}"))?; + write_aumid_metadata( + &format!("Software\\Classes\\AppUserModelId\\{app_id}"), + &display_name, + &icon_path.to_string_lossy(), + ) + } - if display_status != 0 { - return Err(format!( - "RegSetValueExW(DisplayName) failed with status {display_status}" - )); - } - if icon_status != 0 { - return Err(format!( - "RegSetValueExW(IconUri) failed with status {icon_status}" - )); - } + fn write_aumid_metadata( + subkey: &str, + display_name: &str, + icon_path: &str, + ) -> Result<(), String> { + let (key, _) = HKEY::CURRENT_USER + .RegCreateKeyEx( + subkey, + None, + co::REG_OPTION::NON_VOLATILE, + co::KEY::WRITE, + None, + ) + .map_err(|error| format!("could not open Windows AUMID metadata: {error}"))?; + for (name, value) in [("DisplayName", display_name), ("IconUri", icon_path)] { + key.RegSetValueEx(Some(name), RegistryValue::Sz(value.to_string())) + .map_err(|error| format!("could not write AUMID {name}: {error}"))?; } - Ok(()) } - /// Creates or repairs the per-user Start Menu shortcut so it carries a - /// `System.AppUserModel.ID` property matching `app_id`. - /// - /// This is the piece the registry/process-AUMID calls above cannot - /// substitute for. For an unpackaged (non-MSIX) Win32 app, Windows only - /// treats an AUMID as a real, notification-capable app identity once - /// the shell can resolve it back to a Start Menu shortcut carrying that - /// same AUMID as a shell-link property — that's what makes the app show - /// up under Settings > Notifications and what makes `ToastNotifier` - /// accept toasts at all, not just the registry DisplayName/IconUri - /// metadata written above. - /// - /// The installer's NSIS template already sets this property on the - /// shortcut it creates — but only for a *fresh* install. Tauri's - /// generated NSIS `CreateOrUpdateStartMenuShortcut` macro skips - /// recreating the shortcut on an in-place update (which is what every - /// silent auto-update via the Tauri updater is), so any install whose - /// shortcut predates this AUMID work — or was ever silently updated - /// before this fix — never gets it patched by the installer. Repairing - /// it here, unconditionally, on every app launch, makes this self- - /// healing regardless of install history or update path. fn ensure_start_menu_shortcut(app: &tauri::AppHandle, app_id: &str) -> Result<(), String> { - // NOTE: `IPersistFile` and `STGM_READWRITE` live under - // `Win32::System::Com` (general COM persistence/storage types), not - // `Win32::UI::Shell`, even though we only use `IPersistFile` here to - // persist a shell link — double-check this against the actual crate - // docs for the pinned `windows` version if this module fails to - // resolve. - use windows::core::{Interface, PCWSTR}; - // `PKEY_AppUserModel_ID` lives here, not under - // `Win32::UI::Shell::PropertiesSystem` — that module only carries - // `PKEY_PIDSTR_MAX`. Verified against the crate's own generated docs; - // the wrong path fails to compile rather than silently misbehaving. - use windows::Win32::Storage::EnhancedStorage::PKEY_AppUserModel_ID; - use windows::Win32::System::Com::{ - CoCreateInstance, CoInitializeEx, CoUninitialize, IPersistFile, CLSCTX_INPROC_SERVER, - COINIT_APARTMENTTHREADED, STGM_READWRITE, - }; - // No `InitPropVariantFromStringW` here — it doesn't exist in this - // crate (or in any windows-rs version): the Win32 API of that name - // is a header-only inline wrapper in propvarutil.h, not a real - // exported symbol, so win32metadata never carries it (see - // microsoft/windows-rs#976, still open). `PROPVARIANT: From<&str>` - // is the crate's own replacement, and `PROPVARIANT: Drop` already - // clears it — do not additionally call `PropVariantClear` on one of - // these or it double-frees the string it owns. - use windows::Win32::System::Com::StructuredStorage::PROPVARIANT; - use windows::Win32::UI::Shell::{ - FOLDERID_Programs, IShellLinkW, PropertiesSystem::IPropertyStore, SHGetKnownFolderPath, - ShellLink, KF_FLAG_CREATE, - }; - let product_name = app .config() .product_name .clone() .unwrap_or_else(|| "Buzz".to_string()); - let exe_path = std::env::current_exe() - .map_err(|error| format!("could not resolve current exe path: {error}"))?; - - // SAFETY: this thread does not otherwise touch COM; the apartment - // is torn down before returning from this function on every path. - // `CoInitializeEx` returns `S_OK` on first init and `S_FALSE` if this - // thread already has an apartment (both non-negative, so `is_err()` - // is false for either) — only a genuinely negative HRESULT here - // means initialization failed. - let init_result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; - if init_result.is_err() { - return Err(format!("CoInitializeEx failed: {init_result:?}")); - } - let result = (|| -> Result<(), String> { - let programs_dir_pwstr = unsafe { - SHGetKnownFolderPath(&FOLDERID_Programs, KF_FLAG_CREATE, None) - .map_err(|error| format!("SHGetKnownFolderPath failed: {error}"))? - }; - // `SHGetKnownFolderPath` hands back a COM-allocated buffer the - // caller owns and must free — `windows-rs` does not do this - // automatically for a raw returned `PWSTR`. - let programs_dir_result = unsafe { programs_dir_pwstr.to_string() }; - unsafe { - windows::Win32::System::Com::CoTaskMemFree(Some( - programs_dir_pwstr.0 as *const std::ffi::c_void, - )); - } - let programs_dir = - programs_dir_result.map_err(|error| format!("invalid Start Menu path: {error}"))?; - - // NSIS's `MUI_STARTMENU_GETFOLDER` (which Tauri's generated - // installer.nsi uses — see its `$AppStartMenuFolder` var) - // defaults to putting the shortcut in a subfolder named after - // the product, i.e. `$SMPROGRAMS\Buzz\Buzz.lnk`, NOT flat at - // `$SMPROGRAMS\Buzz.lnk`. Confirmed by reading the actual - // generated installer.nsi from a real local build — it only - // falls back to the flat path if the user explicitly cleared - // the Start Menu folder field during install (installer.nsi - // lines ~912-917). Try the subfolder path first, since it's the - // default nearly everyone gets; fall back to flat if that's not - // what's actually on disk; if neither exists yet, create at the - // subfolder path to match what a fresh install would produce. - let subfolder_path = format!("{programs_dir}\\{product_name}\\{product_name}.lnk"); - let flat_path = format!("{programs_dir}\\{product_name}.lnk"); - let shortcut_path = if std::path::Path::new(&subfolder_path).exists() { - subfolder_path - } else if std::path::Path::new(&flat_path).exists() { - flat_path - } else { - // Neither exists — this is a from-scratch repair (no prior - // install ever ran, or its shortcut was deleted). Ensure the - // subfolder exists, then create there, matching the - // installer's own default layout. - std::fs::create_dir_all(format!("{programs_dir}\\{product_name}")) - .map_err(|error| format!("could not create Start Menu folder: {error}"))?; - subfolder_path - }; - let shortcut_path_wide = to_wide(&shortcut_path); - let exe_path_wide = to_wide(&exe_path.to_string_lossy()); + let executable = std::env::current_exe() + .map_err(|error| format!("could not resolve current executable: {error}"))?; + let programs = + winsafe::SHGetKnownFolderPath(&co::KNOWNFOLDERID::Programs, co::KF::CREATE, None) + .map_err(|error| format!("could not find Start Menu programs: {error}"))?; + ensure_shortcut_in( + std::path::Path::new(&programs), + &product_name, + &executable, + app_id, + ) + } - let shell_link: IShellLinkW = unsafe { - CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) - .map_err(|error| format!("CoCreateInstance(ShellLink) failed: {error}"))? - }; - let persist_file: IPersistFile = shell_link - .cast() - .map_err(|error| format!("IShellLinkW -> IPersistFile cast failed: {error}"))?; - - // If a shortcut already exists (the normal case — the installer - // made one), load it so we preserve whatever else it sets - // (working directory, description, etc.) and only touch the - // AUMID property. If it doesn't exist yet, fall through and - // build a fresh one below. - let existing = - unsafe { persist_file.Load(PCWSTR(shortcut_path_wide.as_ptr()), STGM_READWRITE) }; - if existing.is_err() { - unsafe { - shell_link - .SetPath(PCWSTR(exe_path_wide.as_ptr())) - .map_err(|error| format!("IShellLinkW::SetPath failed: {error}"))?; - shell_link - .SetIconLocation(PCWSTR(exe_path_wide.as_ptr()), 0) - .map_err(|error| format!("IShellLinkW::SetIconLocation failed: {error}"))?; - } - } + fn ensure_shortcut_in( + programs: &std::path::Path, + product_name: &str, + executable: &std::path::Path, + app_id: &str, + ) -> Result<(), String> { + let _apartment = winsafe::CoInitializeEx(co::COINIT::APARTMENTTHREADED) + .map_err(|error| format!("could not initialize shortcut COM apartment: {error}"))?; + let subfolder = programs.join(product_name); + let nested_path = subfolder.join(format!("{product_name}.lnk")); + let flat_path = programs.join(format!("{product_name}.lnk")); + let shortcut_path = if nested_path.exists() { + nested_path + } else if flat_path.exists() { + flat_path + } else { + std::fs::create_dir_all(&subfolder) + .map_err(|error| format!("could not create Start Menu folder: {error}"))?; + nested_path + }; + let link = winsafe::CoCreateInstance::( + &co::CLSID::ShellLink, + None::<&winsafe::IUnknown>, + co::CLSCTX::INPROC_SERVER, + ) + .map_err(|error| format!("could not create ShellLink: {error}"))?; + let persist: IPersistFile = link + .QueryInterface() + .map_err(|error| format!("could not query shortcut persistence: {error}"))?; + if shortcut_path.exists() { + persist + .Load(&shortcut_path.to_string_lossy(), co::STGM::READWRITE) + .map_err(|error| format!("could not load Start Menu shortcut: {error}"))?; + } else { + link.SetPath(&executable.to_string_lossy()) + .map_err(|error| format!("could not set shortcut path: {error}"))?; + link.SetIconLocation(&executable.to_string_lossy(), 0) + .map_err(|error| format!("could not set shortcut icon: {error}"))?; + } + let properties: IPropertyStore = link + .QueryInterface() + .map_err(|error| format!("could not query shortcut property store: {error}"))?; + properties + .SetValue( + &co::PKEY::AppUserModel_ID, + &winsafe::PropVariant::from_str(app_id), + ) + .map_err(|error| format!("could not set shortcut AUMID: {error}"))?; + properties + .Commit() + .map_err(|error| format!("could not commit shortcut properties: {error}"))?; + persist + .Save(Some(&shortcut_path.to_string_lossy()), true) + .map_err(|error| format!("could not save Start Menu shortcut: {error}")) + } - let props: IPropertyStore = shell_link - .cast() - .map_err(|error| format!("IShellLinkW -> IPropertyStore cast failed: {error}"))?; - // `prop_value` owns the wide-string copy `SetValue` reads; it is - // freed automatically (via `PROPVARIANT`'s `Drop`) when it goes - // out of scope below, after `Commit`/`Save` are done with it. - let prop_value = PROPVARIANT::from(app_id); - unsafe { - props - .SetValue(&PKEY_AppUserModel_ID, &prop_value) - .map_err(|error| format!("IPropertyStore::SetValue failed: {error}"))?; - props - .Commit() - .map_err(|error| format!("IPropertyStore::Commit failed: {error}"))?; - persist_file - .Save(PCWSTR(shortcut_path_wide.as_ptr()), true) - .map_err(|error| format!("IPersistFile::Save failed: {error}"))?; - } + fn queue_activation(target: Option) { + let Some(target) = target else { + return; + }; + let queue = PENDING_ACTIVATIONS.get_or_init(Default::default); + let Ok(mut queue) = queue.lock() else { + eprintln!("buzz-desktop: Windows activation queue is unavailable"); + return; + }; + if queue.len() == MAX_PENDING_ACTIVATIONS { + queue.pop_front(); + } + queue.push_back(target); + } - Ok(()) - })(); + pub fn take_pending_windows_activations() -> Result, String> { + let queue = PENDING_ACTIVATIONS.get_or_init(Default::default); + let mut queue = queue + .lock() + .map_err(|_| "Windows activation queue is unavailable".to_string())?; + Ok(queue.drain(..).collect()) + } - if init_result.is_ok() { - unsafe { CoUninitialize() }; + fn permission_state_label(setting: NotificationSetting) -> &'static str { + if setting == NotificationSetting::Enabled { + "granted" + } else { + "denied" } - result } -} -#[cfg(target_os = "linux")] -mod linux { - use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; - use tauri::Emitter; + pub async fn permission_state(app: tauri::AppHandle) -> Result<&'static str, String> { + let app_id = app.config().identifier.clone(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + + app.run_on_main_thread(move || { + let result = + ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(app_id)) + .and_then(|notifier| notifier.Setting()) + .map(permission_state_label) + .map_err(|error| { + format!("failed to query Windows notification setting: {error}") + }); + let _ = sender.send(result); + }) + .map_err(|error| format!("failed to schedule Windows notification query: {error}"))?; + + receiver + .await + .map_err(|_| "Windows notification query ended before completing".to_string())? + } - pub fn show( + pub async fn show( app: tauri::AppHandle, title: String, body: Option, target: Option, - ) { - // notify_rust's `show()` blocks on D-Bus and the returned handle must - // outlive the notification, so this runs on its own thread rather than - // the async runtime. - std::thread::spawn(move || { - let mut builder = notify_rust::Notification::new(); - builder.summary(&title); - if let Some(body) = body.as_deref() { - builder.body(body); + ) -> Result<(), String> { + // 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(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + let notification_app = app.clone(); + + // Tauri's main thread owns an initialized Windows apartment. Construct + // and post WinRT notifications there, then report the real result. + app.run_on_main_thread(move || { + let activation_app = notification_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. + queue_activation(target.clone()); + let _ = activation_app.emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, &target); + Ok(()) + }) + .show() + .map_err(|error| format!("failed to post Windows notification: {error}")); + let _ = sender.send(result); + }) + .map_err(|error| format!("failed to schedule Windows notification: {error}"))?; + + receiver + .await + .map_err(|_| "Windows notification task ended before posting".to_string())? + } + + #[cfg(test)] + mod tests { + use super::*; + + struct TestRegistryKey(String); + + impl TestRegistryKey { + fn new() -> Self { + Self(format!( + "Software\\BuzzNotificationTest-{}", + uuid::Uuid::new_v4() + )) } - if let Some(name) = app.config().product_name.clone() { - builder.appname(&name); + + fn open(&self) -> winsafe::guard::RegCloseKeyGuard { + HKEY::CURRENT_USER + .RegOpenKeyEx( + Some(&self.0), + co::REG_OPTION::default(), + co::KEY::READ | co::KEY::WRITE, + ) + .expect("open isolated test key") } - // Tie the notification to the installed desktop entry so GNOME shows - // the app's name and icon and groups our notifications together. - builder.hint(notify_rust::Hint::DesktopEntry( - app.config().identifier.clone(), - )); - builder.auto_icon(); - // Match the silent posting used on other platforms; the app does its - // own unread cues and a per-message sound would be noisy. - builder.hint(notify_rust::Hint::SuppressSound(true)); - // Declaring a default action makes the whole notification clickable. - builder.action("default", "Open"); - - let handle = match builder.show() { - Ok(handle) => handle, - Err(error) => { - eprintln!("buzz-desktop: failed to post native notification: {error}"); - return; - } - }; + } - // Block until the notification is actioned or closed. Holding the - // handle keeps its D-Bus connection alive, which is what stops - // GNOME 46+ from dismissing the notification immediately. The wait - // also returns when the notification expires or is dismissed, so - // the thread does not leak. - handle.wait_for_action(|action| { - if action != "default" { - return; - } + impl Drop for TestRegistryKey { + fn drop(&mut self) { + HKEY::CURRENT_USER + .RegDeleteTree(Some(&self.0)) + .expect("remove isolated test key"); + } + } - // The frontend focuses the window on activation (the same path - // every other platform uses), so we only forward the target. - let _ = app.emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, target); - }); - }); + #[test] + fn notification_settings_preserve_existing_opt_out() { + let test_key = TestRegistryKey::new(); + initialize_notification_settings(&test_key.0).expect("initialize settings"); + let key = test_key.open(); + for name in ["Enabled", "ShowInActionCenter"] { + assert!(matches!( + key.RegQueryValueEx(Some(name)), + Ok(RegistryValue::Dword(1)) + )); + key.RegSetValueEx(Some(name), RegistryValue::Dword(0)) + .expect("disable setting"); + } + initialize_notification_settings(&test_key.0).expect("repair settings"); + for name in ["Enabled", "ShowInActionCenter"] { + assert!(matches!( + key.RegQueryValueEx(Some(name)), + Ok(RegistryValue::Dword(0)) + )); + } + } + + #[test] + fn aumid_metadata_is_repaired() { + let test_key = TestRegistryKey::new(); + write_aumid_metadata(&test_key.0, "Old Buzz", "old.exe").expect("initial metadata"); + write_aumid_metadata(&test_key.0, "Buzz", "new.exe").expect("repair metadata"); + let key = test_key.open(); + assert!( + matches!(key.RegQueryValueEx(Some("DisplayName")), Ok(RegistryValue::Sz(value)) if value == "Buzz") + ); + assert!( + matches!(key.RegQueryValueEx(Some("IconUri")), Ok(RegistryValue::Sz(value)) if value == "new.exe") + ); + } + + #[test] + fn shortcut_aumid_is_repaired_without_changing_arguments() { + let directory = tempfile::tempdir().expect("temporary programs folder"); + let executable = std::env::current_exe().expect("test executable"); + ensure_shortcut_in(directory.path(), "Buzz", &executable, "buzz.test.old") + .expect("create shortcut"); + let path = directory.path().join("Buzz").join("Buzz.lnk"); + let _apartment = + winsafe::CoInitializeEx(co::COINIT::APARTMENTTHREADED).expect("COM apartment"); + let load = || { + let link = winsafe::CoCreateInstance::( + &co::CLSID::ShellLink, + None::<&winsafe::IUnknown>, + co::CLSCTX::INPROC_SERVER, + ) + .expect("ShellLink"); + link.QueryInterface::() + .expect("persist") + .Load(&path.to_string_lossy(), co::STGM::READWRITE) + .expect("load shortcut"); + link + }; + { + let link = load(); + link.SetArguments("--preserve-this").expect("arguments"); + link.QueryInterface::() + .expect("persist") + .Save(Some(&path.to_string_lossy()), true) + .expect("save arguments"); + } + ensure_shortcut_in(directory.path(), "Buzz", &executable, "buzz.test.new") + .expect("repair shortcut"); + let link = load(); + assert_eq!( + link.GetArguments().expect("read arguments"), + "--preserve-this" + ); + let value = link + .QueryInterface::() + .expect("properties") + .GetValue(&co::PKEY::AppUserModel_ID) + .expect("read AUMID"); + assert!(matches!(value, winsafe::PropVariant::Bstr(value) if value == "buzz.test.new")); + } + + #[test] + fn shortcut_failure_is_propagated() { + let directory = tempfile::tempdir().expect("temporary programs folder"); + let executable = std::env::current_exe().expect("test executable"); + let bad_path = directory.path().join("not-a-folder"); + std::fs::write(&bad_path, "blocking file").expect("write blocker"); + assert!(ensure_shortcut_in(&bad_path, "Buzz", &executable, "buzz.test").is_err()); + } } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2dde312d779..babb76b92a6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -235,6 +235,7 @@ pub fn run() { .manage(channel_head_cache::ChannelHeadCacheStore::default()) .setup(move |app| { let app_handle = app.handle().clone(); + commands::ensure_startup_registration(&app_handle).map_err(std::io::Error::other)?; #[cfg(target_os = "macos")] { tray_menu::init(&app_handle)?; @@ -651,6 +652,10 @@ pub fn run() { get_event, get_events, show_native_notification, + #[cfg(target_os = "windows")] + windows_notification_permission_state, + #[cfg(target_os = "windows")] + take_pending_windows_activations, #[cfg(target_os = "macos")] macos_notifications::take_pending_activations, #[cfg(target_os = "macos")] diff --git a/desktop/src/app/useAppShellDesktopNotifications.ts b/desktop/src/app/useAppShellDesktopNotifications.ts index 2ea69c368cc..e410285bf12 100644 --- a/desktop/src/app/useAppShellDesktopNotifications.ts +++ b/desktop/src/app/useAppShellDesktopNotifications.ts @@ -6,7 +6,6 @@ import { shouldBounceForChannelNotification, } from "@/app/AppShell.helpers"; import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts"; -import { isThreadReply } from "@/features/messages/lib/threading"; import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify"; import type { NotificationSettings } from "@/features/notifications/hooks"; import { @@ -60,54 +59,11 @@ export function useAppShellDesktopNotifications({ const resolveSenderName = useNotificationSenderName(); const handleChannelNotification = React.useEffectEvent( - (channelId: string, event: RelayEvent) => { + (_channelId: string, event: RelayEvent) => { if (!enabled) return; + if (!shouldBounceForChannelNotification(event.tags)) return; if (!notificationSettings.desktopEnabled) return; - - const bounce = () => { - if (shouldBounceForChannelNotification(event.tags)) { - void requestDockBounce(); - } - }; - - // Thread replies and DMs each have their own desktop-notification path - // (thread-reply and DM). This handler owns every OTHER top-level channel - // message — WhatsApp-style: notify for every message in a channel until it - // is muted. Muted channels never reach here (shouldNotifyForEvent excludes - // them upstream, and only fires this callback for unmuted channels). - // Top-level @-mentions are notified here too (with mention-specific copy) - // rather than via the home-feed path, so a mention reliably toasts. - const normalizedPubkey = pubkey?.trim().toLowerCase() ?? ""; - if (isThreadReply(event.tags)) { - bounce(); - return; - } - const channel = channels.find((c) => c.id === channelId); - if (channel?.channelType === "dm") { - bounce(); - return; - } - - const isMention = hasMentionForEvent(event, normalizedPubkey); - const channelName = channel?.name?.trim() ?? null; - const { title, body } = formatMessageNotification({ - source: isMention ? "mention" : "channel", - senderName: resolveSenderName(event.pubkey), - channelName, - content: event.content, - }); - - void sendDesktopNotification({ - title, - body, - target: buildEventNotificationTarget(event, { - id: channelId, - name: channelName ?? "", - }), - }).then((didSend) => { - if (!didSend) return; - void requestDockBounce(); - }); + void requestDockBounce(); }, ); diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index d5a871f2564..6406ad058fa 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -16,8 +16,12 @@ import { // queued macOS activation becomes available. See src-tauri notification code. const NATIVE_NOTIFICATION_ACTIVATED_EVENT = "native-notification-activated"; const TAKE_PENDING_MACOS_NOTIFICATION_ACTIVATIONS = "take_pending_activations"; +const TAKE_PENDING_WINDOWS_NOTIFICATION_ACTIVATIONS = + "take_pending_windows_activations"; const MACOS_NOTIFICATION_PERMISSION_STATE = "notification_permission_state"; const REQUEST_MACOS_NOTIFICATION_ACCESS = "request_notification_access"; +const WINDOWS_NOTIFICATION_PERMISSION_STATE = + "windows_notification_permission_state"; export type DesktopNotificationPermissionState = | NotificationPermission @@ -150,22 +154,14 @@ export async function getDesktopNotificationPermissionState(): Promise( - "plugin:notification|is_permission_granted", + return await invoke( + WINDOWS_NOTIFICATION_PERMISSION_STATE, ); - return granted === null ? "default" : granted ? "granted" : "denied"; } catch { return "default"; } @@ -199,16 +195,18 @@ export async function requestDesktopNotificationAccess(): Promise(REQUEST_MACOS_NOTIFICATION_ACCESS).catch( - (error) => { + isTauri() && isWindowsPlatform() + ? getDesktopNotificationPermissionState() + : isTauri() && isMacPlatform() + ? invoke( + REQUEST_MACOS_NOTIFICATION_ACCESS, + ).catch((error) => { if (shouldUseMacDevelopmentFallback(error)) { return requestPermission(); } throw error; - }, - ) - : requestPermission(); + }) + : requestPermission(); pendingPermissionRequest = request.finally(() => { pendingPermissionRequest = null; }); @@ -239,8 +237,11 @@ export async function listenForDesktopNotificationActions( if (isTauri()) { const usesMacActivationQueue = isMacPlatform(); + const usesWindowsActivationQueue = isWindowsPlatform(); + const usesActivationQueue = + usesMacActivationQueue || usesWindowsActivationQueue; - if (!isLinuxPlatform() && !usesMacActivationQueue) { + if (!isLinuxPlatform() && !usesActivationQueue) { try { pluginListener = await onAction((notification) => { const target = parseNotificationTarget( @@ -257,8 +258,8 @@ export async function listenForDesktopNotificationActions( } } - // Linux forwards the target as the event payload. macOS queues targets in - // Rust first so cold-start clicks survive until this listener is mounted. + // Linux forwards the target as the event payload. macOS and Windows queue + // targets in Rust first so cold-start clicks survive until this listener is mounted. const dispatchNativeActivations = async (payload?: unknown) => { if (usesMacActivationQueue) { const targets = await invoke( @@ -273,6 +274,19 @@ export async function listenForDesktopNotificationActions( return; } + if (usesWindowsActivationQueue) { + const targets = await invoke( + TAKE_PENDING_WINDOWS_NOTIFICATION_ACTIVATIONS, + ); + for (const pendingTarget of targets) { + const target = parseNotificationTarget(pendingTarget); + if (target) { + dispatchDesktopNotificationTarget(target); + } + } + return; + } + const target = parseNotificationTarget(payload); if (target) { dispatchDesktopNotificationTarget(target); @@ -295,12 +309,12 @@ export async function listenForDesktopNotificationActions( nativeUnlisten = null; } - if (nativeUnlisten && usesMacActivationQueue) { + if (nativeUnlisten && usesActivationQueue) { try { await dispatchNativeActivations(); } catch (error) { console.error( - "Failed to drain pending macOS notification activations", + "Failed to drain pending notification activations", error, ); } diff --git a/desktop/src/features/notifications/lib/notificationFormat.ts b/desktop/src/features/notifications/lib/notificationFormat.ts index 5f2297dc50e..04c951c33fc 100644 --- a/desktop/src/features/notifications/lib/notificationFormat.ts +++ b/desktop/src/features/notifications/lib/notificationFormat.ts @@ -53,10 +53,7 @@ export type MessageNotificationSource = | "approval" | "needs_action" | "dm" - | "thread_reply" - // A plain new message in a channel (WhatsApp-style: notify for everything in - // a channel until it's muted). - | "channel"; + | "thread_reply"; const MESSAGE_BODY_FALLBACKS: Record = { mention: "Something in Buzz needs your attention.", @@ -64,7 +61,6 @@ const MESSAGE_BODY_FALLBACKS: Record = { needs_action: "Something in Buzz needs your attention.", dm: "New message", thread_reply: "New reply", - channel: "New message", }; /** @@ -110,9 +106,7 @@ export function formatMessageNotification(opts: { ? senderName ? `${senderName} replied` : "Reply" - : source === "channel" - ? (senderName ?? "New message") - : (senderName ?? "Needs Action"); + : (senderName ?? "Needs Action"); return { title: formatNotificationTitle({ prefix, channelLabel }), body }; }