From 87a286a615f5930e96e8b0c4285c0cf4a2c29a69 Mon Sep 17 00:00:00 2001 From: ardevdevts Date: Sun, 13 Sep 2026 07:33:53 -0400 Subject: [PATCH 1/2] Add Windows toast notifications via WinRT --- Cargo.lock | 91 ++++++++++++++++--- src-tauri/Cargo.toml | 1 + src-tauri/src/notifications.rs | 161 ++++++++++++++++++++++++++++++++- src-tauri/src/reminders.rs | 2 +- src/surfaces/SettingsView.tsx | 6 +- 5 files changed, 239 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 750923ad..f8a7ee7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2236,6 +2236,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-updater", "tauri-plugin-window-state", + "tauri-winrt-notification 0.8.1", "ureq", "url", "uuid", @@ -2315,7 +2316,7 @@ dependencies = [ "log", "mac-notification-sys", "serde", - "tauri-winrt-notification", + "tauri-winrt-notification 0.7.3", "zbus", ] @@ -3841,7 +3842,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3924,7 +3925,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -4065,7 +4066,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.20", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -4149,7 +4150,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4174,7 +4175,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4234,7 +4235,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" dependencies = [ "thiserror 2.0.20", - "windows", + "windows 0.61.3", + "windows-version", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" +dependencies = [ + "thiserror 2.0.20", + "windows 0.62.2", "windows-version", ] @@ -4998,7 +5010,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -5022,7 +5034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.20", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -5078,11 +5090,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -5094,6 +5118,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -5128,7 +5161,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -5175,6 +5219,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5313,6 +5367,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -5553,7 +5616,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4e06d4cc..f01b691b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,6 +34,7 @@ libc = "0.2" [target.'cfg(windows)'.dependencies] portable-pty = { version = "0.9", path = "../vendor/portable-pty" } windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_LibraryLoader", "Win32_System_Diagnostics_ToolHelp"] } +tauri-winrt-notification = "0.8" [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6.2" diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index 7e771ccc..b0c60337 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -4,17 +4,19 @@ //! macOS goes through `UNUserNotificationCenter` directly: the app already //! links it for the Dock badge, it reports the real authorization state, and //! a delegate turns a click into a jump back to the session. Linux uses the -//! freedesktop notification bus, which has no permission model. +//! freedesktop notification bus, which has no permission model. Windows uses +//! the WinRT toast API via `tauri-winrt-notification`, which likewise has no +//! runtime permission prompt: toasts are controlled from Windows Settings. use serde::Serialize; use tauri::AppHandle; /// Emitted to every window when the user clicks a notification. Payload is /// the session id; the window that owns that session handles it. -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub const CLICK_EVENT: &str = "monocode:notification-click"; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] fn handle_click(app: &AppHandle, identifier: &str) { use tauri::Emitter; if let Some(reminder) = identifier.strip_prefix(crate::reminders::NOTIFICATION_PREFIX) { @@ -500,7 +502,158 @@ mod platform { } } -#[cfg(not(any(target_os = "macos", target_os = "linux")))] +#[cfg(target_os = "windows")] +mod platform { + use tauri::AppHandle; + + use super::{handle_click, Permission}; + + /// Action payload for the explicit "Show" button. The body click carries + /// no arguments, so the session id is also captured in the activation + /// closure; the payload is a fallback for routing, not the primary path. + /// Reminder identifiers (`reminder::`) flow through the + /// same path and are routed by `handle_click`. + const SHOW_ACTION_PREFIX: &str = "show:"; + + fn show_action(session_id: &str) -> String { + format!("{SHOW_ACTION_PREFIX}{session_id}") + } + + fn session_from_action(action: &str) -> Option<&str> { + action.strip_prefix(SHOW_ACTION_PREFIX) + } + + pub(super) async fn permission() -> Permission { + // WinRT has no runtime prompt; toasts are governed by the system + // Settings app (per-app toggle, Focus Assist). Report granted so the + // frontend policy allows dispatch; a rejected `show` still falls back + // to the in-app cue. + Permission::Granted + } + + pub(super) async fn request_permission() -> Permission { + Permission::Granted + } + + pub(super) async fn show( + app: &AppHandle, + session_id: &str, + title: &str, + subtitle: &str, + body: &str, + sound: bool, + ) -> Result<(), String> { + // `Toast` is `!Send`, so construct and dispatch it inside the blocking + // thread; only owned `Send` data crosses into the closure. + let app = app.clone(); + let app_id = app.config().identifier.clone(); + let session_id = session_id.to_string(); + let title = title.to_string(); + let subtitle = subtitle.to_string(); + let body = body.to_string(); + tauri::async_runtime::spawn_blocking(move || { + show_blocking(&app, &app_id, &session_id, &title, &subtitle, &body, sound) + }) + .await + .map_err(|err| err.to_string())? + } + + fn show_blocking( + app: &AppHandle, + app_id: &str, + session_id: &str, + title: &str, + subtitle: &str, + body: &str, + sound: bool, + ) -> Result<(), String> { + use tauri_winrt_notification::Toast; + + // Installed NSIS builds resolve the bundle identifier through the + // Start Menu shortcut's AppUserModelID. `tauri dev` has no shortcut, + // so fall back to the PowerShell host ID (wrong branding, but visible) + // when the real ID fails. + match show_with_app_id(app, app_id, session_id, title, subtitle, body, sound) { + Ok(()) => Ok(()), + Err(first) if app_id != Toast::POWERSHELL_APP_ID => show_with_app_id( + app, + Toast::POWERSHELL_APP_ID, + session_id, + title, + subtitle, + body, + sound, + ) + .map_err(|fallback| format!("{first}; dev fallback: {fallback}")), + Err(first) => Err(first), + } + } + + fn show_with_app_id( + app: &AppHandle, + app_id: &str, + session_id: &str, + title: &str, + subtitle: &str, + body: &str, + sound: bool, + ) -> Result<(), String> { + use tauri_winrt_notification::{Sound, Toast}; + + let app = app.clone(); + let owned_session = session_id.to_string(); + let sound = if sound { Some(Sound::Default) } else { None }; + // The default icon comes from the AppUserModelID registration; an + // explicit icon needs an absolute non-UNC path and is left out for v1. + Toast::new(app_id) + .title(title) + .text1(subtitle) + .text2(body) + .sound(sound) + .add_button("Show", &show_action(session_id)) + .on_activated(move |action| { + let session = action + .as_deref() + .and_then(session_from_action) + .unwrap_or(&owned_session) + .to_string(); + handle_click(&app, session.as_str()); + Ok(()) + }) + .show() + .map_err(|err| err.to_string()) + } + + pub(super) fn open_settings(_app: &AppHandle) -> Result<(), String> { + let mut cmd = std::process::Command::new("cmd"); + cmd.args(["/C", "start", "", "ms-settings:notifications"]); + crate::hide_window_console(&mut cmd); + cmd.spawn().map(|_| ()).map_err(|err| err.to_string()) + } + + #[cfg(test)] + mod tests { + use super::{session_from_action, show_action}; + + #[test] + fn show_action_round_trips_the_session() { + let action = show_action("549ae7ac"); + assert_eq!(session_from_action(&action), Some("549ae7ac")); + assert_eq!(session_from_action("other"), None); + } + + #[test] + fn show_action_preserves_reminder_identifiers() { + let action = show_action("reminder:549ae7ac:1700000000"); + assert_eq!( + session_from_action(&action), + Some("reminder:549ae7ac:1700000000") + ); + } + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] mod platform { use tauri::AppHandle; diff --git a/src-tauri/src/reminders.rs b/src-tauri/src/reminders.rs index 04290d54..27e6bba6 100644 --- a/src-tauri/src/reminders.rs +++ b/src-tauri/src/reminders.rs @@ -229,7 +229,7 @@ pub fn reminder_take_open( /// A notification can outlive its window. Keep the request until the chosen /// window has mounted and attached its listeners, then let only that window act. -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub(crate) fn open_from_notification(app: &AppHandle, identifier: &str) { let Some((session_id, due_at)) = identifier.rsplit_once(':') else { return; diff --git a/src/surfaces/SettingsView.tsx b/src/surfaces/SettingsView.tsx index b1767c1c..3364a176 100644 --- a/src/surfaces/SettingsView.tsx +++ b/src/surfaces/SettingsView.tsx @@ -127,7 +127,7 @@ import { subscribeModels, } from "../lib/models"; import { prettyCwd, projectKey, projectName } from "../lib/paths"; -import { IS_MAC } from "../lib/platform"; +import { IS_MAC, IS_WIN } from "../lib/platform"; import { loadArchivedProjects, looksLikeProject, @@ -2068,12 +2068,12 @@ function Slider({ ); } -/** macOS keeps the decision after the first prompt; only System Settings can flip it. */ +/** macOS keeps the decision after the first prompt; only System Settings can flip it. Windows toasts are governed by Settings > Notifications. */ function NotificationsBlocked() { return ( Permission needed - {IS_MAC ? ( + {IS_MAC || IS_WIN ? (