use std::fmt;
use std::path::PathBuf;
#[cfg(desktop)]
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(desktop)]
use std::sync::Mutex;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SystemNotification {
pub(crate) title: String,
pub(crate) body: String,
pub(crate) buttons: Vec<(String, String)>,
pub(crate) image: Option<PathBuf>,
pub(crate) silent: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NotificationResponse {
Clicked,
Button(String),
Closed,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NotificationError(pub(crate) String);
impl fmt::Display for NotificationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for NotificationError {}
impl SystemNotification {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
..Self::default()
}
}
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = body.into();
self
}
pub fn button(mut self, id: impl Into<String>, label: impl Into<String>) -> Self {
self.buttons.push((id.into(), label.into()));
self
}
pub fn image(mut self, path: impl Into<PathBuf>) -> Self {
self.image = Some(path.into());
self
}
pub fn silent(mut self) -> Self {
self.silent = true;
self
}
pub async fn show_async(self) -> Result<NotificationResponse, NotificationError> {
#[cfg(web)]
{
log::debug!("notification {:?}: showing", self.title);
let answer = crate::notification_web::show(&self).await;
log::debug!("notification {:?}: {answer:?}", self.title);
answer
}
#[cfg(not(web))]
{
blocking::unblock(move || self.show()).await
}
}
#[cfg(not(web))]
pub fn show(self) -> Result<NotificationResponse, NotificationError> {
if crate::interface_thread::current() {
let error = "SystemNotification::show was called on the interface's thread, which \
it would stop: call it from Command::perform, or from spawn in a \
component";
log::error!("{error}");
return Err(NotificationError(error.to_owned()));
}
log::debug!("notification {:?}: showing", self.title);
#[cfg(android)]
let answer = crate::android_notification::show(&self)?;
#[cfg(desktop)]
let answer = self.show_desktop()?;
log::debug!("notification {:?}: {answer:?}", self.title);
Ok(answer)
}
#[cfg(desktop)]
fn show_desktop(&self) -> Result<NotificationResponse, NotificationError> {
let sender = SENDER.lock().ok().and_then(|sender| sender.clone());
let mut native = notify_rust::Notification::new();
native.summary(&self.title).body(&self.body);
for (id, label) in &self.buttons {
native.action(&format!("{BUTTON}{id}"), label);
}
if let Some(image) = &self.image {
native.image_path(&image.to_string_lossy());
}
if let Some(sound) = sound(self.silent) {
native.sound_name(sound);
}
#[cfg(all(unix, not(target_os = "macos")))]
if self.silent {
native.hint(notify_rust::Hint::SuppressSound(true));
}
#[cfg(windows)]
if let Some(id) = sender.as_ref().and_then(windows::registered) {
native.app_id(&id);
}
#[cfg(all(unix, not(target_os = "macos")))]
{
native.action("default", "Open");
if let Some(sender) = &sender {
native.appname(&sender.name);
if let Some(icon) = sender.icon_file() {
native.icon(&icon.to_string_lossy());
}
}
}
#[cfg(target_os = "macos")]
{
let _ = &sender;
if let Ok(bundle) = std::env::var("__CFBundleIdentifier") {
let _ = notify_rust::set_application(&bundle);
}
}
let error = |e: notify_rust::error::Error| NotificationError(e.to_string());
let mut answer = NotificationResponse::Closed;
native
.show()
.map_err(error)?
.wait_for_response(|platform: ¬ify_rust::NotificationResponse| {
answer = response(platform);
})
.map_err(error)?;
if answer == NotificationResponse::Clicked {
RAISE.store(true, Ordering::Relaxed);
}
Ok(answer)
}
}
#[cfg_attr(web, allow(dead_code))]
const BUTTON: &str = "button:";
#[cfg_attr(web, allow(dead_code))]
pub(crate) fn button(action: &str) -> Option<NotificationResponse> {
action
.strip_prefix(BUTTON)
.map(|id| NotificationResponse::Button(id.to_owned()))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(not(android), allow(dead_code))]
pub(crate) enum Question {
Open,
Settled,
Unanswered,
}
#[cfg_attr(not(android), allow(dead_code))]
pub(crate) const NO_DIALOG: std::time::Duration = std::time::Duration::from_secs(2);
#[cfg_attr(not(android), allow(dead_code))]
pub(crate) const ASKING: std::time::Duration = std::time::Duration::from_secs(60);
#[cfg_attr(not(android), allow(dead_code))]
pub(crate) fn question(
allowed: i32,
focused: bool,
was_away: bool,
elapsed: std::time::Duration,
) -> Question {
if allowed != 0 || (was_away && focused) || (!was_away && elapsed >= NO_DIALOG) {
Question::Settled
} else if elapsed >= ASKING {
Question::Unanswered
} else {
Question::Open
}
}
#[cfg_attr(not(android), allow(dead_code))]
pub(crate) fn left_the_shade(recorded: Option<&str>) -> NotificationResponse {
match recorded {
None => NotificationResponse::Clicked,
Some(action) => button(action).unwrap_or(NotificationResponse::Closed),
}
}
#[cfg(desktop)]
fn response(platform: ¬ify_rust::NotificationResponse) -> NotificationResponse {
use notify_rust::NotificationResponse as Platform;
match platform {
Platform::Closed(_) => NotificationResponse::Closed,
Platform::Action(action) => button(action).unwrap_or(NotificationResponse::Clicked),
_ => NotificationResponse::Clicked,
}
}
#[cfg(desktop)]
fn sound(silent: bool) -> Option<&'static str> {
if silent || cfg!(not(any(windows, target_os = "macos"))) {
None
} else if cfg!(windows) {
Some("Default")
} else {
Some("NSUserNotificationDefaultSoundName")
}
}
#[cfg(desktop)]
#[derive(Clone, Debug)]
struct Sender {
name: String,
icon: Option<Vec<u8>>,
}
#[cfg(desktop)]
#[cfg_attr(target_os = "macos", allow(dead_code))]
impl Sender {
fn icon_file(&self) -> Option<PathBuf> {
let png = self.icon.as_deref()?;
let dir = std::env::current_exe().ok().and_then(|exe| {
crate::cache_dir::desktop(
std::env::consts::OS,
|name| std::env::var_os(name),
exe.file_stem()?,
)
})?;
let path = dir.join("notification-icon.png");
if std::fs::read(&path).ok().as_deref() != Some(png) {
std::fs::create_dir_all(&dir).ok()?;
std::fs::write(&path, png).ok()?;
}
Some(path)
}
}
#[cfg(desktop)]
static SENDER: Mutex<Option<Sender>> = Mutex::new(None);
#[cfg(desktop)]
static RAISE: AtomicBool = AtomicBool::new(false);
#[cfg(desktop)]
pub(crate) fn window_made(title: &str, icon: Option<&[u8]>) {
if let Ok(mut slot) = SENDER.lock() {
*slot = Some(Sender {
name: title.to_owned(),
icon: icon.map(<[u8]>::to_vec),
});
}
}
#[cfg(desktop)]
pub(crate) fn take_raise() -> bool {
RAISE.swap(false, Ordering::Relaxed)
}
#[cfg(windows)]
mod windows {
use std::sync::OnceLock;
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{RegSetKeyValueW, HKEY_CURRENT_USER, REG_SZ};
use super::Sender;
pub(super) fn registered(sender: &Sender) -> Option<String> {
static ID: OnceLock<Option<String>> = OnceLock::new();
ID.get_or_init(|| {
let exe = std::env::current_exe().ok()?;
let id = app_id(&exe.file_stem()?.to_string_lossy());
let icon = sender.icon_file();
let mut values = vec![("DisplayName", sender.name.clone())];
if let Some(icon) = icon {
values.push(("IconUri", icon.to_string_lossy().into_owned()));
}
match write(&format!(r"Software\Classes\AppUserModelId\{id}"), &values) {
Ok(()) => Some(id),
Err(status) => {
log::warn!("notifications: could not record the application ({status})");
None
}
}
})
.clone()
}
pub(super) fn app_id(program: &str) -> String {
let name: String = program
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.take(100)
.collect();
format!("frus.app.{name}")
}
fn wide(text: &str) -> Vec<u16> {
text.encode_utf16().chain(Some(0)).collect()
}
fn write(path: &str, values: &[(&str, String)]) -> Result<(), u32> {
let path = wide(path);
for (name, value) in values {
let data = wide(value);
let status = unsafe {
RegSetKeyValueW(
HKEY_CURRENT_USER,
path.as_ptr(),
wide(name).as_ptr(),
REG_SZ,
data.as_ptr().cast(),
(data.len() * 2) as u32,
)
};
if status != ERROR_SUCCESS {
return Err(status);
}
}
Ok(())
}
}
#[cfg(all(test, desktop))]
mod tests {
use super::*;
#[test]
fn the_description_is_kept() {
let notification = SystemNotification::new("Water the plants").body("Due today");
assert_eq!(notification.title, "Water the plants");
assert_eq!(notification.body, "Due today");
assert_eq!(SystemNotification::new("Done").body, "");
}
#[test]
fn buttons_picture_and_silence_are_kept() {
let notification = SystemNotification::new("Water the plants")
.button("done", "Done")
.button("snooze", "Later")
.image("/pictures/plant.png")
.silent();
assert_eq!(
notification.buttons,
vec![
("done".to_owned(), "Done".to_owned()),
("snooze".to_owned(), "Later".to_owned()),
]
);
assert_eq!(
notification.image,
Some(PathBuf::from("/pictures/plant.png"))
);
assert!(notification.silent);
let plain = SystemNotification::new("Done");
assert!(plain.buttons.is_empty() && plain.image.is_none() && !plain.silent);
}
#[test]
fn what_the_system_reports_is_read() {
use notify_rust::{CloseReason, NotificationResponse as Platform};
assert_eq!(response(&Platform::Default), NotificationResponse::Clicked);
assert_eq!(
response(&Platform::Action("default".into())),
NotificationResponse::Clicked
);
assert_eq!(
response(&Platform::Action(format!("{BUTTON}snooze"))),
NotificationResponse::Button("snooze".into())
);
assert_eq!(
response(&Platform::Action(format!("{BUTTON}default"))),
NotificationResponse::Button("default".into())
);
assert_eq!(
response(&Platform::Reply("ok".into())),
NotificationResponse::Clicked
);
for reason in [
CloseReason::Expired,
CloseReason::Dismissed,
CloseReason::CloseAction,
CloseReason::Other(4),
] {
assert_eq!(
response(&Platform::Closed(reason)),
NotificationResponse::Closed
);
}
}
#[test]
fn the_interface_thread_is_refused() {
if !crate::interface_thread::claim_for_test() {
return; }
let refused = SystemNotification::new("Never shown").show();
assert!(
refused
.as_ref()
.is_err_and(|e| e.to_string().contains("Command::perform")),
"{refused:?}"
);
}
#[test]
fn the_question_is_settled_by_the_focus_coming_back() {
use std::time::Duration;
let soon = Duration::from_millis(500);
assert_eq!(question(1, true, false, soon), Question::Settled);
assert_eq!(question(-1, true, false, soon), Question::Settled);
assert_eq!(question(0, false, true, soon), Question::Open);
assert_eq!(
question(0, false, true, Duration::from_secs(30)),
Question::Open
);
assert_eq!(question(0, true, true, soon), Question::Settled);
assert_eq!(question(0, true, false, soon), Question::Open);
assert_eq!(question(0, true, false, NO_DIALOG), Question::Settled);
assert_eq!(question(0, false, true, ASKING), Question::Unanswered);
}
#[test]
fn what_left_the_shade_is_read() {
assert_eq!(left_the_shade(None), NotificationResponse::Clicked);
assert_eq!(left_the_shade(Some("closed")), NotificationResponse::Closed);
assert_eq!(
left_the_shade(Some(&format!("{BUTTON}done"))),
NotificationResponse::Button("done".into())
);
assert_eq!(left_the_shade(Some("other")), NotificationResponse::Closed);
}
#[test]
fn the_systems_sound_unless_silent() {
assert_eq!(sound(true), None);
let expected = if cfg!(windows) {
Some("Default")
} else if cfg!(target_os = "macos") {
Some("NSUserNotificationDefaultSoundName")
} else {
None
};
assert_eq!(sound(false), expected);
}
#[test]
fn a_raise_is_taken_once() {
RAISE.store(true, Ordering::Relaxed);
assert!(take_raise());
assert!(!take_raise());
}
#[cfg(windows)]
#[test]
fn the_identity_is_the_programs_name() {
assert_eq!(windows::app_id("frus-demo"), "frus.app.frus-demo");
assert_eq!(windows::app_id("my app.v2"), "frus.app.my-app-v2");
assert_eq!(windows::app_id(&"x".repeat(300)).len(), 109);
}
}