1use std::collections::HashMap;
30use std::path::PathBuf;
31use std::sync::Mutex;
32use std::time::{Duration, Instant};
33
34use sha2::{Digest, Sha256};
35
36const DEFAULT_DEDUP_TTL_SECS: u64 = 30;
40
41fn dedup_ttl() -> Duration {
42 let secs = std::env::var("WIRE_TOAST_DEDUP_TTL_SECS")
43 .ok()
44 .and_then(|s| s.parse::<u64>().ok())
45 .unwrap_or(DEFAULT_DEDUP_TTL_SECS);
46 Duration::from_secs(secs)
47}
48
49fn dedup_cache() -> &'static Mutex<HashMap<String, Instant>> {
50 use std::sync::OnceLock;
51 static CACHE: OnceLock<Mutex<HashMap<String, Instant>>> = OnceLock::new();
52 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
53}
54
55pub(crate) fn should_emit_with(
65 cache: &mut HashMap<String, Instant>,
66 key: &str,
67 now: Instant,
68 ttl: Duration,
69) -> bool {
70 if ttl.is_zero() {
71 return true;
72 }
73 cache.retain(|_, shown_at| now.duration_since(*shown_at) < ttl);
74 match cache.get(key) {
75 Some(_) => false,
76 None => {
77 cache.insert(key.to_string(), now);
78 true
79 }
80 }
81}
82
83pub fn toast_dedup(key: &str, title: &str, body: &str) {
101 if toasts_disabled() {
102 return;
103 }
104 let now = Instant::now();
105 let ttl = dedup_ttl();
106 let emit_in_proc = {
107 let mut guard = dedup_cache().lock().unwrap();
108 should_emit_with(&mut guard, key, now, ttl)
109 };
110 if !emit_in_proc {
111 return;
112 }
113 if !claim_cross_process(key) {
114 return;
115 }
116 emit_toast(title, body);
117}
118
119fn claim_cross_process(key: &str) -> bool {
132 let path = match cross_process_dedup_path(key) {
133 Some(p) => p,
134 None => return true,
135 };
136 match std::fs::OpenOptions::new()
137 .write(true)
138 .create_new(true)
139 .open(&path)
140 {
141 Ok(_) => true,
142 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => false,
143 Err(_) => true,
144 }
145}
146
147fn cross_process_dedup_path(key: &str) -> Option<PathBuf> {
148 let cache = dirs::cache_dir()?.join("wire").join("toast-dedup");
149 std::fs::create_dir_all(&cache).ok()?;
150 let mut h = Sha256::new();
151 h.update(key.as_bytes());
152 let hex = hex::encode(h.finalize());
153 Some(cache.join(format!("{hex}.touch")))
154}
155
156fn toasts_disabled() -> bool {
172 if std::env::var("WIRE_NO_TOASTS").is_ok_and(|v| !v.is_empty() && v != "0") {
173 return true;
174 }
175 if let Ok(cfg) = crate::config::config_dir()
176 && cfg.join("quiet").exists()
177 {
178 return true;
179 }
180 false
181}
182
183#[cfg(test)]
185pub(crate) fn _reset_dedup_cache_for_tests() {
186 dedup_cache().lock().unwrap().clear();
187}
188
189pub fn toast(title: &str, body: &str) {
197 let key = format!("content:{title}\u{1f}{body}");
198 toast_dedup(&key, title, body);
199}
200
201#[cfg(target_os = "linux")]
202fn emit_toast(title: &str, body: &str) {
203 let _ = std::process::Command::new("notify-send")
204 .arg("--app-name=wire")
205 .arg("--icon=mail-message-new")
206 .arg(title)
207 .arg(body)
208 .output();
209}
210
211#[cfg(target_os = "macos")]
212fn emit_toast(title: &str, body: &str) {
213 let safe = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
214 let script = format!(
215 "display notification \"{}\" with title \"{}\"",
216 safe(body),
217 safe(title),
218 );
219 let _ = std::process::Command::new("osascript")
220 .arg("-e")
221 .arg(script)
222 .output();
223}
224
225#[cfg(target_os = "windows")]
226fn emit_toast(title: &str, body: &str) {
227 eprintln!("[wire notify] {title}\n {body}");
228}
229
230#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
231fn emit_toast(title: &str, body: &str) {
232 eprintln!("[wire notify] {title}\n {body}");
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
245 fn disabled_false_in_clean_env_and_dir() {
246 crate::config::test_support::with_temp_home(|| {
247 unsafe { std::env::remove_var("WIRE_NO_TOASTS") };
248 assert!(!toasts_disabled());
249 });
250 }
251
252 #[test]
253 fn disabled_true_when_env_set() {
254 crate::config::test_support::with_temp_home(|| {
260 unsafe { std::env::set_var("WIRE_NO_TOASTS", "1") };
261 let disabled = toasts_disabled();
262 unsafe { std::env::remove_var("WIRE_NO_TOASTS") };
263 assert!(disabled);
264 });
265 }
266
267 #[test]
268 fn disabled_true_when_quiet_flag_file_present() {
269 crate::config::test_support::with_temp_home(|| {
270 unsafe { std::env::remove_var("WIRE_NO_TOASTS") };
271 let home = std::env::var("WIRE_HOME").unwrap();
272 let cfg = std::path::PathBuf::from(&home).join("config").join("wire");
273 std::fs::create_dir_all(&cfg).unwrap();
274 std::fs::write(cfg.join("quiet"), b"").unwrap();
275 assert!(toasts_disabled());
276 });
277 }
278
279 #[test]
280 fn env_var_zero_string_does_not_silence() {
281 crate::config::test_support::with_temp_home(|| {
282 unsafe { std::env::set_var("WIRE_NO_TOASTS", "0") };
283 let disabled = toasts_disabled();
285 unsafe { std::env::remove_var("WIRE_NO_TOASTS") };
286 assert!(!disabled);
287 });
288 }
289
290 #[test]
291 fn first_emission_for_a_key_passes() {
292 let mut cache = HashMap::new();
293 let t0 = Instant::now();
294 assert!(should_emit_with(
295 &mut cache,
296 "evt-1",
297 t0,
298 Duration::from_secs(30),
299 ));
300 assert_eq!(cache.len(), 1);
301 }
302
303 #[test]
304 fn repeat_within_ttl_is_suppressed() {
305 let mut cache = HashMap::new();
306 let t0 = Instant::now();
307 let ttl = Duration::from_secs(30);
308 assert!(should_emit_with(&mut cache, "evt-1", t0, ttl));
309 let later = t0 + Duration::from_secs(5);
310 assert!(!should_emit_with(&mut cache, "evt-1", later, ttl));
311 }
312
313 #[test]
314 fn repeat_after_ttl_re_emits() {
315 let mut cache = HashMap::new();
316 let t0 = Instant::now();
317 let ttl = Duration::from_secs(30);
318 assert!(should_emit_with(&mut cache, "evt-1", t0, ttl));
319 let later = t0 + Duration::from_secs(31);
320 assert!(should_emit_with(&mut cache, "evt-1", later, ttl));
321 }
322
323 #[test]
324 fn different_keys_each_emit() {
325 let mut cache = HashMap::new();
326 let t0 = Instant::now();
327 let ttl = Duration::from_secs(30);
328 assert!(should_emit_with(&mut cache, "evt-1", t0, ttl));
329 assert!(should_emit_with(&mut cache, "evt-2", t0, ttl));
330 assert_eq!(cache.len(), 2);
331 }
332
333 #[test]
334 fn zero_ttl_disables_dedup() {
335 let mut cache = HashMap::new();
336 let t0 = Instant::now();
337 assert!(should_emit_with(&mut cache, "evt-1", t0, Duration::ZERO));
338 assert!(should_emit_with(&mut cache, "evt-1", t0, Duration::ZERO));
339 assert!(cache.is_empty(), "zero-ttl must not touch the cache");
340 }
341
342 #[test]
343 fn expired_entries_are_garbage_collected_on_access() {
344 let mut cache = HashMap::new();
345 let t0 = Instant::now();
346 let ttl = Duration::from_secs(30);
347 assert!(should_emit_with(&mut cache, "stale-1", t0, ttl));
348 assert!(should_emit_with(&mut cache, "stale-2", t0, ttl));
349 let later = t0 + Duration::from_secs(120);
350 assert!(should_emit_with(&mut cache, "fresh", later, ttl));
351 assert_eq!(
352 cache.len(),
353 1,
354 "expired keys must be evicted on the next emit"
355 );
356 assert!(cache.contains_key("fresh"));
357 }
358
359 #[test]
360 fn toast_dedup_public_api_suppresses_repeat() {
361 _reset_dedup_cache_for_tests();
362 let key = "wire-test::toast_dedup_public_api_suppresses_repeat";
363 toast_dedup(key, "first", "body");
364 let len_after_first = dedup_cache().lock().unwrap().len();
365 toast_dedup(key, "second", "body");
366 let len_after_second = dedup_cache().lock().unwrap().len();
367 assert_eq!(
368 len_after_first, len_after_second,
369 "second emission with the same key must not grow the cache",
370 );
371 assert_eq!(len_after_first, 1);
372 }
373}