Skip to main content

omp_tui/
notify.rs

1//! Structured desktop notifications for terminal and Linux D-Bus sinks.
2
3use std::{
4	ffi::{OsStr, OsString},
5	fmt::Write as _,
6	fs,
7	io::{self, Write},
8	path::{Path, PathBuf},
9	process::{Command, Stdio},
10	sync::atomic::{AtomicU64, Ordering},
11};
12
13use omp_core::{Str, encoding::base64};
14use smallvec::SmallVec;
15
16use crate::{NotifyProtocol, TerminalCaps, escape::esc, kitty::append_tmux_passthrough};
17
18const OSC99_MAX_PAYLOAD_BYTES: usize = 2048;
19const OSC99_APP_NAME: &str = "omp";
20const DBUS_APP_NAME: &str = "omp";
21const DEFAULT_TITLE: &str = "omp";
22static NEXT_OSC99_ID: AtomicU64 = AtomicU64::new(1);
23
24/// The importance assigned to a desktop notification.
25#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
26pub enum Urgency {
27	/// A low-priority notification.
28	Low,
29	/// A normal-priority notification.
30	#[default]
31	Normal,
32	/// A critical notification.
33	Critical,
34}
35
36impl Urgency {
37	const fn name(self) -> &'static str {
38		match self {
39			Self::Low => "low",
40			Self::Normal => "normal",
41			Self::Critical => "critical",
42		}
43	}
44
45	const fn osc99(self) -> char {
46		match self {
47			Self::Low => '0',
48			Self::Normal => '1',
49			Self::Critical => '2',
50		}
51	}
52
53	const fn dbus_byte(self) -> u8 {
54		match self {
55			Self::Low => 0,
56			Self::Normal => 1,
57			Self::Critical => 2,
58		}
59	}
60}
61
62/// Terminal actions advertised by a structured notification.
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum NotificationAction {
65	/// Ask the terminal to focus the originating window when activated.
66	Focus,
67	/// Ask the terminal to report activation to the application.
68	Report,
69	/// Focus the window and report activation.
70	FocusReport,
71	/// Explicitly disable the default focus action.
72	None,
73}
74
75impl NotificationAction {
76	const fn osc99(self) -> &'static str {
77		match self {
78			Self::Focus => "focus",
79			Self::Report => "report",
80			Self::FocusReport => "focus,report",
81			Self::None => "-focus",
82		}
83	}
84}
85
86/// Sound requested for a structured notification.
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum NotificationSound {
89	/// Do not play a sound.
90	Silent,
91	/// Use the terminal's system notification sound.
92	System,
93	/// Use an informational sound.
94	Info,
95	/// Use a warning sound.
96	Warning,
97	/// Use an error sound.
98	Error,
99	/// Use a question sound.
100	Question,
101}
102
103impl NotificationSound {
104	const fn name(self) -> &'static str {
105		match self {
106			Self::Silent => "silent",
107			Self::System => "system",
108			Self::Info => "info",
109			Self::Warning => "warning",
110			Self::Error => "error",
111			Self::Question => "question",
112		}
113	}
114}
115
116/// A structured desktop notification.
117///
118/// OSC 99 carries every field. OSC 9 and unconfirmed OSC 99 collapse the title
119/// and body to one line, while the bell protocol can additionally fan out to a
120/// Linux freedesktop notification daemon.
121#[derive(Clone, Debug, Default, Eq, PartialEq)]
122pub struct Notification {
123	/// Optional notification title.
124	pub title:      Option<Str>,
125	/// Optional notification body.
126	pub body:       Option<Str>,
127	/// Optional stable OSC 99 notification identifier.
128	pub id:         Option<Str>,
129	/// Notification categories encoded as OSC 99 `t=` metadata.
130	pub types:      SmallVec<Str, 1>,
131	/// Optional urgency metadata.
132	pub urgency:    Option<Urgency>,
133	/// Optional icon name.
134	pub icon_name:  Option<Str>,
135	/// Optional sound request.
136	pub sound:      Option<NotificationSound>,
137	/// Optional terminal action request.
138	pub actions:    Option<NotificationAction>,
139	/// Optional expiry timeout in milliseconds; values below `-1` become `-1`.
140	pub expires_ms: Option<i64>,
141}
142
143impl Notification {
144	/// Starts a structured notification builder.
145	#[must_use]
146	pub fn builder() -> NotificationBuilder {
147		NotificationBuilder::default()
148	}
149}
150
151/// Builder for [`Notification`].
152#[derive(Clone, Debug, Default)]
153pub struct NotificationBuilder {
154	notification: Notification,
155}
156
157impl NotificationBuilder {
158	/// Sets the notification title.
159	#[must_use]
160	pub fn title(mut self, title: impl Into<Str>) -> Self {
161		self.notification.title = Some(title.into());
162		self
163	}
164
165	/// Sets the notification body.
166	#[must_use]
167	pub fn body(mut self, body: impl Into<Str>) -> Self {
168		self.notification.body = Some(body.into());
169		self
170	}
171
172	/// Sets the stable OSC 99 identifier.
173	#[must_use]
174	pub fn id(mut self, id: impl Into<Str>) -> Self {
175		self.notification.id = Some(id.into());
176		self
177	}
178
179	/// Appends one notification category.
180	#[must_use]
181	pub fn notification_type(mut self, notification_type: impl Into<Str>) -> Self {
182		self.notification.types.push(notification_type.into());
183		self
184	}
185
186	/// Appends notification categories in iteration order.
187	#[must_use]
188	pub fn notification_types<I, S>(mut self, notification_types: I) -> Self
189	where
190		I: IntoIterator<Item = S>,
191		S: Into<Str>,
192	{
193		self
194			.notification
195			.types
196			.extend(notification_types.into_iter().map(Into::into));
197		self
198	}
199
200	/// Sets notification urgency.
201	#[must_use]
202	pub const fn urgency(mut self, urgency: Urgency) -> Self {
203		self.notification.urgency = Some(urgency);
204		self
205	}
206
207	/// Sets the icon name.
208	#[must_use]
209	pub fn icon_name(mut self, icon_name: impl Into<Str>) -> Self {
210		self.notification.icon_name = Some(icon_name.into());
211		self
212	}
213
214	/// Sets the requested notification sound.
215	#[must_use]
216	pub const fn sound(mut self, sound: NotificationSound) -> Self {
217		self.notification.sound = Some(sound);
218		self
219	}
220
221	/// Sets the terminal action request.
222	#[must_use]
223	pub const fn actions(mut self, actions: NotificationAction) -> Self {
224		self.notification.actions = Some(actions);
225		self
226	}
227
228	/// Sets the expiry timeout in milliseconds.
229	#[must_use]
230	pub const fn expires_ms(mut self, expires_ms: i64) -> Self {
231		self.notification.expires_ms = Some(expires_ms);
232		self
233	}
234
235	/// Finishes the notification.
236	#[must_use]
237	pub fn build(self) -> Notification {
238		self.notification
239	}
240}
241
242/// Delivers a notification through the protocol selected by `caps`.
243///
244/// Delivery commands are detached and best-effort. Only terminal output errors
245/// are returned to the caller.
246pub fn notify(
247	out: &mut impl Write,
248	caps: &TerminalCaps,
249	notification: &Notification,
250) -> io::Result<()> {
251	notify_with_system(out, caps, notification, &RealSystem)
252}
253
254trait System {
255	fn var(&self, name: &str) -> Option<OsString>;
256	fn is_linux(&self) -> bool;
257	fn path_exists(&self, path: &Path) -> bool;
258	fn find_program(&self, name: &str) -> Option<PathBuf>;
259	fn spawn(&self, argv: &[OsString]) -> io::Result<()>;
260}
261
262struct RealSystem;
263
264impl System for RealSystem {
265	fn var(&self, name: &str) -> Option<OsString> {
266		std::env::var_os(name)
267	}
268
269	fn is_linux(&self) -> bool {
270		cfg!(target_os = "linux")
271	}
272
273	fn path_exists(&self, path: &Path) -> bool {
274		path.exists()
275	}
276
277	fn find_program(&self, name: &str) -> Option<PathBuf> {
278		let path = self.var("PATH")?;
279		std::env::split_paths(&path)
280			.map(|directory| directory.join(name))
281			.find(|candidate| fs::metadata(candidate).is_ok_and(|metadata| metadata.is_file()))
282	}
283
284	fn spawn(&self, argv: &[OsString]) -> io::Result<()> {
285		let Some((program, arguments)) = argv.split_first() else {
286			return Err(io::Error::new(io::ErrorKind::InvalidInput, "empty notification command"));
287		};
288		Command::new(program)
289			.args(arguments)
290			.stdin(Stdio::null())
291			.stdout(Stdio::null())
292			.stderr(Stdio::null())
293			.spawn()
294			.map(drop)
295	}
296}
297
298fn notify_with_system(
299	out: &mut impl Write,
300	caps: &TerminalCaps,
301	notification: &Notification,
302	system: &impl System,
303) -> io::Result<()> {
304	if route_cmux(notification, system) {
305		return Ok(());
306	}
307
308	let sequence = format_notification(caps, notification);
309	if caps.notify != NotifyProtocol::Bell && caps.inside_tmux {
310		let mut wrapped = String::with_capacity(sequence.len() + 16);
311		append_tmux_passthrough(&mut wrapped, &sequence);
312		wrapped.push('\x07');
313		out.write_all(wrapped.as_bytes())?;
314	} else {
315		out.write_all(sequence.as_bytes())?;
316		if caps.notify != NotifyProtocol::Bell && env_present(system, "ZELLIJ") {
317			out.write_all(esc!(bel).as_bytes())?;
318		}
319	}
320
321	if caps.notify == NotifyProtocol::Bell {
322		deliver_linux_fallback(notification, system);
323	}
324	Ok(())
325}
326
327fn route_cmux(notification: &Notification, system: &impl System) -> bool {
328	let Some(surface) = system.var("CMUX_SURFACE_ID") else {
329		return false;
330	};
331	let surface = surface.to_string_lossy();
332	let surface = surface.trim();
333	if !valid_surface_id(surface.as_bytes()) {
334		return false;
335	}
336	let title = notification
337		.title
338		.as_deref()
339		.map(str::trim)
340		.filter(|title| !title.is_empty())
341		.unwrap_or(DEFAULT_TITLE);
342	let body = notification.body.as_deref().unwrap_or("");
343	let argv = [
344		OsString::from("cmux"),
345		OsString::from("notify"),
346		OsString::from("--surface"),
347		OsString::from(surface),
348		OsString::from("--title"),
349		OsString::from(title),
350		OsString::from("--body"),
351		OsString::from(body),
352	];
353	system.spawn(&argv).is_ok()
354}
355
356fn valid_surface_id(id: &[u8]) -> bool {
357	if id.len() != 36 {
358		return false;
359	}
360	for (index, byte) in id.iter().copied().enumerate() {
361		if matches!(index, 8 | 13 | 18 | 23) {
362			if byte != b'-' {
363				return false;
364			}
365		} else if !byte.is_ascii_hexdigit() {
366			return false;
367		}
368	}
369	true
370}
371
372fn format_notification(caps: &TerminalCaps, notification: &Notification) -> String {
373	match caps.notify {
374		NotifyProtocol::Bell => String::from(esc!(bel)),
375		NotifyProtocol::Osc9 => {
376			format!(esc!(osc, "9;{}", st), notification_line(notification))
377		},
378		NotifyProtocol::Osc99 if caps.osc99_confirmed => format_osc99(notification),
379		NotifyProtocol::Osc99 => {
380			format!(esc!(osc, "99;;{}", st), notification_line(notification))
381		},
382	}
383}
384
385fn notification_line(notification: &Notification) -> String {
386	match (notification.title.as_deref(), notification.body.as_deref()) {
387		(Some(title), Some(body)) => format!("{title}: {body}"),
388		(Some(title), None) => title.to_owned(),
389		(None, Some(body)) => body.to_owned(),
390		(None, None) => String::new(),
391	}
392}
393
394fn format_osc99(notification: &Notification) -> String {
395	let id = osc99_id(notification.id.as_deref());
396	let mut metadata = format!("i={id}:f={}", base64_utf8(OSC99_APP_NAME));
397	if let Some(actions) = notification.actions {
398		let _ = write!(metadata, ":a={}", actions.osc99());
399	}
400	if let Some(urgency) = notification.urgency {
401		let _ = write!(metadata, ":u={}", urgency.osc99());
402	}
403	for notification_type in &notification.types {
404		let _ = write!(metadata, ":t={}", base64_utf8(notification_type));
405	}
406	if let Some(icon_name) = &notification.icon_name {
407		let _ = write!(metadata, ":n={}", base64_utf8(icon_name));
408	}
409	if let Some(sound) = notification.sound {
410		let _ = write!(metadata, ":s={}", base64_utf8(sound.name()));
411	}
412	if let Some(expires_ms) = notification.expires_ms {
413		let _ = write!(metadata, ":w={}", expires_ms.max(-1));
414	}
415
416	let title = notification
417		.title
418		.as_deref()
419		.or(notification.body.as_deref())
420		.unwrap_or("");
421	let body = notification
422		.title
423		.as_ref()
424		.and(notification.body.as_deref())
425		.filter(|body| !body.is_empty());
426	let mut output = String::new();
427	append_osc99_payload(&mut output, &metadata, title, body.is_some());
428	if let Some(body) = body {
429		let body_metadata = format!("i={id}:p=body");
430		append_osc99_payload(&mut output, &body_metadata, body, false);
431	}
432	output
433}
434
435fn osc99_id(id: Option<&str>) -> String {
436	if let Some(id) = id {
437		let sanitized: String = id
438			.chars()
439			.filter(|character| {
440				character.is_ascii_alphanumeric() || matches!(character, '_' | '+' | '-' | '.')
441			})
442			.collect();
443		if !sanitized.is_empty() && sanitized != "0" {
444			return sanitized;
445		}
446	}
447	format!("omp-{}", NEXT_OSC99_ID.fetch_add(1, Ordering::Relaxed))
448}
449
450fn append_osc99_payload(output: &mut String, metadata: &str, payload: &str, hold: bool) {
451	if payload.is_empty() {
452		append_osc99_chunk(output, metadata, "", hold);
453		return;
454	}
455	let mut start = 0;
456	while start < payload.len() {
457		let mut end = (start + OSC99_MAX_PAYLOAD_BYTES).min(payload.len());
458		while !payload.is_char_boundary(end) {
459			end -= 1;
460		}
461		let more = end < payload.len();
462		append_osc99_chunk(output, metadata, &payload[start..end], hold || more);
463		start = end;
464	}
465}
466
467fn append_osc99_chunk(output: &mut String, metadata: &str, payload: &str, hold: bool) {
468	output.push_str(esc!(osc, "99;"));
469	output.push_str(metadata);
470	if hold {
471		output.push_str(":d=0");
472	}
473	if osc99_unsafe(payload) {
474		output.push_str(":e=1");
475	}
476	output.push(';');
477	if osc99_unsafe(payload) {
478		output.push_str(&base64_utf8(payload));
479	} else {
480		output.push_str(payload);
481	}
482	output.push_str(esc!(st));
483}
484
485fn osc99_unsafe(payload: &str) -> bool {
486	payload.chars().any(|character| {
487		let code = character as u32;
488		code <= 0x1f || code == 0x7f || (0x80..=0x9f).contains(&code)
489	})
490}
491
492fn base64_utf8(value: &str) -> String {
493	base64::encode(value.as_bytes()).into_string()
494}
495
496fn deliver_linux_fallback(notification: &Notification, system: &impl System) {
497	if !system.is_linux()
498		|| system.var("OMP_NO_DESKTOP_NOTIFY").as_deref() == Some(OsStr::new("1"))
499		|| !has_desktop_session(system)
500	{
501		return;
502	}
503	let (title, body, urgency) = resolved_fields(notification);
504	if let Some(program) = system.find_program("notify-send") {
505		let argv = [
506			program.into_os_string(),
507			OsString::from("--app-name"),
508			OsString::from(DBUS_APP_NAME),
509			OsString::from(format!("--urgency={}", urgency.name())),
510			OsString::from("--expire-time=5000"),
511			OsString::from(title),
512			OsString::from(body),
513		];
514		let _ = system.spawn(&argv);
515		return;
516	}
517	let Some(program) = system.find_program("gdbus") else {
518		return;
519	};
520	let argv = [
521		program.into_os_string(),
522		OsString::from("call"),
523		OsString::from("--session"),
524		OsString::from("--dest"),
525		OsString::from("org.freedesktop.Notifications"),
526		OsString::from("--object-path"),
527		OsString::from("/org/freedesktop/Notifications"),
528		OsString::from("--method"),
529		OsString::from("org.freedesktop.Notifications.Notify"),
530		OsString::from(DBUS_APP_NAME),
531		OsString::from("0"),
532		OsString::new(),
533		OsString::from(title),
534		OsString::from(body),
535		OsString::from("[]"),
536		OsString::from(format!("{{\"urgency\": <byte {}>}}", urgency.dbus_byte())),
537		OsString::from("5000"),
538	];
539	let _ = system.spawn(&argv);
540}
541
542fn has_desktop_session(system: &impl System) -> bool {
543	if env_present(system, "DBUS_SESSION_BUS_ADDRESS") {
544		return true;
545	}
546	let Some(runtime_dir) = system.var("XDG_RUNTIME_DIR") else {
547		return false;
548	};
549	system.path_exists(&PathBuf::from(runtime_dir).join("bus"))
550}
551
552fn env_present(system: &impl System, name: &str) -> bool {
553	system.var(name).is_some_and(|value| !value.is_empty())
554}
555
556fn resolved_fields(notification: &Notification) -> (&str, &str, Urgency) {
557	let title = notification
558		.title
559		.as_deref()
560		.map(str::trim)
561		.filter(|title| !title.is_empty())
562		.unwrap_or(DEFAULT_TITLE);
563	let body = notification.body.as_deref().unwrap_or("");
564	let urgency = notification.urgency.unwrap_or_default();
565	(title, body, urgency)
566}
567
568#[cfg(test)]
569mod tests {
570	use std::{
571		cell::RefCell,
572		collections::{HashMap, HashSet},
573	};
574
575	use super::*;
576	use crate::{TerminalPlatform, detect_from};
577
578	#[derive(Default)]
579	struct MockSystem {
580		env:      HashMap<String, OsString>,
581		linux:    bool,
582		existing: HashSet<PathBuf>,
583		programs: HashMap<String, PathBuf>,
584		spawns:   RefCell<Vec<Vec<OsString>>>,
585	}
586
587	impl System for MockSystem {
588		fn var(&self, name: &str) -> Option<OsString> {
589			self.env.get(name).cloned()
590		}
591
592		fn is_linux(&self) -> bool {
593			self.linux
594		}
595
596		fn path_exists(&self, path: &Path) -> bool {
597			self.existing.contains(path)
598		}
599
600		fn find_program(&self, name: &str) -> Option<PathBuf> {
601			self.programs.get(name).cloned()
602		}
603
604		fn spawn(&self, argv: &[OsString]) -> io::Result<()> {
605			self.spawns.borrow_mut().push(argv.to_vec());
606			Ok(())
607		}
608	}
609
610	fn caps(protocol: NotifyProtocol) -> TerminalCaps {
611		let mut caps = detect_from(&|_| None, TerminalPlatform::Other);
612		caps.notify = protocol;
613		caps
614	}
615
616	fn strings(argv: &[OsString]) -> Vec<String> {
617		argv
618			.iter()
619			.map(|value| value.to_string_lossy().into_owned())
620			.collect()
621	}
622
623	#[test]
624	fn structured_osc99_is_byte_exact_and_chunks_on_utf8_boundaries() {
625		let title = format!("{}éZ", "a".repeat(2047));
626		let notification = Notification::builder()
627			.id("job:7")
628			.title(title)
629			.body("body\n")
630			.actions(NotificationAction::FocusReport)
631			.urgency(Urgency::Critical)
632			.notification_types(["build", "complete"])
633			.icon_name("omp")
634			.sound(NotificationSound::Warning)
635			.expires_ms(2500)
636			.build();
637		let mut caps = caps(NotifyProtocol::Osc99);
638		caps.osc99_confirmed = true;
639		let mut actual = Vec::new();
640		notify_with_system(&mut actual, &caps, &notification, &MockSystem::default()).unwrap();
641
642		let metadata =
643			"i=job7:f=b21w:a=focus,report:u=2:t=YnVpbGQ=:t=Y29tcGxldGU=:n=b21w:s=d2FybmluZw==:w=2500";
644		let expected = format!(
645			"\x1b]99;{metadata}:d=0;{}\x1b\\\x1b]99;{metadata}:d=0;éZ\x1b\\\x1b]99;i=job7:p=body:e=1;\
646			 Ym9keQo=\x1b\\",
647			"a".repeat(2047),
648		);
649		assert_eq!(actual, expected.as_bytes());
650	}
651
652	#[test]
653	fn unconfirmed_osc99_collapses_to_one_line() {
654		let notification = Notification::builder()
655			.title("Done")
656			.body("All green")
657			.build();
658		let mut actual = Vec::new();
659		notify_with_system(
660			&mut actual,
661			&caps(NotifyProtocol::Osc99),
662			&notification,
663			&MockSystem::default(),
664		)
665		.unwrap();
666		assert_eq!(actual, b"\x1b]99;;Done: All green\x1b\\");
667	}
668
669	#[test]
670	fn osc9_and_bell_have_exact_wire_forms() {
671		let notification = Notification::builder()
672			.title("Done")
673			.body("All green")
674			.build();
675		let mut osc9 = Vec::new();
676		notify_with_system(
677			&mut osc9,
678			&caps(NotifyProtocol::Osc9),
679			&notification,
680			&MockSystem::default(),
681		)
682		.unwrap();
683		assert_eq!(osc9, b"\x1b]9;Done: All green\x1b\\");
684		let mut bell = Vec::new();
685		notify_with_system(
686			&mut bell,
687			&caps(NotifyProtocol::Bell),
688			&notification,
689			&MockSystem::default(),
690		)
691		.unwrap();
692		assert_eq!(bell, b"\x07");
693	}
694
695	#[test]
696	fn tmux_wraps_non_bell_and_appends_bell() {
697		let mut caps = caps(NotifyProtocol::Osc9);
698		caps.inside_tmux = true;
699		let notification = Notification::builder().body("done").build();
700		let mut actual = Vec::new();
701		notify_with_system(&mut actual, &caps, &notification, &MockSystem::default()).unwrap();
702		assert_eq!(actual, b"\x1bPtmux;\x1b\x1b]9;done\x1b\x1b\\\x1b\\\x07");
703	}
704
705	#[test]
706	fn zellij_appends_bell_to_non_bell() {
707		let mut system = MockSystem::default();
708		system.env.insert("ZELLIJ".into(), "1".into());
709		let notification = Notification::builder().body("done").build();
710		let mut actual = Vec::new();
711		notify_with_system(&mut actual, &caps(NotifyProtocol::Osc9), &notification, &system).unwrap();
712		assert_eq!(actual, b"\x1b]9;done\x1b\\\x07");
713	}
714
715	#[test]
716	fn cmux_surface_routes_instead_of_writing_terminal_sequence() {
717		let mut system = MockSystem::default();
718		system
719			.env
720			.insert("CMUX_SURFACE_ID".into(), "01234567-89AB-cdef-0123-456789abcdef".into());
721		let notification = Notification::builder().title("Build").body("done").build();
722		let mut actual = Vec::new();
723		notify_with_system(&mut actual, &caps(NotifyProtocol::Osc9), &notification, &system).unwrap();
724		assert_eq!(actual, [] as [u8; 0]);
725		assert_eq!(strings(&system.spawns.borrow()[0]), [
726			"cmux",
727			"notify",
728			"--surface",
729			"01234567-89AB-cdef-0123-456789abcdef",
730			"--title",
731			"Build",
732			"--body",
733			"done"
734		],);
735	}
736
737	#[test]
738	fn linux_notify_send_fallback_uses_exact_argv() {
739		let mut system = MockSystem { linux: true, ..MockSystem::default() };
740		system
741			.env
742			.insert("DBUS_SESSION_BUS_ADDRESS".into(), "unix:path=/run/user/1/bus".into());
743		system
744			.programs
745			.insert("notify-send".into(), "/usr/bin/notify-send".into());
746		let notification = Notification::builder()
747			.title("Build")
748			.body("failed")
749			.urgency(Urgency::Critical)
750			.build();
751		let mut actual = Vec::new();
752		notify_with_system(&mut actual, &caps(NotifyProtocol::Bell), &notification, &system).unwrap();
753		assert_eq!(actual, b"\x07");
754		assert_eq!(strings(&system.spawns.borrow()[0]), [
755			"/usr/bin/notify-send",
756			"--app-name",
757			"omp",
758			"--urgency=critical",
759			"--expire-time=5000",
760			"Build",
761			"failed"
762		],);
763	}
764
765	#[test]
766	fn linux_gdbus_fallback_uses_exact_argv() {
767		let mut system = MockSystem { linux: true, ..MockSystem::default() };
768		system
769			.env
770			.insert("XDG_RUNTIME_DIR".into(), "/run/user/1".into());
771		system.existing.insert(PathBuf::from("/run/user/1/bus"));
772		system
773			.programs
774			.insert("gdbus".into(), "/usr/bin/gdbus".into());
775		let notification = Notification::builder()
776			.title("Build")
777			.body("done")
778			.urgency(Urgency::Low)
779			.build();
780		let mut actual = Vec::new();
781		notify_with_system(&mut actual, &caps(NotifyProtocol::Bell), &notification, &system).unwrap();
782		assert_eq!(strings(&system.spawns.borrow()[0]), [
783			"/usr/bin/gdbus",
784			"call",
785			"--session",
786			"--dest",
787			"org.freedesktop.Notifications",
788			"--object-path",
789			"/org/freedesktop/Notifications",
790			"--method",
791			"org.freedesktop.Notifications.Notify",
792			"omp",
793			"0",
794			"",
795			"Build",
796			"done",
797			"[]",
798			"{\"urgency\": <byte 0>}",
799			"5000",
800		],);
801	}
802}