use std::{collections::HashMap, thread};
use crossbeam_channel::{Sender, bounded};
use notify_rust::Notification as OsNotification;
#[cfg(all(unix, not(target_os = "macos")))]
use notify_rust::NotificationHandle as OsNotificationHandle;
use crate::{
constants::APP_NAME,
domain::{
notification::{Notification, NotificationId, NotificationScope},
port::Notifier,
value::PaneId,
},
};
const QUEUE_CAPACITY: usize = 64;
const MAX_ACTIVE_NOTIFICATIONS: usize = 64;
#[cfg(all(unix, not(target_os = "macos")))]
const XDG_AMPERSAND: &str = "&";
#[cfg(all(unix, not(target_os = "macos")))]
const XDG_LESS_THAN: &str = "<";
#[cfg(all(unix, not(target_os = "macos")))]
const XDG_GREATER_THAN: &str = ">";
type NotificationKey = (PaneId, NotificationScope, NotificationId);
#[derive(Clone)]
enum Delivery {
Show(Notification),
Close {
pane: PaneId,
scope: NotificationScope,
identifier: NotificationId,
},
}
pub struct DesktopNotifier {
sender: Sender<Delivery>,
}
impl DesktopNotifier {
pub fn new() -> Self {
let (sender, receiver) = bounded::<Delivery>(QUEUE_CAPACITY);
thread::spawn(move || {
let mut worker = DeliveryWorker::new(OsBackend);
for delivery in receiver {
worker.deliver(delivery);
}
});
Self { sender }
}
}
impl Default for DesktopNotifier {
fn default() -> Self {
Self::new()
}
}
impl Notifier for DesktopNotifier {
fn notify(&self, notification: &Notification) {
let _ = self.sender.try_send(Delivery::Show(notification.clone()));
}
fn close(&self, pane: PaneId, scope: NotificationScope, identifier: &NotificationId) {
let _ = self.sender.try_send(Delivery::Close {
pane,
scope,
identifier: identifier.clone(),
});
}
}
trait NotificationBackend {
type Handle;
fn show(&mut self, notification: &Notification) -> Option<Self::Handle>;
fn update(&mut self, handle: &mut Self::Handle, notification: &Notification) -> bool;
fn close(&mut self, handle: Self::Handle);
}
struct DeliveryWorker<B: NotificationBackend> {
backend: B,
active: HashMap<NotificationKey, B::Handle>,
}
impl<B: NotificationBackend> DeliveryWorker<B> {
fn new(backend: B) -> Self {
Self {
backend,
active: HashMap::new(),
}
}
fn deliver(&mut self, delivery: Delivery) {
match delivery {
Delivery::Show(notification) => self.show(¬ification),
Delivery::Close {
pane,
scope,
identifier,
} => {
if let Some(handle) = self.active.remove(&(pane, scope, identifier)) {
self.backend.close(handle);
}
},
}
}
fn show(&mut self, notification: &Notification) {
let key = notification
.identifier()
.clone()
.map(|identifier| (*notification.pane(), *notification.scope(), identifier));
if let Some(key) = &key {
let updated = self
.active
.get_mut(key)
.is_some_and(|handle| self.backend.update(handle, notification));
if updated {
return;
}
if let Some(handle) = self.active.remove(key) {
self.backend.close(handle);
}
}
let Some(handle) = self.backend.show(notification) else {
return;
};
let Some(key) = key else {
return;
};
self.evict_if_full();
self.active.insert(key, handle);
}
fn evict_if_full(&mut self) {
if self.active.len() < MAX_ACTIVE_NOTIFICATIONS {
return;
}
let key = self.active.keys().next().cloned();
if let Some(key) = key
&& let Some(handle) = self.active.remove(&key)
{
self.backend.close(handle);
}
}
}
struct OsBackend;
#[cfg(all(unix, not(target_os = "macos")))]
impl NotificationBackend for OsBackend {
type Handle = OsNotificationHandle;
fn show(&mut self, notification: &Notification) -> Option<Self::Handle> {
os_notification(notification).show().ok()
}
fn update(&mut self, handle: &mut Self::Handle, notification: &Notification) -> bool {
configure_os_notification(handle, notification);
handle.update().is_ok()
}
fn close(&mut self, handle: Self::Handle) {
handle.close();
}
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
impl NotificationBackend for OsBackend {
type Handle = ();
fn show(&mut self, notification: &Notification) -> Option<Self::Handle> {
os_notification(notification).show().ok().map(|_| ())
}
fn update(&mut self, _handle: &mut Self::Handle, notification: &Notification) -> bool {
self.show(notification).is_some()
}
fn close(&mut self, _handle: Self::Handle) {}
}
fn os_notification(notification: &Notification) -> OsNotification {
let mut output = OsNotification::new();
configure_os_notification(&mut output, notification);
output
}
fn configure_os_notification(output: &mut OsNotification, notification: &Notification) {
let summary = notification
.title()
.clone()
.unwrap_or_else(|| notification.source().as_ref().to_string());
let body = notification
.body()
.as_deref()
.map(notification_body)
.unwrap_or_default();
output.appname(APP_NAME).summary(&summary).body(&body);
}
#[cfg(all(unix, not(target_os = "macos")))]
fn notification_body(body: &str) -> String {
let mut escaped = String::with_capacity(body.len());
for character in body.chars() {
match character {
'&' => escaped.push_str(XDG_AMPERSAND),
'<' => escaped.push_str(XDG_LESS_THAN),
'>' => escaped.push_str(XDG_GREATER_THAN),
_ => escaped.push(character),
}
}
escaped
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
fn notification_body(body: &str) -> String {
body.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::value::ProcessName;
#[derive(Default)]
struct RecordingBackend {
shown: Vec<Option<String>>,
updated: Vec<(usize, Option<String>)>,
closed: Vec<usize>,
}
impl NotificationBackend for RecordingBackend {
type Handle = usize;
fn show(&mut self, notification: &Notification) -> Option<Self::Handle> {
let handle = self.shown.len();
self.shown.push(notification.body().clone());
Some(handle)
}
fn update(&mut self, handle: &mut Self::Handle, notification: &Notification) -> bool {
self.updated.push((*handle, notification.body().clone()));
true
}
fn close(&mut self, handle: Self::Handle) {
self.closed.push(handle);
}
}
fn notification(
pane: PaneId,
scope: NotificationScope,
identifier: &NotificationId,
body: &str,
) -> Notification {
Notification::builder()
.pane(pane)
.scope(scope)
.source(ProcessName::try_new("worker").unwrap())
.body(Some(body.to_string()))
.identifier(Some(identifier.clone()))
.build()
}
#[test]
fn a_reused_kitty_identifier_updates_then_closes_one_handle() {
let pane = PaneId::new(1);
let scope = NotificationScope::new(1);
let identifier = NotificationId::try_new("build").unwrap();
let mut worker = DeliveryWorker::new(RecordingBackend::default());
worker.deliver(Delivery::Show(notification(
pane,
scope,
&identifier,
"starting",
)));
worker.deliver(Delivery::Show(notification(
pane,
scope,
&identifier,
"finished",
)));
worker.deliver(Delivery::Close {
pane,
scope,
identifier,
});
assert_eq!(worker.backend.shown, [Some("starting".to_string())]);
assert_eq!(worker.backend.updated, [(0, Some("finished".to_string()))]);
assert_eq!(worker.backend.closed, [0]);
assert!(worker.active.is_empty());
}
#[test]
fn identical_identifiers_from_different_panes_keep_separate_handles() {
let first_pane = PaneId::new(1);
let second_pane = PaneId::new(2);
let scope = NotificationScope::new(1);
let identifier = NotificationId::try_new("build").unwrap();
let mut worker = DeliveryWorker::new(RecordingBackend::default());
worker.deliver(Delivery::Show(notification(
first_pane,
scope,
&identifier,
"first",
)));
worker.deliver(Delivery::Show(notification(
second_pane,
scope,
&identifier,
"second",
)));
worker.deliver(Delivery::Show(notification(
first_pane,
scope,
&identifier,
"first update",
)));
worker.deliver(Delivery::Close {
pane: first_pane,
scope,
identifier: identifier.clone(),
});
assert_eq!(worker.backend.shown, [
Some("first".to_string()),
Some("second".to_string())
]);
assert_eq!(worker.backend.updated, [(
0,
Some("first update".to_string())
)]);
assert_eq!(worker.backend.closed, [0]);
assert!(
worker
.active
.contains_key(&(second_pane, scope, identifier))
);
}
#[test]
fn a_reused_pane_identifier_is_independent_in_a_new_terminal_scope() {
let pane = PaneId::new(1);
let old_scope = NotificationScope::new(1);
let new_scope = NotificationScope::new(2);
let identifier = NotificationId::try_new("build").unwrap();
let mut worker = DeliveryWorker::new(RecordingBackend::default());
worker.deliver(Delivery::Show(notification(
pane,
old_scope,
&identifier,
"old project",
)));
worker.deliver(Delivery::Show(notification(
pane,
new_scope,
&identifier,
"new project",
)));
worker.deliver(Delivery::Close {
pane,
scope: new_scope,
identifier: identifier.clone(),
});
assert_eq!(worker.backend.shown, [
Some("old project".to_string()),
Some("new project".to_string())
]);
assert!(worker.backend.updated.is_empty());
assert_eq!(worker.backend.closed, [1]);
assert!(worker.active.contains_key(&(pane, old_scope, identifier)));
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn xdg_delivery_escapes_plain_text_markup_characters() {
assert_eq!(
notification_body("<b>two & three</b>"),
"<b>two & three</b>"
);
}
}