feat: use native actionable Windows notifications (#4803)

refs #4773
This commit is contained in:
JJ Liebig
2026-09-30 18:56:30 +02:00
committed by GitHub
parent d4e335e7b4
commit 347f9c99bc
19 changed files with 1140 additions and 128 deletions
Generated
+1
View File
@@ -812,6 +812,7 @@ dependencies = [
"unicode-segmentation",
"unicode-width",
"widestring",
"windows",
"windows-sys",
"wmi",
"zbus",
+5
View File
@@ -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",
@@ -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.
+15
View File
@@ -592,6 +592,11 @@ impl PendingEndpointActivation {
focus: Option<crate::client::shell::ClientEndpointFocusTarget>,
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
@@ -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<crate::protocol::ClientMessage> {
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(),
+187
View File
@@ -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();
+2
View File
@@ -10,6 +10,8 @@ pub(super) enum ClientLoopEvent {
DirectGraphicsResponse(direct_graphics::Response),
#[cfg(windows)]
StdinEvents(Vec<crate::protocol::ClientInputEvent>),
#[cfg(windows)]
NotificationActivated(shell::ClientSystemNotificationTarget),
Resize(u16, u16, u32, u32, bool),
TerminalUnavailable(io::Error),
ServerMessage {
+37 -2
View File
@@ -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,
+130 -5
View File
@@ -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<bool> + Send + 'static,
) -> io::Result<bool> {
type Task = Box<dyn FnOnce() -> io::Result<bool> + Send>;
static QUEUE: std::sync::OnceLock<tokio::sync::mpsc::UnboundedSender<Task>> =
std::sync::OnceLock::new();
let queue = QUEUE.get_or_init(|| {
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::<Task>();
// 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<bool> {
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<shell::ClientShellNotificationEffect>,
sound_config: &crate::config::SoundConfig,
#[cfg(windows)] event_tx: &tokio::sync::mpsc::Sender<super::events::ClientLoopEvent>,
) {
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<shell::ClientSystemNotificationTarget>,
event_tx: &tokio::sync::mpsc::Sender<super::events::ClientLoopEvent>,
) -> io::Result<bool> {
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<crate::sound::S
_ => 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");
}
}
+8
View File
@@ -439,6 +439,10 @@ impl ClientShellState {
&mut self,
target: ClientEndpointFocusTarget,
) -> Vec<ClientShellAction> {
#[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);
+10
View File
@@ -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 {
+5
View File
@@ -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 {
+52
View File
@@ -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,
+10
View File
@@ -721,9 +721,19 @@ pub(crate) enum ClientShellNotificationEffect {
System {
title: String,
body: Option<String>,
#[cfg(windows)]
target: Option<ClientSystemNotificationTarget>,
},
}
#[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,
+99
View File
@@ -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();
+9
View File
@@ -216,6 +216,15 @@ pub(super) fn begin_endpoint_activation(
now: std::time::Instant,
scheduled_activation: &mut Option<ClientLoopEvent>,
) -> 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 {
+4
View File
@@ -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);
}
+8 -121
View File
@@ -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<Vec<u8>> {
Some(bytes)
}
pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result<bool> {
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<std::io::Result<bool>>,
) {
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::<NOTIFYICONDATAW>() };
notification.cbSize = size_of::<NOTIFYICONDATAW>() 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, &notification) } == 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, &notification) } == 0 {
unsafe {
Shell_NotifyIconW(NIM_DELETE, &notification);
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, &notification);
DestroyWindow(hwnd);
}
}
fn copy_wide_truncated<const N: usize>(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<u16> {
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()];
+533
View File
@@ -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<dyn Fn() + Send + Sync>;
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<Mutex<HashMap<String, Activation>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
static SERVICE: Mutex<Option<Service>> = Mutex::new(None);
#[derive(Clone)]
struct Service {
window: usize,
requests: mpsc::Sender<Request>,
}
struct Request {
title: String,
body: Option<String>,
key: String,
callback: Callback,
ready: mpsc::SyncSender<io::Result<bool>>,
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(|err| err.into_inner())
}
fn winrt<T>(result: windows::core::Result<T>) -> io::Result<T> {
result.map_err(io::Error::other)
}
struct ComApartment;
impl ComApartment {
fn new() -> io::Result<Self> {
winrt(unsafe { CoInitializeEx(None, COINIT_MULTITHREADED).ok() })?;
Ok(Self)
}
}
impl Drop for ComApartment {
fn drop(&mut self) {
unsafe {
CoUninitialize();
}
}
}
fn random_token() -> io::Result<u128> {
winrt(unsafe { CoCreateGuid() }).map(|guid| guid.to_u128())
}
fn issue_activation(
activations: &mut HashMap<String, Activation>,
key: &str,
callback: Callback,
) -> io::Result<u128> {
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<Self> {
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<HWND> {
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<XmlDocument> {
let xml = winrt(XmlDocument::new())?;
winrt(xml.LoadXml(&HSTRING::from(r#"<toast activationType="protocol"><visual><binding template="ToastGeneric"/></visual><audio silent="true"/></toast>"#)))?;
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<bool> {
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(&notification))?;
Ok(true)
}
fn run(requests: mpsc::Receiver<Request>, ready: mpsc::SyncSender<io::Result<usize>>) {
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, &notifier);
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<bool> {
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<bool> {
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<io::Result<()>> {
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 <done> & 😀";
let xml = notification_xml(title, Some("body & <tag>"), "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 & <tag>")
);
}
}