diff --git a/Cargo.lock b/Cargo.lock index fed0f874..8739be01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -812,6 +812,7 @@ dependencies = [ "unicode-segmentation", "unicode-width", "widestring", + "windows", "windows-sys", "wmi", "zbus", diff --git a/Cargo.toml b/Cargo.toml index c6731424..f5810a3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,10 +65,13 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] } # UTF-16 SDDL input for interprocess security descriptors. widestring = "1.2" wmi = { version = "0.18.4", default-features = false } +# Native Notification Center delivery for unpackaged Windows installs. +windows = { version = "0.62.2", features = ["Data_Xml_Dom", "UI_Notifications", "Win32_System_Com"] } windows-sys = { version = "0.61.2", features = [ "Wdk_System_Threading", "Win32_Foundation", "Win32_Globalization", + "Win32_Graphics_Gdi", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_DataExchange", @@ -78,8 +81,10 @@ windows-sys = { version = "0.61.2", features = [ "Win32_System_JobObjects", "Win32_System_Kernel", "Win32_System_Memory", + "Win32_System_LibraryLoader", "Win32_System_Ole", "Win32_System_Pipes", + "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_UI_Input_Ime", diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 9188dbb5..874fe56c 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -496,6 +496,8 @@ Choose `herdr` for an in-app toast, `terminal` for an outer-terminal notificatio On macOS, `system` tries `terminal-notifier` first and falls back to `/usr/bin/osascript` when it is unavailable or fails. The fallback appears as Script Editor in Notification Center and cannot activate the hosting terminal. Install `terminal-notifier` with `brew install terminal-notifier`. For a supported, detected terminal, it can activate the terminal app when you click the notification. Alternatively, choose `terminal` to let a supported outer terminal own the notification. +On Windows, `system` notifications remain in Notification Center. Clicking one selects its originating pane in the running Herdr client and brings its terminal window forward when Windows exposes that window. A click cannot reopen a closed client or select a pane from a restarted server. Windows Terminal can bring the owning window forward, but cannot reliably select a different outer terminal tab. + ## Sound Sound notifications play through the local Herdr client. Custom sounds must be mp3 files; Herdr resolves relative paths from the config file's directory. diff --git a/src/client/endpoint/activation.rs b/src/client/endpoint/activation.rs index 5ab9dfa8..3314552a 100644 --- a/src/client/endpoint/activation.rs +++ b/src/client/endpoint/activation.rs @@ -592,6 +592,11 @@ impl PendingEndpointActivation { focus: Option, endpoints: &mut EndpointRegistry, ) -> Result<(), String> { + #[cfg(windows)] + if matches!(&focus, Some(crate::client::shell::ClientEndpointFocusTarget::Notification { boot_id, .. }) if boot_id != &self.target.boot_id) + { + return Ok(()); + } self.focus = focus; if let ActivationPhase::ActivatingTarget { focus_request_id, @@ -1184,6 +1189,16 @@ impl PendingEndpointActivation { _ => return false, }; match &self.focus { + #[cfg(windows)] + Some(crate::client::shell::ClientEndpointFocusTarget::Notification { + pane_id, .. + }) => { + evidence.focused_pane_id.as_deref() == Some(pane_id) + && surface + .panes + .iter() + .any(|pane| pane.focused && &pane.pane_id == pane_id) + } Some(crate::client::shell::ClientEndpointFocusTarget::Pane(pane_id)) => { evidence.focused_pane_id.as_deref() == Some(pane_id) && surface diff --git a/src/client/endpoint/activation/protocol.rs b/src/client/endpoint/activation/protocol.rs index 569d53f3..707dd0e3 100644 --- a/src/client/endpoint/activation/protocol.rs +++ b/src/client/endpoint/activation/protocol.rs @@ -10,6 +10,14 @@ pub(super) fn focus_result_matches( Some(crate::client::shell::ClientEndpointFocusTarget::Pane(expected)), crate::api::schema::ResponseResult::PaneInfo { pane }, ) => pane.focused && &pane.pane_id == expected, + #[cfg(windows)] + ( + Some(crate::client::shell::ClientEndpointFocusTarget::Notification { + pane_id: expected, + .. + }), + crate::api::schema::ResponseResult::PaneInfo { pane }, + ) => pane.focused && &pane.pane_id == expected, ( Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(expected)), crate::api::schema::ResponseResult::WorkspaceInfo { workspace }, @@ -166,6 +174,21 @@ pub(super) fn focus_request( focus: &crate::client::shell::ClientEndpointFocusTarget, ) -> std::io::Result { let method = match focus { + #[cfg(windows)] + crate::client::shell::ClientEndpointFocusTarget::Notification { + pane_id, + boot_id: expected, + } => { + if boot_id != expected { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "notification target restarted", + )); + } + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: pane_id.clone(), + }) + } crate::client::shell::ClientEndpointFocusTarget::Workspace(workspace_id) => { crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { workspace_id: workspace_id.clone(), diff --git a/src/client/endpoint/activation_tests.rs b/src/client/endpoint/activation_tests.rs index 7201940a..532342e7 100644 --- a/src/client/endpoint/activation_tests.rs +++ b/src/client/endpoint/activation_tests.rs @@ -1370,6 +1370,98 @@ fn newer_remote_selection_cancels_deferred_local_selection() { assert_eq!(pending.as_ref().unwrap().target(), &endpoint()); } +#[cfg(windows)] +#[test] +fn notification_successor_replay_rejects_a_restarted_destination() { + use crate::client::shell::ClientEndpointFocusTarget; + use crate::client::{ + endpoint_commands::EndpointCommands, shell_runtime::begin_endpoint_activation, ClientState, + }; + for destination_boot in ["local-boot", "replacement-boot"] { + let (shell, mut endpoints, local_sent, remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 70, + Instant::now(), + ) + .unwrap(); + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:70:off", + &surface_success("client-shell-surface:70:off", false, 1), + &mut endpoints, + ); + activation.supersede( + ClientEndpointId::Local, + Some(ClientEndpointFocusTarget::Notification { + pane_id: "pane_1".into(), + boot_id: "local-boot".into(), + }), + &mut endpoints, + ); + let intent = activation + .successor + .take() + .expect("notification queued during handoff"); + let mut state = ClientState::test_new(); + state.shell = Some(shell); + let mut snapshot = test_snapshot(destination_boot, 2); + snapshot.panes.push(crate::protocol::ClientShellPane { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + label: None, + cwd: None, + foreground_cwd: None, + focused: false, + right_click_passthrough: false, + }); + state + .shell + .as_mut() + .unwrap() + .set_snapshot(Box::new(snapshot)); + // Model restoration completing before the retained intent is replayed. + endpoints.set_surface_active(&ClientEndpointId::Local, true); + endpoints.unfreeze_input(); + local_sent.lock().unwrap().clear(); + remote_sent.lock().unwrap().clear(); + let mut pending = None; + let mut commands = EndpointCommands::default(); + begin_endpoint_activation( + &mut state, + &mut endpoints, + &mut commands, + &mut pending, + &mut 71, + intent.endpoint_id, + intent.target, + true, + Instant::now(), + &mut None, + ) + .unwrap(); + if destination_boot == "replacement-boot" { + assert!(pending.is_none()); + assert!( + local_sent.lock().unwrap().is_empty(), + "stale successor must emit no navigation or surface write" + ); + assert!(remote_sent.lock().unwrap().is_empty()); + } else { + assert!( + pending.is_some(), + "same boot remains eligible after handoff" + ); + } + } +} + #[test] fn rollback_keeps_the_latest_intent_even_when_it_returns_to_the_target() { let (shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry(); @@ -1423,6 +1515,101 @@ fn rollback_keeps_the_latest_intent_even_when_it_returns_to_the_target() { ); } +#[cfg(windows)] +#[test] +fn notification_click_on_remote_source_supersedes_a_handoff() { + use crate::client::shell::{ClientEndpointFocusTarget, ClientSystemNotificationTarget}; + use crate::client::{ + endpoint_commands::EndpointCommands, + events::ClientLoopEvent, + shell_runtime::{begin_endpoint_activation, dispatch_client_shell_actions}, + ClientState, + }; + let (mut shell, mut endpoints, _, _) = shell_and_registry(); + let remote = endpoint(); + let mut snapshot = test_snapshot("remote-boot", 2); + snapshot.panes.push(crate::protocol::ClientShellPane { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + label: None, + cwd: None, + foreground_cwd: None, + focused: false, + right_click_passthrough: false, + }); + shell.set_endpoint_snapshot(&remote, Box::new(snapshot)); + endpoints.set_surface_active(&ClientEndpointId::Local, false); + endpoints.set_surface_active(&remote, true); + assert!(endpoints.set_active(&remote)); + assert!(shell.activate_endpoint_projection(&remote)); + let mut pending = Some( + PendingEndpointActivation::begin( + &shell, + &mut endpoints, + ClientEndpointId::Local, + None, + resize(), + 80, + Instant::now(), + ) + .unwrap(), + ); + assert!( + !endpoints.active_surface_available(), + "source is inactive during handoff" + ); + let outcome = shell.activate_system_notification(ClientSystemNotificationTarget { + endpoint_id: remote.clone(), + boot_id: "remote-boot".into(), + pane_id: "pane_1".into(), + }); + let mut commands = EndpointCommands::default(); + let mut scheduled = None; + dispatch_client_shell_actions( + outcome.actions, + &mut commands, + &mut endpoints, + Some(&mut shell), + &mut Vec::new(), + &mut scheduled, + ) + .unwrap(); + let ClientLoopEvent::ActivateEndpoint { + endpoint_id, + target, + force, + } = scheduled.expect("click must reach the handoff manager") + else { + panic!("activation expected"); + }; + let mut state = ClientState::test_new(); + state.shell = Some(shell); + begin_endpoint_activation( + &mut state, + &mut endpoints, + &mut commands, + &mut pending, + &mut 81, + endpoint_id, + target, + force, + Instant::now(), + &mut None, + ) + .unwrap(); + assert_eq!( + pending.unwrap().successor, + Some(EndpointActivationIntent { + endpoint_id: remote, + target: Some(ClientEndpointFocusTarget::Notification { + pane_id: "pane_1".into(), + boot_id: "remote-boot".into() + }), + }) + ); +} + #[test] fn unacknowledged_target_release_closes_target_before_restoring_source() { let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry(); diff --git a/src/client/events.rs b/src/client/events.rs index 43f7b8ad..dc4415bd 100644 --- a/src/client/events.rs +++ b/src/client/events.rs @@ -10,6 +10,8 @@ pub(super) enum ClientLoopEvent { DirectGraphicsResponse(direct_graphics::Response), #[cfg(windows)] StdinEvents(Vec), + #[cfg(windows)] + NotificationActivated(shell::ClientSystemNotificationTarget), Resize(u16, u16, u32, u32, bool), TerminalUnavailable(io::Error), ServerMessage { diff --git a/src/client/mod.rs b/src/client/mod.rs index d55e4c48..2668fd49 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1178,6 +1178,31 @@ async fn run_client_loop( } // Direct terminal attach is Unix-only; every Windows client uses ClientShell. } + #[cfg(windows)] + ClientLoopEvent::NotificationActivated(target) => { + if let Some(shell) = state.shell.as_mut() { + let outcome = shell.activate_system_notification(target); + if !outcome.actions.is_empty() { + crate::platform::foreground_desktop_notification_host(); + } + let frame = outcome + .repaint + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten(); + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + &mut prefix_input_source, + &mut scheduled_activation, + )? { + return Ok(()); + } + } + } ClientLoopEvent::TerminalUnavailable(err) => { info!(err = %err, "client terminal unavailable; detaching"); let _ = write_to_server(&mut write_stream, &ClientMessage::Detach); @@ -1744,7 +1769,12 @@ async fn run_client_loop( .flatten(); (effects, frame) }; - handle_shell_notification_effects(effects, &state.sound_config); + handle_shell_notification_effects( + effects, + &state.sound_config, + #[cfg(windows)] + &event_tx, + ); if let Some(frame) = frame { state.present_frame(frame); } @@ -2257,7 +2287,12 @@ async fn run_client_loop( .flatten(); (effects, outcome, frame) }; - handle_shell_notification_effects(effects, &state.sound_config); + handle_shell_notification_effects( + effects, + &state.sound_config, + #[cfg(windows)] + &event_tx, + ); if finish_client_shell_input( &mut state, outcome, diff --git a/src/client/notifications.rs b/src/client/notifications.rs index 7c97742e..0b87f2db 100644 --- a/src/client/notifications.rs +++ b/src/client/notifications.rs @@ -6,9 +6,53 @@ use crate::protocol::NotifyKind; use super::shell; +#[cfg(not(windows))] +use crate::platform::show_desktop_notification as show_untargeted_system_notification; + +#[cfg(windows)] +fn queue_system_notification( + task: impl FnOnce() -> io::Result + Send + 'static, +) -> io::Result { + type Task = Box io::Result + Send>; + static QUEUE: std::sync::OnceLock> = + std::sync::OnceLock::new(); + let queue = QUEUE.get_or_init(|| { + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::(); + // Preserve replacement order while keeping native waits off the client loop. + tokio::spawn(async move { + while let Some(task) = receiver.recv().await { + let result = tokio::task::spawn_blocking(task) + .await + .unwrap_or_else(|err| Err(io::Error::other(err))); + if let Err(err) = result { + warn!(err = %err, "failed to emit system notification"); + } + } + }); + sender + }); + queue.send(Box::new(task)).map_err(|_| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "notification delivery queue closed", + ) + })?; + Ok(true) +} + +#[cfg(windows)] +fn show_untargeted_system_notification(title: &str, body: Option<&str>) -> io::Result { + let title = title.to_owned(); + let body = body.map(str::to_owned); + queue_system_notification(move || { + crate::platform::show_desktop_notification(&title, body.as_deref()) + }) +} + pub(super) fn handle_shell_notification_effects( effects: Vec, sound_config: &crate::config::SoundConfig, + #[cfg(windows)] event_tx: &tokio::sync::mpsc::Sender, ) { for effect in effects { match effect { @@ -24,10 +68,17 @@ pub(super) fn handle_shell_notification_effects( warn!(err = %err, "failed to emit terminal notification"); } } - shell::ClientShellNotificationEffect::System { title, body } => { - if let Err(err) = - crate::platform::show_desktop_notification(&title, body.as_deref()) - { + shell::ClientShellNotificationEffect::System { + title, + body, + #[cfg(windows)] + target, + } => { + #[cfg(windows)] + let result = show_system_notification(&title, body.as_deref(), target, event_tx); + #[cfg(not(windows))] + let result = crate::platform::show_desktop_notification(&title, body.as_deref()); + if let Err(err) = result { warn!(err = %err, "failed to emit system notification"); } } @@ -35,6 +86,47 @@ pub(super) fn handle_shell_notification_effects( } } +#[cfg(windows)] +fn show_system_notification( + title: &str, + body: Option<&str>, + target: Option, + event_tx: &tokio::sync::mpsc::Sender, +) -> io::Result { + let Some(target) = target else { + return show_untargeted_system_notification(title, body); + }; + let key = serde_json::to_string(&( + &target.endpoint_id.storage_key(), + &target.boot_id, + &target.pane_id, + )) + .map_err(io::Error::other)?; + let event_tx = event_tx.clone(); + let runtime = tokio::runtime::Handle::current(); + let title = title.to_owned(); + let body = body.map(str::to_owned); + queue_system_notification(move || { + crate::platform::show_actionable_desktop_notification( + &title, + body.as_deref(), + key, + std::sync::Arc::new(move || { + let event_tx = event_tx.clone(); + let target = target.clone(); + // Never block a native callback: WinRT can dispatch it while Show is awaited. + runtime.spawn(async move { + let _ = event_tx + .send(super::events::ClientLoopEvent::NotificationActivated( + target, + )) + .await; + }); + }), + ) + }) +} + pub(super) fn handle_notify( kind: NotifyKind, message: &str, @@ -47,7 +139,7 @@ pub(super) fn handle_notify( body, sound_config, crate::terminal_notify::show_notification, - crate::platform::show_desktop_notification, + show_untargeted_system_notification, ); } @@ -100,3 +192,36 @@ pub(super) fn sound_from_notify_message(message: &str) -> Option None, } } + +#[cfg(all(test, windows))] +mod tests { + use super::*; + + #[tokio::test(flavor = "current_thread")] + async fn native_delivery_keeps_client_responsive_and_preserves_replacement_order() { + let (started, ready) = tokio::sync::oneshot::channel(); + let (release, wait) = std::sync::mpsc::channel(); + queue_system_notification(move || { + let _ = started.send(()); + wait.recv_timeout(std::time::Duration::from_secs(5)) + .map_err(io::Error::other)?; + Ok(true) + }) + .expect("queued first notification"); + ready.await.expect("notification worker started"); + let (finished, mut second) = tokio::sync::oneshot::channel(); + queue_system_notification(move || { + let _ = finished.send(()); + Ok(true) + }) + .expect("queued replacement"); + tokio::task::yield_now().await; + assert_eq!( + second.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + ); + // This send must run while native delivery is still waiting. + release.send(()).expect("client loop remained responsive"); + second.await.expect("replacement delivered after original"); + } +} diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index 25878cee..ed79409d 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -439,6 +439,10 @@ impl ClientShellState { &mut self, target: ClientEndpointFocusTarget, ) -> Vec { + #[cfg(windows)] + if !self.notification_target_is_current(&self.active_endpoint_id, &target) { + return Vec::new(); + } let method = match target { ClientEndpointFocusTarget::Workspace(workspace_id) => { crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { @@ -451,6 +455,10 @@ impl ClientShellState { ClientEndpointFocusTarget::Pane(pane_id) => { crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }) } + #[cfg(windows)] + ClientEndpointFocusTarget::Notification { pane_id, .. } => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }) + } }; let mut outcome = ClientShellInput::default(); self.push_endpoint_method(method, &mut outcome); diff --git a/src/client/shell/endpoint_navigation.rs b/src/client/shell/endpoint_navigation.rs index a1f7ac2c..3d99e5a6 100644 --- a/src/client/shell/endpoint_navigation.rs +++ b/src/client/shell/endpoint_navigation.rs @@ -256,6 +256,10 @@ impl ClientShellState { target: ClientEndpointFocusTarget, outcome: &mut ClientShellInput, ) -> bool { + #[cfg(windows)] + if !self.notification_target_is_current(&endpoint_id, &target) { + return false; + } self.pending_workspace_highlight = None; self.pending_agent_reveal = None; let online = self.endpoint_is_online(&endpoint_id); @@ -284,6 +288,12 @@ impl ClientShellState { pane_id, }) } + #[cfg(windows)] + ClientEndpointFocusTarget::Notification { pane_id, .. } => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id, + }) + } }; self.push_endpoint_method(method, outcome); } else { diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs index fff28b71..9e71d00a 100644 --- a/src/client/shell/endpoints.rs +++ b/src/client/shell/endpoints.rs @@ -36,6 +36,11 @@ pub(crate) enum ClientEndpointFocusTarget { Workspace(String), Tab(String), Pane(String), + #[cfg(windows)] + Notification { + pane_id: String, + boot_id: String, + }, } impl ClientShellState { diff --git a/src/client/shell/notification_policy.rs b/src/client/shell/notification_policy.rs index f3eeeb3c..10ab3d4b 100644 --- a/src/client/shell/notification_policy.rs +++ b/src/client/shell/notification_policy.rs @@ -229,9 +229,21 @@ impl ClientShellState { }); } crate::config::ToastDelivery::System if !suppress_external => { + #[cfg(windows)] + let target = pending.event.pane_id.as_ref().and_then(|pane_id| { + self.endpoint_boot_id(&pending.endpoint_id).map(|boot_id| { + ClientSystemNotificationTarget { + endpoint_id: pending.endpoint_id.clone(), + boot_id: boot_id.to_owned(), + pane_id: pane_id.clone(), + } + }) + }); effects.push(ClientShellNotificationEffect::System { title: pending.event.title, body: pending.event.body, + #[cfg(windows)] + target, }); } crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {} @@ -240,6 +252,46 @@ impl ClientShellState { (effects, repaint) } + #[cfg(windows)] + pub(crate) fn notification_target_is_current( + &self, + endpoint_id: &ClientEndpointId, + target: &ClientEndpointFocusTarget, + ) -> bool { + let ClientEndpointFocusTarget::Notification { pane_id, boot_id } = target else { + return true; + }; + self.endpoint_is_online(endpoint_id) + && self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.as_deref()) + .is_some_and(|snapshot| { + snapshot.boot_id == *boot_id + && snapshot.panes.iter().any(|pane| pane.pane_id == *pane_id) + }) + } + + #[cfg(windows)] + pub(crate) fn activate_system_notification( + &mut self, + target: ClientSystemNotificationTarget, + ) -> ClientShellInput { + let focus = ClientEndpointFocusTarget::Notification { + pane_id: target.pane_id, + boot_id: target.boot_id, + }; + let mut outcome = ClientShellInput::default(); + if self.notification_target_is_current(&target.endpoint_id, &focus) { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id: target.endpoint_id, + target: Some(focus), + }); + } + outcome + } + fn notification_target_is_active( &self, endpoint_id: &ClientEndpointId, diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 8c1d6f34..9c69fbe5 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -721,9 +721,19 @@ pub(crate) enum ClientShellNotificationEffect { System { title: String, body: Option, + #[cfg(windows)] + target: Option, }, } +#[cfg(windows)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ClientSystemNotificationTarget { + pub(crate) endpoint_id: ClientEndpointId, + pub(crate) boot_id: String, + pub(crate) pane_id: String, +} + pub(super) struct ClientPendingNotification { pub(super) endpoint_id: ClientEndpointId, pub(super) event: SemanticNotification, diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index f327f8c1..9cc70a90 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -73,6 +73,105 @@ fn state_with_remote() -> (ClientShellState, ClientEndpointId) { (state, endpoint_id) } +#[cfg(windows)] +#[test] +fn system_notification_clicks_keep_endpoint_and_boot_identity() { + let (mut state, remote) = state_with_remote(); + state.config.toast_delivery = crate::config::ToastDelivery::System; + state.config.toast_delay_seconds = 0; + state.outer_focused = Some(false); + for endpoint_id in [ClientEndpointId::Local, remote.clone()] { + let (effects, _) = state.receive_notification( + &endpoint_id, + SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: "test".into(), + body: None, + sound: None, + agent: None, + workspace_id: Some("ws_1".into()), + tab_id: Some("tab_1".into()), + pane_id: Some("pane_1".into()), + position: None, + }, + std::time::Instant::now(), + ); + let [ClientShellNotificationEffect::System { + target: Some(target), + .. + }] = effects.as_slice() + else { + panic!("system effect must retain notification target"); + }; + let target = target.clone(); + assert_eq!(target.endpoint_id, endpoint_id); + let outcome = state.activate_system_notification(target.clone()); + assert!( + !outcome.actions.is_empty(), + "a current target must navigate" + ); + if endpoint_id == remote { + assert!( + matches!(&outcome.actions[..], [ClientShellAction::ActivateEndpoint { + endpoint_id: id, target: Some(ClientEndpointFocusTarget::Notification { pane_id, boot_id }), + }] if id == &remote && pane_id == "pane_1" && boot_id == "remote-boot") + ); + } + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting); + assert!(state + .activate_system_notification(target.clone()) + .actions + .is_empty()); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + assert!( + !state + .activate_system_notification(target.clone()) + .actions + .is_empty(), + "same-boot reconnect remains valid" + ); + let endpoint = state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .unwrap(); + let snapshot = endpoint.snapshot.as_mut().unwrap(); + snapshot.boot_id = "replacement-boot".into(); + assert!( + state + .activate_system_notification(target.clone()) + .actions + .is_empty(), + "same pane ID from another boot must not navigate" + ); + let endpoint = state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .unwrap(); + let snapshot = endpoint.snapshot.as_mut().unwrap(); + snapshot.boot_id = target.boot_id.clone(); + snapshot.panes.clear(); + assert!( + state + .activate_system_notification(target.clone()) + .actions + .is_empty(), + "closed pane must not navigate" + ); + if endpoint_id == remote { + state.set_endpoint_catalog(&[]); + assert!( + state + .activate_system_notification(target) + .actions + .is_empty(), + "removed profile must not navigate" + ); + } + } +} + #[test] fn machine_diagnostic_badge_reopens_notice_without_collapsing_machine() { let (mut state, id) = state_with_remote(); diff --git a/src/client/shell_runtime.rs b/src/client/shell_runtime.rs index 5e1da58c..1ab3e287 100644 --- a/src/client/shell_runtime.rs +++ b/src/client/shell_runtime.rs @@ -216,6 +216,15 @@ pub(super) fn begin_endpoint_activation( now: std::time::Instant, scheduled_activation: &mut Option, ) -> Result<(), ClientError> { + #[cfg(windows)] + if target.as_ref().is_some_and(|target| { + !state + .shell + .as_ref() + .is_some_and(|shell| shell.notification_target_is_current(&endpoint_id, target)) + }) { + return Ok(()); + } state.deferred_local_activation = None; if endpoint_id.is_local() && !local_activation_metadata_ready(state, endpoints) { state.deferred_local_activation = Some(endpoint::EndpointActivationIntent { diff --git a/src/main.rs b/src/main.rs index 45a64095..b5c92ec1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -512,6 +512,10 @@ fn main() -> io::Result<()> { std::process::exit(2); } }; + #[cfg(windows)] + if let Some(result) = platform::maybe_activate_desktop_notification(&raw_args) { + return result; + } if let Some(outcome) = cli::maybe_run_machine(&raw_args) { return finish_cli(outcome); } diff --git a/src/platform/windows.rs b/src/platform/windows.rs index b82aac6c..b8e542e5 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -15,6 +15,11 @@ use std::{ mod clipboard_image; mod config_backup; +mod notifications; +pub(crate) use notifications::{ + foreground_desktop_notification_host, maybe_activate_desktop_notification, + show_actionable_desktop_notification, show_desktop_notification, +}; pub(crate) fn probe_local_server(path: &std::path::Path) -> std::io::Result<()> { use interprocess::os::windows::named_pipe::{pipe_mode::Bytes, DuplexPipeStream}; @@ -446,13 +451,10 @@ use windows_sys::{ KEYBDINPUT, KEYEVENTF_KEYUP, }, }, - Shell::{ - CommandLineToArgvW, ShellExecuteW, Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_TIP, - NIIF_INFO, NIIF_NOSOUND, NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, - }, + Shell::{CommandLineToArgvW, ShellExecuteW}, WindowsAndMessaging::{ - CreateWindowExW, DestroyWindow, GetForegroundWindow, GetWindowThreadProcessId, - LoadIconW, SendMessageTimeoutW, IDI_APPLICATION, SMTO_ABORTIFHUNG, WM_IME_CONTROL, + GetForegroundWindow, GetWindowThreadProcessId, SendMessageTimeoutW, + SMTO_ABORTIFHUNG, WM_IME_CONTROL, }, }, }, @@ -2659,112 +2661,6 @@ fn clipboard_global_bytes(format: u32, max_bytes: usize) -> Option> { Some(bytes) } -pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result { - let title = title.to_owned(); - let body = body.unwrap_or(&title).to_owned(); - let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1); - std::thread::Builder::new() - .name("herdr-windows-notification".into()) - .spawn(move || show_desktop_notification_on_thread(&title, &body, ready_tx))?; - ready_rx - .recv_timeout(Duration::from_secs(2)) - .map_err(|err| match err { - std::sync::mpsc::RecvTimeoutError::Timeout => std::io::Error::new( - std::io::ErrorKind::TimedOut, - "Windows notification setup timed out", - ), - std::sync::mpsc::RecvTimeoutError::Disconnected => std::io::Error::other( - "Windows notification thread exited before reporting readiness", - ), - })? -} - -fn show_desktop_notification_on_thread( - title: &str, - body: &str, - ready_tx: std::sync::mpsc::SyncSender>, -) { - let class_name = wide_null("STATIC"); - let window_name = wide_null("Herdr notifications"); - let hwnd = unsafe { - CreateWindowExW( - 0, - class_name.as_ptr(), - window_name.as_ptr(), - 0, - 0, - 0, - 0, - 0, - null_mut(), - null_mut(), - null_mut(), - std::ptr::null(), - ) - }; - if hwnd.is_null() { - let _ = ready_tx.send(Err(std::io::Error::last_os_error())); - return; - } - - let mut notification = unsafe { std::mem::zeroed::() }; - notification.cbSize = size_of::() as u32; - notification.hWnd = hwnd; - notification.uID = 1; - notification.hIcon = unsafe { LoadIconW(null_mut(), IDI_APPLICATION) }; - notification.uFlags = NIF_TIP; - if !notification.hIcon.is_null() { - notification.uFlags |= NIF_ICON; - } - copy_wide_truncated(&mut notification.szTip, "Herdr"); - - if unsafe { Shell_NotifyIconW(NIM_ADD, ¬ification) } == 0 { - let _ = ready_tx.send(Err(std::io::Error::other( - "failed to add Herdr notification-area icon", - ))); - unsafe { - DestroyWindow(hwnd); - } - return; - } - - notification.uFlags = NIF_INFO; - notification.dwInfoFlags = NIIF_INFO | NIIF_NOSOUND; - copy_wide_truncated(&mut notification.szInfoTitle, title); - copy_wide_truncated(&mut notification.szInfo, body); - if unsafe { Shell_NotifyIconW(NIM_MODIFY, ¬ification) } == 0 { - unsafe { - Shell_NotifyIconW(NIM_DELETE, ¬ification); - DestroyWindow(hwnd); - } - let _ = ready_tx.send(Err(std::io::Error::other( - "failed to show Herdr desktop notification", - ))); - return; - } - - let _ = ready_tx.send(Ok(true)); - std::thread::sleep(Duration::from_secs(10)); - unsafe { - Shell_NotifyIconW(NIM_DELETE, ¬ification); - DestroyWindow(hwnd); - } -} - -fn copy_wide_truncated(destination: &mut [u16; N], value: &str) { - destination.fill(0); - let mut offset = 0; - for ch in value.chars() { - let mut units = [0; 2]; - let encoded = ch.encode_utf16(&mut units); - if offset + encoded.len() >= N { - break; - } - destination[offset..offset + encoded.len()].copy_from_slice(encoded); - offset += encoded.len(); - } -} - fn wide_null(value: &str) -> Vec { value.encode_utf16().chain(std::iter::once(0)).collect() } @@ -3546,15 +3442,6 @@ mod tests { ); } - #[test] - fn windows_notification_text_is_null_terminated_and_unicode_safe() { - let mut destination = [u16::MAX; 6]; - super::copy_wide_truncated(&mut destination, "abc😀def"); - - assert_eq!(String::from_utf16(&destination[..5]).unwrap(), "abc😀"); - assert_eq!(destination[5], 0); - } - #[test] fn powershell_agent_command_omits_argument_list_when_no_arguments_are_passed() { let argv = vec!["opencode".into()]; diff --git a/src/platform/windows/notifications.rs b/src/platform/windows/notifications.rs new file mode 100644 index 00000000..a8b318d8 --- /dev/null +++ b/src/platform/windows/notifications.rs @@ -0,0 +1,533 @@ +use std::{ + collections::HashMap, + io, + ptr::null_mut, + sync::{mpsc, Arc, LazyLock, Mutex}, + time::Duration, +}; + +use sha2::{Digest, Sha256}; +use windows::{ + core::HSTRING, + Data::Xml::Dom::XmlDocument, + Win32::System::Com::{CoCreateGuid, CoInitializeEx, CoUninitialize, COINIT_MULTITHREADED}, + UI::Notifications::{ToastNotification, ToastNotificationManager, ToastNotifier}, +}; +use windows_sys::Win32::{ + Foundation::{HWND, LPARAM, LRESULT, WPARAM}, + System::{Console::GetConsoleWindow, LibraryLoader::GetModuleHandleW, Registry::*}, + UI::WindowsAndMessaging::*, +}; + +const APP_ID: &str = "Herdr.Desktop"; +const WINDOW_CLASS: &str = "HerdrNotificationActivation"; +const ACTIVATION_MESSAGE: &str = "Herdr.Notification.Activate"; +const SHOW_MESSAGE: u32 = WM_APP + 1; +const ACTIVATOR: &str = "{D58C72D3-4548-4A40-B55B-092EF0B365C3}"; + +type Callback = Arc; + +struct Activation { + token: u128, + callback: Callback, +} + +// ponytail: one entry per notified target for this client lifetime. Retiring by +// history loses clicks already removed by Windows; add retention only if measured. +static ACTIVATIONS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +static SERVICE: Mutex> = Mutex::new(None); + +#[derive(Clone)] +struct Service { + window: usize, + requests: mpsc::Sender, +} + +struct Request { + title: String, + body: Option, + key: String, + callback: Callback, + ready: mpsc::SyncSender>, +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|err| err.into_inner()) +} + +fn winrt(result: windows::core::Result) -> io::Result { + result.map_err(io::Error::other) +} + +struct ComApartment; + +impl ComApartment { + fn new() -> io::Result { + winrt(unsafe { CoInitializeEx(None, COINIT_MULTITHREADED).ok() })?; + Ok(Self) + } +} + +impl Drop for ComApartment { + fn drop(&mut self) { + unsafe { + CoUninitialize(); + } + } +} + +fn random_token() -> io::Result { + winrt(unsafe { CoCreateGuid() }).map(|guid| guid.to_u128()) +} + +fn issue_activation( + activations: &mut HashMap, + key: &str, + callback: Callback, +) -> io::Result { + if let Some(activation) = activations.get_mut(key) { + activation.callback = callback; + return Ok(activation.token); + } + let token = random_token()?; + activations.insert(key.into(), Activation { token, callback }); + Ok(token) +} + +fn scheme_for_executable(executable: &std::path::Path) -> String { + let hash = Sha256::digest(executable.as_os_str().as_encoded_bytes()); + format!( + "herdr-notification-{:016x}", + u64::from_be_bytes(hash[..8].try_into().expect("eight bytes")) + ) +} + +struct RegistryKey(HKEY); + +impl RegistryKey { + fn create(path: &str) -> io::Result { + let mut handle = null_mut(); + let status = unsafe { + RegCreateKeyExW( + HKEY_CURRENT_USER, + super::wide_null(path).as_ptr(), + 0, + null_mut(), + REG_OPTION_NON_VOLATILE, + KEY_SET_VALUE, + std::ptr::null(), + &mut handle, + null_mut(), + ) + }; + if status != 0 { + return Err(io::Error::from_raw_os_error(status as i32)); + } + Ok(Self(handle)) + } + + fn set(&self, name: &str, value: &str) -> io::Result<()> { + let value = super::wide_null(value); + let status = unsafe { + RegSetValueExW( + self.0, + super::wide_null(name).as_ptr(), + 0, + REG_SZ, + value.as_ptr().cast(), + (value.len() * 2) as u32, + ) + }; + if status != 0 { + return Err(io::Error::from_raw_os_error(status as i32)); + } + Ok(()) + } +} + +impl Drop for RegistryKey { + fn drop(&mut self) { + unsafe { + RegCloseKey(self.0); + } + } +} + +fn register(executable: &std::path::Path, scheme: &str) -> io::Result<()> { + let parent = executable + .parent() + .ok_or_else(|| io::Error::other("missing executable directory"))?; + let host = parent.join("conpty/x64/OpenConsole.exe"); + if !host.is_file() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "Windows notification activation requires the bundled ConPTY runtime", + )); + } + let identity = RegistryKey::create(&format!(r"Software\Classes\AppUserModelId\{APP_ID}"))?; + identity.set("DisplayName", "Herdr")?; + // Unpackaged protocol toasts use a stub activator; no COM server is installed. + identity.set("CustomActivator", ACTIVATOR)?; + let protocol = RegistryKey::create(&format!(r"Software\Classes\{scheme}"))?; + protocol.set("URL Protocol", "")?; + let command = RegistryKey::create(&format!(r"Software\Classes\{scheme}\shell\open\command"))?; + // The pinned, app-local console host avoids flashing a new terminal on click. + command.set( + "", + &format!( + "\"{}\" --headless -- \"{}\" --notification-activate \"%1\"", + host.display(), + executable.display() + ), + ) +} + +fn activation_message() -> u32 { + unsafe { RegisterWindowMessageW(super::wide_null(ACTIVATION_MESSAGE).as_ptr()) } +} + +unsafe extern "system" fn window_proc(window: HWND, message: u32, w: WPARAM, l: LPARAM) -> LRESULT { + if message == activation_message() { + let token = ((w as u128) << 64) | (l as u64 as u128); + let callback = lock(&ACTIVATIONS) + .values() + .find(|activation| activation.token == token) + .map(|activation| Arc::clone(&activation.callback)); + if let Some(callback) = callback { + callback(); + } + return 0; + } + DefWindowProcW(window, message, w, l) +} + +pub(crate) fn foreground_desktop_notification_host() { + // Resolve after target validation: a Windows Terminal tab can move windows. + unsafe { + let host = GetAncestor(GetConsoleWindow(), GA_ROOTOWNER); + if IsWindowVisible(host) != 0 { + if IsIconic(host) != 0 { + ShowWindow(host, SW_RESTORE); + } + SetForegroundWindow(host); + } + } +} + +fn create_window() -> io::Result { + let class = super::wide_null(WINDOW_CLASS); + let instance = unsafe { GetModuleHandleW(std::ptr::null()) }; + let descriptor = WNDCLASSW { + lpfnWndProc: Some(window_proc), + hInstance: instance, + lpszClassName: class.as_ptr(), + ..unsafe { std::mem::zeroed() } + }; + if unsafe { RegisterClassW(&descriptor) } == 0 { + let err = io::Error::last_os_error(); + if err.raw_os_error() != Some(1410) { + return Err(err); + } + } + let window = unsafe { + CreateWindowExW( + 0, + class.as_ptr(), + class.as_ptr(), + 0, + 0, + 0, + 0, + 0, + null_mut(), + null_mut(), + instance, + std::ptr::null(), + ) + }; + if window.is_null() { + return Err(io::Error::last_os_error()); + } + let message = activation_message(); + if message == 0 + || unsafe { ChangeWindowMessageFilterEx(window, message, MSGFLT_ALLOW, null_mut()) } == 0 + { + let error = io::Error::last_os_error(); + unsafe { + DestroyWindow(window); + } + return Err(error); + } + Ok(window) +} + +fn notification_xml(title: &str, body: Option<&str>, uri: &str) -> io::Result { + let xml = winrt(XmlDocument::new())?; + winrt(xml.LoadXml(&HSTRING::from(r#""#)))?; + let root = winrt(xml.DocumentElement())?; + winrt(root.SetAttribute(&HSTRING::from("launch"), &HSTRING::from(uri)))?; + let binding = winrt(winrt(xml.GetElementsByTagName(&HSTRING::from("binding")))?.Item(0))?; + for text in std::iter::once(title).chain(body) { + let element = winrt(xml.CreateElement(&HSTRING::from("text")))?; + let node = winrt(xml.CreateTextNode(&HSTRING::from(text)))?; + winrt(element.AppendChild(&node))?; + winrt(binding.AppendChild(&element))?; + } + Ok(xml) +} + +fn show( + request: &Request, + window: HWND, + scheme: &str, + group: &str, + notifier: &ToastNotifier, +) -> io::Result { + let token = { + issue_activation( + &mut lock(&ACTIVATIONS), + &request.key, + Arc::clone(&request.callback), + )? + }; + let uri = format!("{scheme}://{:x}/{token:032x}", window as usize); + let xml = notification_xml(&request.title, request.body.as_deref(), &uri)?; + let notification = winrt(ToastNotification::CreateToastNotification(&xml))?; + winrt(notification.SetGroup(&HSTRING::from(group)))?; + // Only pane notifications replace one another; custom messages stay distinct. + if request.key != "general" { + winrt(notification.SetTag(&HSTRING::from(format!("{token:032x}"))))?; + } + // Setting can report ELEMENT_NOT_FOUND before the first successful Show. + winrt(notifier.Show(¬ification))?; + Ok(true) +} + +fn run(requests: mpsc::Receiver, ready: mpsc::SyncSender>) { + let _apartment = match ComApartment::new() { + Ok(apartment) => apartment, + Err(err) => { + let _ = ready.send(Err(err)); + return; + } + }; + let initialize = || -> io::Result<_> { + let executable = std::env::current_exe()?; + let scheme = scheme_for_executable(&executable); + register(&executable, &scheme)?; + let group = format!("{:032x}", random_token()?); + let notifier = winrt(ToastNotificationManager::CreateToastNotifierWithId( + &HSTRING::from(APP_ID), + ))?; + let window = create_window()?; + Ok((window, scheme, group, notifier)) + }; + let (window, scheme, group, notifier) = match initialize() { + Ok(state) => state, + Err(err) => { + let _ = ready.send(Err(err)); + return; + } + }; + if ready.send(Ok(window as usize)).is_ok() { + let mut message: MSG = unsafe { std::mem::zeroed() }; + while unsafe { GetMessageW(&mut message, window, 0, 0) } > 0 { + if message.message == SHOW_MESSAGE { + while let Ok(request) = requests.try_recv() { + let result = show(&request, window, &scheme, &group, ¬ifier); + let _ = request.ready.send(result); + } + } else { + unsafe { + DispatchMessageW(&message); + } + } + } + } + unsafe { + DestroyWindow(window); + } +} + +pub(crate) fn show_actionable_desktop_notification( + title: &str, + body: Option<&str>, + key: String, + callback: Callback, +) -> io::Result { + let service = { + let mut service = lock(&SERVICE); + if service.is_none() { + let (requests, receiver) = mpsc::channel(); + let (ready, response) = mpsc::sync_channel(1); + std::thread::Builder::new() + .name("herdr-windows-notification".into()) + .spawn(move || run(receiver, ready))?; + let window = response + .recv_timeout(Duration::from_secs(2)) + .map_err(io::Error::other)??; + *service = Some(Service { window, requests }); + } + service + .as_ref() + .expect("initialized notification service") + .clone() + }; + let (ready, response) = mpsc::sync_channel(1); + service + .requests + .send(Request { + title: title.into(), + body: body.map(str::to_owned), + key, + callback, + ready, + }) + .map_err(io::Error::other)?; + if unsafe { PostMessageW(service.window as HWND, SHOW_MESSAGE, 0, 0) } == 0 { + return Err(io::Error::last_os_error()); + } + response + .recv_timeout(Duration::from_secs(2)) + .map_err(io::Error::other)? +} + +pub(crate) fn show_desktop_notification(title: &str, body: Option<&str>) -> io::Result { + show_actionable_desktop_notification( + title, + body, + "general".into(), + Arc::new(foreground_desktop_notification_host), + ) +} + +fn parse_activation(uri: &str, scheme: &str) -> Option<(usize, u128)> { + let rest = uri.strip_prefix(scheme)?.strip_prefix("://")?; + let (window, token) = rest.split_once('/')?; + if window.is_empty() + || window.len() > 16 + || token.len() != 32 + || !window + .bytes() + .chain(token.bytes()) + .all(|byte| byte.is_ascii_hexdigit()) + { + return None; + } + let window = usize::from_str_radix(window, 16).ok()?; + (window != 0).then_some((window, u128::from_str_radix(token, 16).ok()?)) +} + +pub(crate) fn maybe_activate_desktop_notification(args: &[String]) -> Option> { + if args.get(1).map(String::as_str) != Some("--notification-activate") { + return None; + } + Some((|| { + let scheme = scheme_for_executable(&std::env::current_exe()?); + let (window, token) = args + .get(2) + .filter(|_| args.len() == 3) + .and_then(|uri| parse_activation(uri, &scheme)) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "invalid notification activation", + ) + })?; + let window = window as HWND; + let mut class = [0u16; 64]; + let length = unsafe { GetClassNameW(window, class.as_mut_ptr(), class.len() as i32) }; + if length <= 0 || String::from_utf16_lossy(&class[..length as usize]) != WINDOW_CLASS { + return Ok(()); + } + let mut process = 0; + unsafe { + GetWindowThreadProcessId(window, &mut process); + AllowSetForegroundWindow(process); + } + let message = activation_message(); + if message == 0 + || unsafe { PostMessageW(window, message, (token >> 64) as usize, token as isize) } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(()) + })()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replacement_preserves_issued_click_and_uses_latest_callback() { + let mut activations = HashMap::new(); + let count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let first_count = Arc::clone(&count); + let original = issue_activation( + &mut activations, + "endpoint/boot/pane", + Arc::new(move || { + first_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + }), + ) + .expect("token"); + let latest_count = Arc::clone(&count); + let replacement = issue_activation( + &mut activations, + "endpoint/boot/pane", + Arc::new(move || { + latest_count.fetch_add(10, std::sync::atomic::Ordering::Relaxed); + }), + ) + .expect("replacement"); + assert_eq!(original, replacement); + // There is deliberately no dependency on the history entry still existing. + let callback = activations + .values() + .find(|entry| entry.token == original) + .expect("issued click"); + (callback.callback)(); + assert_eq!(count.load(std::sync::atomic::Ordering::Relaxed), 10); + let restarted = + issue_activation(&mut activations, "endpoint/new-boot/pane", Arc::new(|| {})) + .expect("new boot"); + assert_ne!(restarted, original); + } + + #[test] + fn activation_requires_exact_scheme_window_and_full_issued_token_shape() { + let scheme = "herdr-notification-test"; + let token = 0x0123456789abcdef_fedcba9876543210u128; + let uri = format!("{scheme}://1a/{token:032x}"); + assert_eq!(parse_activation(&uri, scheme), Some((0x1a, token))); + for invalid in [ + "other://1a/0123456789abcdeffedcba9876543210", + "herdr-notification-test://0/0123456789abcdeffedcba9876543210", + "herdr-notification-test://1a/123", + "herdr-notification-test://1a/0123456789abcdeffedcba9876543210/extra", + "herdr-notification-test://1a/+123456789abcdeffedcba9876543210", + ] { + assert_eq!(parse_activation(invalid, scheme), None, "{invalid}"); + } + } + + #[test] + fn notification_text_remains_literal_xml_and_unicode() { + let _apartment = ComApartment::new().expect("COM"); + let title = "Agent & 😀"; + let xml = notification_xml(title, Some("body & "), "herdr-test://1/123").expect("XML"); + let texts = xml + .GetElementsByTagName(&HSTRING::from("text")) + .expect("texts"); + assert_eq!( + texts.Item(0).expect("title").InnerText().expect("text"), + HSTRING::from(title) + ); + assert_eq!( + texts.Item(1).expect("body").InnerText().expect("text"), + HSTRING::from("body & ") + ); + } +}