mirror of
https://github.com/herdrdev/herdr.git
synced 2026-10-07 08:00:40 +00:00
Generated
+1
@@ -812,6 +812,7 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
"widestring",
|
||||
"windows",
|
||||
"windows-sys",
|
||||
"wmi",
|
||||
"zbus",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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, ¬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<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()];
|
||||
|
||||
@@ -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(¬ification))?;
|
||||
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, ¬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<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>")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user