1use 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
26pub enum Urgency {
27 Low,
29 #[default]
31 Normal,
32 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum NotificationAction {
65 Focus,
67 Report,
69 FocusReport,
71 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum NotificationSound {
89 Silent,
91 System,
93 Info,
95 Warning,
97 Error,
99 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#[derive(Clone, Debug, Default, Eq, PartialEq)]
122pub struct Notification {
123 pub title: Option<Str>,
125 pub body: Option<Str>,
127 pub id: Option<Str>,
129 pub types: SmallVec<Str, 1>,
131 pub urgency: Option<Urgency>,
133 pub icon_name: Option<Str>,
135 pub sound: Option<NotificationSound>,
137 pub actions: Option<NotificationAction>,
139 pub expires_ms: Option<i64>,
141}
142
143impl Notification {
144 #[must_use]
146 pub fn builder() -> NotificationBuilder {
147 NotificationBuilder::default()
148 }
149}
150
151#[derive(Clone, Debug, Default)]
153pub struct NotificationBuilder {
154 notification: Notification,
155}
156
157impl NotificationBuilder {
158 #[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 #[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 #[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 #[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 #[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 #[must_use]
202 pub const fn urgency(mut self, urgency: Urgency) -> Self {
203 self.notification.urgency = Some(urgency);
204 self
205 }
206
207 #[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 #[must_use]
216 pub const fn sound(mut self, sound: NotificationSound) -> Self {
217 self.notification.sound = Some(sound);
218 self
219 }
220
221 #[must_use]
223 pub const fn actions(mut self, actions: NotificationAction) -> Self {
224 self.notification.actions = Some(actions);
225 self
226 }
227
228 #[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 #[must_use]
237 pub fn build(self) -> Notification {
238 self.notification
239 }
240}
241
242pub 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 ¬ification.types {
404 let _ = write!(metadata, ":t={}", base64_utf8(notification_type));
405 }
406 if let Some(icon_name) = ¬ification.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, ¬ification, &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 ¬ification,
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 ¬ification,
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 ¬ification,
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, ¬ification, &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), ¬ification, &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), ¬ification, &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), ¬ification, &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), ¬ification, &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}