agentmux_launcher/notify/
linux.rs1use std::collections::HashMap;
24use std::sync::mpsc as std_mpsc;
25
26use futures_util::StreamExt;
27use tokio::sync::mpsc;
28use zbus::zvariant::Value;
29
30use super::{Notification, Presenter, UserAction};
31
32const DEST: &str = "org.freedesktop.Notifications";
33const PATH: &str = "/org/freedesktop/Notifications";
34const IFACE: &str = "org.freedesktop.Notifications";
35
36enum Cmd {
37 Show(Notification),
38 Retract(String),
39 ClearAll(std_mpsc::Sender<()>),
40}
41
42pub struct LinuxPresenter {
43 tx: mpsc::UnboundedSender<Cmd>,
44}
45
46impl LinuxPresenter {
47 pub fn spawn(actions: mpsc::UnboundedSender<UserAction>) -> Self {
48 let (tx, rx) = mpsc::unbounded_channel();
49 tokio::spawn(run(rx, actions));
50 Self { tx }
51 }
52}
53
54impl Presenter for LinuxPresenter {
55 fn show(&self, n: &Notification) {
56 let _ = self.tx.send(Cmd::Show(n.clone()));
57 }
58 fn retract(&self, tag: &str) {
59 let _ = self.tx.send(Cmd::Retract(tag.to_string()));
60 }
61 fn clear_all(&self) {
62 let (done_tx, done_rx) = std_mpsc::channel();
63 if self.tx.send(Cmd::ClearAll(done_tx)).is_ok() {
64 let _ = done_rx.recv_timeout(std::time::Duration::from_secs(2));
65 }
66 }
67}
68
69pub fn escape_markup(s: &str) -> String {
71 s.replace('&', "&").replace('<', "<").replace('>', ">")
72}
73
74pub fn texts(n: &Notification, body_markup: bool) -> (String, String) {
79 let body = [n.body.as_deref(), n.summary.as_deref()].into_iter().flatten().collect::<Vec<_>>().join("\n");
80 (n.title.clone(), if body_markup { escape_markup(&body) } else { body })
81}
82
83pub fn delivery(n: &Notification) -> (u8, i32, bool) {
92 if n.is_attention() {
93 (1, 0, true)
94 } else {
95 (1, -1, false)
96 }
97}
98
99pub fn closed_is_user_dismissal(reason: u32) -> bool {
102 reason == 2
103}
104
105async fn run(mut rx: mpsc::UnboundedReceiver<Cmd>, actions: mpsc::UnboundedSender<UserAction>) {
106 let conn = match zbus::Connection::session().await {
107 Ok(c) => c,
108 Err(e) => {
109 crate::logging::log(&format!("notify: no D-Bus session bus ({e}); notifications disabled"));
110 while let Some(cmd) = rx.recv().await {
112 if let Cmd::ClearAll(done) = cmd {
113 let _ = done.send(());
114 }
115 }
116 return;
117 }
118 };
119
120 let body_markup = match conn.call_method(Some(DEST), PATH, Some(IFACE), "GetCapabilities", &()).await {
121 Ok(reply) => reply.body().deserialize::<Vec<String>>().map(|c| c.iter().any(|x| x == "body-markup")).unwrap_or(false),
122 Err(e) => {
123 crate::logging::log(&format!("notify: no notification server ({e})"));
124 false
125 }
126 };
127
128 let rule = zbus::MatchRule::builder()
133 .msg_type(zbus::message::Type::Signal)
134 .sender(DEST)
135 .and_then(|b| b.path(PATH))
136 .and_then(|b| b.interface(IFACE))
137 .map(|b| b.build());
138 let mut signals = match rule {
139 Ok(rule) => zbus::MessageStream::for_match_rule(rule, &conn, None).await.ok(),
140 Err(_) => None,
141 };
142
143 let mut by_tag: HashMap<String, (u32, String)> = HashMap::new();
145 let mut by_server: HashMap<u32, String> = HashMap::new();
146
147 loop {
148 tokio::select! {
149 cmd = rx.recv() => {
150 let Some(cmd) = cmd else { return };
151 match cmd {
152 Cmd::Show(n) => {
153 let replaces = by_tag.get(&n.tag).map(|(sid, _)| *sid).unwrap_or(0);
154 let (summary, body) = texts(&n, body_markup);
155 let mut hints: HashMap<&str, Value<'_>> = HashMap::new();
156 hints.insert("desktop-entry", Value::from("agentmux"));
157 let (urgency, timeout, resident) = delivery(&n);
158 hints.insert("urgency", Value::from(urgency));
159 if resident {
160 hints.insert("resident", Value::from(true));
161 }
162 let args = (
163 "AgentMux",
164 replaces,
165 "agentmux",
166 summary.as_str(),
167 body.as_str(),
168 vec!["default", "Open"],
169 hints,
170 timeout,
171 );
172 match conn.call_method(Some(DEST), PATH, Some(IFACE), "Notify", &args).await {
173 Ok(reply) => {
174 if let Ok(sid) = reply.body().deserialize::<u32>() {
175 if let Some((old, _)) = by_tag.insert(n.tag.clone(), (sid, n.id.clone())) {
176 by_server.remove(&old);
177 }
178 by_server.insert(sid, n.id.clone());
179 }
180 }
181 Err(e) => crate::logging::log(&format!("notify: Notify failed: {e}")),
182 }
183 }
184 Cmd::Retract(tag) => {
185 if let Some((sid, _)) = by_tag.remove(&tag) {
186 by_server.remove(&sid);
187 let _ = conn.call_method(Some(DEST), PATH, Some(IFACE), "CloseNotification", &(sid,)).await;
188 }
189 }
190 Cmd::ClearAll(done) => {
191 for (_, (sid, _)) in by_tag.drain() {
192 let _ = conn.call_method(Some(DEST), PATH, Some(IFACE), "CloseNotification", &(sid,)).await;
193 }
194 by_server.clear();
195 let _ = done.send(());
196 }
197 }
198 }
199 msg = async {
200 match signals.as_mut() {
201 Some(s) => s.next().await,
202 None => std::future::pending().await,
203 }
204 } => {
205 let Some(Ok(msg)) = msg else { continue };
206 let header = msg.header();
207 let member = header.member().map(|m| m.as_str().to_string()).unwrap_or_default();
208 match member.as_str() {
209 "ActionInvoked" => {
210 if let Ok((sid, action)) = msg.body().deserialize::<(u32, String)>() {
211 if action == "default" {
212 if let Some(id) = by_server.get(&sid) {
213 let _ = actions.send(UserAction::Clicked(id.clone()));
214 }
215 }
216 }
217 }
218 "NotificationClosed" => {
219 if let Ok((sid, reason)) = msg.body().deserialize::<(u32, u32)>() {
220 if let Some(id) = by_server.remove(&sid) {
221 by_tag.retain(|_, (s, _)| *s != sid);
222 if closed_is_user_dismissal(reason) {
223 let _ = actions.send(UserAction::Dismissed(id));
224 }
225 }
226 }
227 }
228 _ => {}
229 }
230 }
231 }
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 fn n(body: Option<&str>) -> Notification {
240 Notification {
241 id: "n1".into(),
242 kind: "input_waiting".into(),
243 priority: "attention".into(),
244 title: "lark has a question".into(),
245 body: body.map(Into::into),
246 summary: None,
247 tag: "t1".into(),
248 }
249 }
250
251 #[test]
252 fn body_is_escaped_only_for_markup_servers() {
253 let evil = "<a href='x'>click</a> & more";
254 assert_eq!(texts(&n(Some(evil)), true).1, "<a href='x'>click</a> & more");
255 assert_eq!(texts(&n(Some(evil)), false).1, evil);
256 assert_eq!(texts(&n(None), true), ("lark has a question".to_string(), String::new()));
257 }
258
259 #[test]
260 fn summary_is_the_last_body_line_and_escaped_with_it() {
261 let with = |body: Option<&str>, summary: &str| Notification { summary: Some(summary.into()), ..n(body) };
262 assert_eq!(texts(&with(Some("Which branch?"), "Fix repaint"), false).1, "Which branch?\nFix repaint");
263 assert_eq!(texts(&with(None, "Fix repaint"), false).1, "Fix repaint");
264 assert_eq!(texts(&with(Some("a"), "<b>x</b>"), true).1, "a\n<b>x</b>");
265 }
266
267 #[test]
268 fn attention_never_expires_but_never_goes_critical() {
269 let mut a = n(None);
270 a.priority = "attention".into();
271 assert_eq!(delivery(&a), (1, 0, true));
272 let mut normal = n(None);
273 normal.priority = "normal".into();
274 assert_eq!(delivery(&normal), (1, -1, false));
275 }
276
277 #[test]
278 fn only_user_dismissal_is_feedback() {
279 assert!(closed_is_user_dismissal(2));
280 assert!(!closed_is_user_dismissal(1)); assert!(!closed_is_user_dismissal(3)); assert!(!closed_is_user_dismissal(4));
283 }
284}