diff --git a/Cargo.lock b/Cargo.lock index 750923ad..a15c5c96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2236,9 +2236,11 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-updater", "tauri-plugin-window-state", + "tauri-winrt-notification 0.8.1", "ureq", "url", "uuid", + "windows 0.62.2", "windows-sys 0.61.2", ] @@ -2315,7 +2317,7 @@ dependencies = [ "log", "mac-notification-sys", "serde", - "tauri-winrt-notification", + "tauri-winrt-notification 0.7.3", "zbus", ] @@ -3841,7 +3843,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3924,7 +3926,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -4065,7 +4067,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.20", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -4149,7 +4151,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4174,7 +4176,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4234,7 +4236,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 +5011,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 +5035,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 +5091,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 +5119,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 +5162,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 +5220,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 +5368,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 +5617,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..709413e2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,6 +34,9 @@ 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" +# WinRT toast settings (Windows.UI.Notifications) to read the per-app block. +windows = { version = "0.62", features = ["UI_Notifications"] } [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6.2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index bbb6dd97..6d31db1a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,6 +9,8 @@ "core:menu:default", "core:window:allow-start-dragging", "core:window:allow-minimize", + "core:window:allow-unminimize", + "core:window:allow-set-focus", "core:window:allow-toggle-maximize", "core:window:allow-set-title", "core:window:allow-close", diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index 7e771ccc..8706e39a 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) { @@ -43,13 +45,13 @@ pub enum Permission { } #[tauri::command] -pub async fn notification_permission() -> Permission { - platform::permission().await +pub async fn notification_permission(app: AppHandle) -> Permission { + platform::permission(&app).await } #[tauri::command] -pub async fn request_notification_permission() -> Permission { - platform::request_permission().await +pub async fn request_notification_permission(app: AppHandle) -> Permission { + platform::request_permission(&app).await } /// Resolves only once the platform reports the banner as scheduled: the @@ -197,15 +199,15 @@ mod platform { .flatten() } - pub(super) async fn permission() -> Permission { + pub(super) async fn permission(_app: &AppHandle) -> Permission { wait(query_permission(), None) .await .unwrap_or(Permission::Denied) } - pub(super) async fn request_permission() -> Permission { + pub(super) async fn request_permission(app: &AppHandle) -> Permission { wait(start_request(), None).await; - permission().await + permission(app).await } /// Hands the request to the center and reports what its completion @@ -426,11 +428,11 @@ mod platform { use super::Permission; - pub(super) async fn permission() -> Permission { + pub(super) async fn permission(_app: &AppHandle) -> Permission { Permission::Granted } - pub(super) async fn request_permission() -> Permission { + pub(super) async fn request_permission(_app: &AppHandle) -> Permission { Permission::Granted } @@ -500,17 +502,237 @@ 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:"; + + /// Wraps the session id in the "Show" button's activation payload. + fn show_action(session_id: &str) -> String { + format!("{SHOW_ACTION_PREFIX}{session_id}") + } + + /// Reads the session id back out of a "Show" button payload. + fn session_from_action(action: &str) -> Option<&str> { + action.strip_prefix(SHOW_ACTION_PREFIX) + } + + /// Collapses the WinRT setting onto the frontend's decision. Only + /// `Enabled` is unblocked; `None` is "could not ask" and defers to the + /// dispatch. + fn blocked_from_setting( + setting: Option, + ) -> Option { + use windows::UI::Notifications::NotificationSetting; + match setting { + Some(NotificationSetting::Enabled) => Some(false), + // DisabledForApplication / ForUser / ByGroupPolicy / ByManifest. + Some(_) => Some(true), + None => None, + } + } + + /// Whether Windows blocks toasts for this AppUserModelID. `None` means the + /// system could not be asked — an unknown ID (`tauri dev` before the + /// shortcut exists) or a WinRT failure — and the dispatch itself decides. + fn toasts_blocked(app_id: &str) -> Option { + use windows::core::HSTRING; + use windows::UI::Notifications::ToastNotificationManager; + let notifier = + ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(app_id)).ok()?; + blocked_from_setting(notifier.Setting().ok()) + } + + /// Asks the toast system, which reflects the per-app toggle, the user-wide + /// switch and group policy. There is no prompt to show, so a request just + /// re-reads the same state. + pub(super) async fn permission(app: &AppHandle) -> Permission { + let app_id = app.config().identifier.clone(); + let blocked = tauri::async_runtime::spawn_blocking(move || toasts_blocked(&app_id)) + .await + .ok() + .flatten(); + if blocked == Some(true) { + Permission::Denied + } else { + Permission::Granted + } + } + + pub(super) async fn request_permission(app: &AppHandle) -> Permission { + // Windows has no authorization dialog; Settings owns the decision. + permission(app).await + } + + 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())? + } + + /// Dispatches on the blocking thread a `Toast` needs, honouring a Windows + /// block first so the caller's in-app cue stands in when no banner shows. + 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; + + if toasts_blocked(app_id) == Some(true) { + return Err("notifications are disabled in Windows settings".into()); + } + + // 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), + } + } + + /// Builds and shows one toast under `app_id`, wiring the "Show" button to + /// the shared click router. + 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()) + } + + /// Opens Settings > System > Notifications, where the per-app toggle lives. + 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 windows::UI::Notifications::NotificationSetting; + + use super::{blocked_from_setting, 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") + ); + } + + #[test] + fn only_an_enabled_setting_is_unblocked() { + assert_eq!( + blocked_from_setting(Some(NotificationSetting::Enabled)), + Some(false) + ); + assert_eq!( + blocked_from_setting(Some(NotificationSetting::DisabledForApplication)), + Some(true) + ); + assert_eq!( + blocked_from_setting(Some(NotificationSetting::DisabledForUser)), + Some(true) + ); + assert_eq!( + blocked_from_setting(Some(NotificationSetting::DisabledByGroupPolicy)), + Some(true) + ); + assert_eq!(blocked_from_setting(None), None); + } + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] mod platform { use tauri::AppHandle; use super::Permission; - pub(super) async fn permission() -> Permission { + pub(super) async fn permission(_app: &AppHandle) -> Permission { Permission::Unsupported } - pub(super) async fn request_permission() -> Permission { + pub(super) async fn request_permission(_app: &AppHandle) -> Permission { Permission::Unsupported } 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/App.tsx b/src/App.tsx index 0526ce4c..f5136fae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6608,7 +6608,12 @@ export default function App({ // Every window hears the click; only the one holding the session acts. listen(NOTIFICATION_CLICK_EVENT, ({ payload: sessionId }) => { if (!sessionsRef.current.some((s) => s.id === sessionId)) return; - void getCurrentWindow().setFocus(); + const win = getCurrentWindow(); + // Windows leaves a minimized window minimized when it is only focused. + void win + .unminimize() + .then(() => win.setFocus()) + .catch(() => {}); actions.current.onOpenApprovalSession(sessionId); }), listen("zoom_in", () => { 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 ? (