use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use sha2::{Digest, Sha256};
const DEFAULT_DEDUP_TTL_SECS: u64 = 30;
fn dedup_ttl() -> Duration {
let secs = std::env::var("WIRE_TOAST_DEDUP_TTL_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(DEFAULT_DEDUP_TTL_SECS);
Duration::from_secs(secs)
}
fn dedup_cache() -> &'static Mutex<HashMap<String, Instant>> {
use std::sync::OnceLock;
static CACHE: OnceLock<Mutex<HashMap<String, Instant>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
pub(crate) fn should_emit_with(
cache: &mut HashMap<String, Instant>,
key: &str,
now: Instant,
ttl: Duration,
) -> bool {
if ttl.is_zero() {
return true;
}
cache.retain(|_, shown_at| now.duration_since(*shown_at) < ttl);
match cache.get(key) {
Some(_) => false,
None => {
cache.insert(key.to_string(), now);
true
}
}
}
pub fn toast_dedup(key: &str, title: &str, body: &str) {
if toasts_disabled() {
return;
}
let now = Instant::now();
let ttl = dedup_ttl();
let emit_in_proc = {
let mut guard = dedup_cache().lock().unwrap();
should_emit_with(&mut guard, key, now, ttl)
};
if !emit_in_proc {
return;
}
if !claim_cross_process(key) {
return;
}
emit_toast(title, body);
}
fn claim_cross_process(key: &str) -> bool {
let path = match cross_process_dedup_path(key) {
Some(p) => p,
None => return true,
};
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(_) => true,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => false,
Err(_) => true,
}
}
fn cross_process_dedup_path(key: &str) -> Option<PathBuf> {
let cache = dirs::cache_dir()?.join("wire").join("toast-dedup");
std::fs::create_dir_all(&cache).ok()?;
let mut h = Sha256::new();
h.update(key.as_bytes());
let hex = hex::encode(h.finalize());
Some(cache.join(format!("{hex}.touch")))
}
fn toasts_disabled() -> bool {
if std::env::var("WIRE_NO_TOASTS").is_ok_and(|v| !v.is_empty() && v != "0") {
return true;
}
if let Ok(cfg) = crate::config::config_dir()
&& cfg.join("quiet").exists()
{
return true;
}
false
}
#[cfg(test)]
pub(crate) fn _reset_dedup_cache_for_tests() {
dedup_cache().lock().unwrap().clear();
}
pub fn toast(title: &str, body: &str) {
let key = format!("content:{title}\u{1f}{body}");
toast_dedup(&key, title, body);
}
#[cfg(target_os = "linux")]
fn emit_toast(title: &str, body: &str) {
let _ = std::process::Command::new("notify-send")
.arg("--app-name=wire")
.arg("--icon=mail-message-new")
.arg(title)
.arg(body)
.output();
}
#[cfg(target_os = "macos")]
fn emit_toast(title: &str, body: &str) {
let safe = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
let script = format!(
"display notification \"{}\" with title \"{}\"",
safe(body),
safe(title),
);
let _ = std::process::Command::new("osascript")
.arg("-e")
.arg(script)
.output();
}
#[cfg(target_os = "windows")]
fn emit_toast(title: &str, body: &str) {
eprintln!("[wire notify] {title}\n {body}");
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn emit_toast(title: &str, body: &str) {
eprintln!("[wire notify] {title}\n {body}");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_false_in_clean_env_and_dir() {
crate::config::test_support::with_temp_home(|| {
unsafe { std::env::remove_var("WIRE_NO_TOASTS") };
assert!(!toasts_disabled());
});
}
#[test]
fn disabled_true_when_env_set() {
crate::config::test_support::with_temp_home(|| {
unsafe { std::env::set_var("WIRE_NO_TOASTS", "1") };
let disabled = toasts_disabled();
unsafe { std::env::remove_var("WIRE_NO_TOASTS") };
assert!(disabled);
});
}
#[test]
fn disabled_true_when_quiet_flag_file_present() {
crate::config::test_support::with_temp_home(|| {
unsafe { std::env::remove_var("WIRE_NO_TOASTS") };
let home = std::env::var("WIRE_HOME").unwrap();
let cfg = std::path::PathBuf::from(&home).join("config").join("wire");
std::fs::create_dir_all(&cfg).unwrap();
std::fs::write(cfg.join("quiet"), b"").unwrap();
assert!(toasts_disabled());
});
}
#[test]
fn env_var_zero_string_does_not_silence() {
crate::config::test_support::with_temp_home(|| {
unsafe { std::env::set_var("WIRE_NO_TOASTS", "0") };
let disabled = toasts_disabled();
unsafe { std::env::remove_var("WIRE_NO_TOASTS") };
assert!(!disabled);
});
}
#[test]
fn first_emission_for_a_key_passes() {
let mut cache = HashMap::new();
let t0 = Instant::now();
assert!(should_emit_with(
&mut cache,
"evt-1",
t0,
Duration::from_secs(30),
));
assert_eq!(cache.len(), 1);
}
#[test]
fn repeat_within_ttl_is_suppressed() {
let mut cache = HashMap::new();
let t0 = Instant::now();
let ttl = Duration::from_secs(30);
assert!(should_emit_with(&mut cache, "evt-1", t0, ttl));
let later = t0 + Duration::from_secs(5);
assert!(!should_emit_with(&mut cache, "evt-1", later, ttl));
}
#[test]
fn repeat_after_ttl_re_emits() {
let mut cache = HashMap::new();
let t0 = Instant::now();
let ttl = Duration::from_secs(30);
assert!(should_emit_with(&mut cache, "evt-1", t0, ttl));
let later = t0 + Duration::from_secs(31);
assert!(should_emit_with(&mut cache, "evt-1", later, ttl));
}
#[test]
fn different_keys_each_emit() {
let mut cache = HashMap::new();
let t0 = Instant::now();
let ttl = Duration::from_secs(30);
assert!(should_emit_with(&mut cache, "evt-1", t0, ttl));
assert!(should_emit_with(&mut cache, "evt-2", t0, ttl));
assert_eq!(cache.len(), 2);
}
#[test]
fn zero_ttl_disables_dedup() {
let mut cache = HashMap::new();
let t0 = Instant::now();
assert!(should_emit_with(&mut cache, "evt-1", t0, Duration::ZERO));
assert!(should_emit_with(&mut cache, "evt-1", t0, Duration::ZERO));
assert!(cache.is_empty(), "zero-ttl must not touch the cache");
}
#[test]
fn expired_entries_are_garbage_collected_on_access() {
let mut cache = HashMap::new();
let t0 = Instant::now();
let ttl = Duration::from_secs(30);
assert!(should_emit_with(&mut cache, "stale-1", t0, ttl));
assert!(should_emit_with(&mut cache, "stale-2", t0, ttl));
let later = t0 + Duration::from_secs(120);
assert!(should_emit_with(&mut cache, "fresh", later, ttl));
assert_eq!(
cache.len(),
1,
"expired keys must be evicted on the next emit"
);
assert!(cache.contains_key("fresh"));
}
#[test]
fn toast_dedup_public_api_suppresses_repeat() {
_reset_dedup_cache_for_tests();
let key = "wire-test::toast_dedup_public_api_suppresses_repeat";
toast_dedup(key, "first", "body");
let len_after_first = dedup_cache().lock().unwrap().len();
toast_dedup(key, "second", "body");
let len_after_second = dedup_cache().lock().unwrap().len();
assert_eq!(
len_after_first, len_after_second,
"second emission with the same key must not grow the cache",
);
assert_eq!(len_after_first, 1);
}
}