Skip to main content

wire/
os_notify.rs

1//! Cross-platform best-effort desktop notifications.
2//!
3//! Each backend shells out to the native binary (notify-send / osascript /
4//! powershell). Failures are swallowed — we'd rather lose a toast than crash
5//! the caller. Used by both `wire notify` (inbox events) and the daemon's
6//! pending-pair tick (SAS-ready, pair-confirmed).
7//!
8//! ## Once-only across every wire process on the host
9//!
10//! Pre-v0.14.2: dedup was an in-process `Mutex<HashMap>` with 30s TTL.
11//! That fell apart catastrophically once #170's `--all-sessions`
12//! supervisor put **134 wire daemons** on a single box: every daemon
13//! polled its own inbox, every daemon fired its own toast, the operator
14//! saw the same "pair complete" notification 134 times within seconds.
15//!
16//! v0.14.2: every toast — both [`toast`] (now content-keyed) and
17//! [`toast_dedup`] (explicit-key) — first acquires a process-shared
18//! "first-emit" claim via an atomic `O_CREAT|O_EXCL` filesystem touch
19//! file under `<cache_dir>/wire/toast-dedup/<sha256(key)>.touch`. The
20//! filesystem is the dedup primitive — no flock, no race window. Once
21//! a touch file exists, no wire process anywhere on the host re-emits
22//! the same toast — **ever**. Operators who want to reset (e.g., to
23//! re-see a notification class after a code change) `rm -rf` the dir.
24//!
25//! The in-process `Mutex<HashMap>` survives as a fast-path inside one
26//! process so the per-toast fs-stat doesn't dominate the loop, but the
27//! filesystem claim is the actual guarantee.
28
29use std::collections::HashMap;
30use std::path::PathBuf;
31use std::sync::Mutex;
32use std::time::{Duration, Instant};
33
34use sha2::{Digest, Sha256};
35
36/// Default TTL for the in-process toast-dedup LRU. Overridable via
37/// `WIRE_TOAST_DEDUP_TTL_SECS` (set to `0` to disable dedup entirely —
38/// useful when chasing notification regressions).
39const 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
55/// Pure decision: should we emit a toast for `key` right now? Mutates the
56/// supplied cache (recording the new "shown_at" if we return `true`, and
57/// opportunistically evicting expired entries so the map doesn't grow
58/// unbounded across a long-running daemon).
59///
60/// Behaviour:
61/// - `ttl == Duration::ZERO` → dedup disabled, always emit (cache untouched).
62/// - `key` absent or its entry expired → emit + record `now`.
63/// - `key` present and entry not yet expired → suppress.
64pub(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
83/// Idempotent variant of [`toast`]: emits at most once per `key`,
84/// **forever, across every wire process on the host**.
85///
86/// `key` should encode whatever uniquely identifies the notification's
87/// underlying event. For inbox toasts: `format!("{peer}:{event_id}")`.
88/// For pending-pair state transitions: `format!("pair:{code}:{status}")`.
89///
90/// Two-stage check:
91/// 1. In-process LRU (Mutex<HashMap>, 30s TTL by default — controlled
92///    via `WIRE_TOAST_DEDUP_TTL_SECS`). Fast-path so a busy daemon
93///    polling its own inbox doesn't stat the filesystem on every
94///    inbound event.
95/// 2. **Cross-process atomic claim** via `O_CREAT|O_EXCL` on a
96///    sha256-named file under `<cache_dir>/wire/toast-dedup/`. Once
97///    the file exists, no wire process anywhere on the host emits
98///    again for that key. No TTL — operators rm the directory to
99///    reset.
100pub 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
119/// Cross-process atomic "are we the first emitter for this key?"
120/// claim. Computes
121/// `<cache_dir>/wire/toast-dedup/<sha256(key)>.touch` and tries to
122/// create it with `O_CREAT|O_EXCL`. Returns `true` iff we won the
123/// race (or the dedup-dir is unreachable and we should fall back to
124/// the in-process check alone). Returns `false` only when we
125/// observe an `AlreadyExists` for the same key — a confirmed prior
126/// claim.
127///
128/// Fail-soft: any other filesystem error (perms, EROFS, …) → return
129/// `true` so the toast still fires. We'd rather see a duplicate
130/// once than miss a notification entirely.
131fn 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
156/// v0.14.x kill switch: the operator silences ALL wire desktop toasts by
157/// either (a) `wire quiet on` — which touches `<config_dir>/quiet` — or
158/// (b) exporting `WIRE_NO_TOASTS=1` in the daemon's environment (e.g. via
159/// `launchctl setenv WIRE_NO_TOASTS 1` then `wire upgrade --local`).
160///
161/// Checked at every `toast`/`toast_dedup` entry. The file check is a
162/// per-call `fs::metadata` stat (cheap; bounded by the 30s dedup TTL); the
163/// env check is a `std::env::var`. Either match ⇒ no-op return; nothing
164/// shells out to `osascript`/`notify-send`/`powershell`.
165///
166/// Intentionally bypasses dedup — disabled means disabled, no leakage.
167/// Note: callers that DROP a notification because of this guard MUST NOT
168/// also bail their downstream side effects (pending stash still runs,
169/// receive path still pins per policy, etc.) — the toast is the ONLY
170/// thing suppressed.
171fn 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/// Test-only escape hatch: empty the in-process dedup cache.
184#[cfg(test)]
185pub(crate) fn _reset_dedup_cache_for_tests() {
186    dedup_cache().lock().unwrap().clear();
187}
188
189/// Bare toast — fires once per (title, body) content across every
190/// wire process on the host. Defers to [`toast_dedup`] with a
191/// content-hash key so legacy call sites that don't have a stable
192/// event-id key still get the cross-process once-only guarantee.
193///
194/// v0.14.x kill switch (`wire quiet on` / `WIRE_NO_TOASTS=1`) is
195/// honored here too — disabled means disabled, no leakage.
196pub 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    // v0.14.x kill-switch tests. `toasts_disabled` is process-state-leaky
240    // (reads WIRE_HOME, WIRE_NO_TOASTS, and a filesystem flag), so each
241    // test runs inside `with_temp_home` — it holds the shared ENV_LOCK and
242    // pins a throwaway WIRE_HOME, making them hermetic under the default
243    // parallel test runner instead of requiring --test-threads=1.
244    #[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        // Goes through `with_temp_home` so it holds the shared ENV_LOCK
255        // while mutating WIRE_HOME / WIRE_NO_TOASTS — otherwise it races
256        // every other test that reads those process-global vars (the
257        // source of the suite's parallel-run flakiness). Read the result
258        // before removing the var so cleanup survives an assert failure.
259        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            // "0" / empty is operator-explicit "off"; respect it.
284            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}