Skip to main content

navi_core/
notify.rs

1//! Cross-platform desktop notifications and URL opening.
2//!
3//! Used by the TUI, CLI, and SDK so host apps (desktop, browser via events)
4//! can surface user-visible alerts consistently.
5
6use std::process::Command;
7
8/// Urgency / priority for a notification.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum NotificationUrgency {
12    Low,
13    #[default]
14    Normal,
15    Critical,
16}
17
18impl NotificationUrgency {
19    pub fn as_str(self) -> &'static str {
20        match self {
21            Self::Low => "low",
22            Self::Normal => "normal",
23            Self::Critical => "critical",
24        }
25    }
26}
27
28/// A desktop / OS notification request.
29#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
30pub struct NotifyRequest {
31    pub title: String,
32    pub body: String,
33    #[serde(default)]
34    pub urgency: NotificationUrgency,
35    /// Optional category for clients (e.g. `update`, `turn_complete`, `error`).
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub category: Option<String>,
38}
39
40impl NotifyRequest {
41    pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
42        Self {
43            title: title.into(),
44            body: body.into(),
45            urgency: NotificationUrgency::Normal,
46            category: None,
47        }
48    }
49
50    pub fn with_urgency(mut self, urgency: NotificationUrgency) -> Self {
51        self.urgency = urgency;
52        self
53    }
54
55    pub fn with_category(mut self, category: impl Into<String>) -> Self {
56        self.category = Some(category.into());
57        self
58    }
59}
60
61/// Show a desktop notification on the current OS.
62///
63/// Falls back to platform tools (`notify-send`, `osascript`, PowerShell toast)
64/// so we stay dependency-light and work on headless CI (returns `Ok` if the
65/// notifier is missing — notifications are best-effort).
66pub fn notify_desktop(req: &NotifyRequest) -> anyhow::Result<()> {
67    match std::env::consts::OS {
68        "linux" => notify_linux(req),
69        "macos" => notify_macos(req),
70        "windows" => notify_windows(req),
71        other => {
72            tracing::debug!(os = other, "desktop notifications not supported on this OS");
73            Ok(())
74        }
75    }
76}
77
78/// Open a URL in the user's default browser (or associated handler).
79pub fn open_url(url: &str) -> anyhow::Result<()> {
80    let url = url.trim();
81    if url.is_empty() {
82        anyhow::bail!("empty URL");
83    }
84    // Basic safety: only allow http(s) and a few known schemes.
85    let ok = url.starts_with("https://")
86        || url.starts_with("http://")
87        || url.starts_with("mailto:")
88        || url.starts_with("file://");
89    if !ok {
90        anyhow::bail!("refusing to open non-http(s) URL: {url}");
91    }
92    match std::env::consts::OS {
93        "linux" => {
94            // xdg-open is the portable Linux path.
95            let status = Command::new("xdg-open").arg(url).status();
96            match status {
97                Ok(s) if s.success() => Ok(()),
98                Ok(s) => anyhow::bail!("xdg-open exited with {s}"),
99                Err(err) => anyhow::bail!("xdg-open failed: {err}"),
100            }
101        }
102        "macos" => {
103            let status = Command::new("open").arg(url).status();
104            match status {
105                Ok(s) if s.success() => Ok(()),
106                Ok(s) => anyhow::bail!("open exited with {s}"),
107                Err(err) => anyhow::bail!("open failed: {err}"),
108            }
109        }
110        "windows" => {
111            // `start` is a cmd builtin; use cmd /C start "" url
112            let status = Command::new("cmd").args(["/C", "start", "", url]).status();
113            match status {
114                Ok(s) if s.success() => Ok(()),
115                Ok(s) => anyhow::bail!("cmd start exited with {s}"),
116                Err(err) => anyhow::bail!("cmd start failed: {err}"),
117            }
118        }
119        other => anyhow::bail!("open_url not supported on {other}"),
120    }
121}
122
123fn notify_linux(req: &NotifyRequest) -> anyhow::Result<()> {
124    // Prefer notify-send (libnotify). Missing tool is not fatal.
125    if !command_exists("notify-send") {
126        tracing::debug!("notify-send not found; skipping desktop notification");
127        return Ok(());
128    }
129    let mut cmd = Command::new("notify-send");
130    cmd.arg(format!("--urgency={}", req.urgency.as_str()));
131    cmd.arg("--app-name=NAVI");
132    if let Some(cat) = &req.category {
133        cmd.arg(format!("--category={cat}"));
134    }
135    cmd.arg(&req.title);
136    cmd.arg(&req.body);
137    match cmd.status() {
138        Ok(s) if s.success() => Ok(()),
139        Ok(s) => {
140            tracing::debug!(?s, "notify-send non-zero exit");
141            Ok(())
142        }
143        Err(err) => {
144            tracing::debug!(%err, "notify-send failed");
145            Ok(())
146        }
147    }
148}
149
150fn notify_macos(req: &NotifyRequest) -> anyhow::Result<()> {
151    // Notification Center via osascript.
152    let title = escape_applescript(&req.title);
153    let body = escape_applescript(&req.body);
154    let script =
155        format!("display notification \"{body}\" with title \"{title}\" subtitle \"NAVI\"");
156    match Command::new("osascript").args(["-e", &script]).status() {
157        Ok(s) if s.success() => Ok(()),
158        Ok(s) => {
159            tracing::debug!(?s, "osascript notification non-zero");
160            Ok(())
161        }
162        Err(err) => {
163            tracing::debug!(%err, "osascript notification failed");
164            Ok(())
165        }
166    }
167}
168
169fn notify_windows(req: &NotifyRequest) -> anyhow::Result<()> {
170    // Toast via PowerShell WinRT (Windows 10+). Best-effort.
171    let title = escape_ps(&req.title);
172    let body = escape_ps(&req.body);
173    let script = format!(
174        r#"
175[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
176[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
177$template = @"
178<toast>
179  <visual>
180    <binding template="ToastGeneric">
181      <text>{title}</text>
182      <text>{body}</text>
183    </binding>
184  </visual>
185</toast>
186"@
187$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
188$xml.LoadXml($template)
189$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
190[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("NAVI").Show($toast)
191"#
192    );
193    match Command::new("powershell")
194        .args(["-NoProfile", "-NonInteractive", "-Command", &script])
195        .status()
196    {
197        Ok(s) if s.success() => Ok(()),
198        Ok(s) => {
199            tracing::debug!(?s, "powershell toast non-zero");
200            Ok(())
201        }
202        Err(err) => {
203            tracing::debug!(%err, "powershell toast failed");
204            Ok(())
205        }
206    }
207}
208
209fn command_exists(name: &str) -> bool {
210    Command::new("sh")
211        .args(["-c", &format!("command -v {name} >/dev/null 2>&1")])
212        .status()
213        .map(|s| s.success())
214        .unwrap_or(false)
215}
216
217fn escape_applescript(s: &str) -> String {
218    s.replace('\\', "\\\\").replace('"', "\\\"")
219}
220
221fn escape_ps(s: &str) -> String {
222    s.replace('&', "&amp;")
223        .replace('<', "&lt;")
224        .replace('>', "&gt;")
225        .replace('"', "&quot;")
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn notify_request_builder() {
234        let n = NotifyRequest::new("t", "b")
235            .with_urgency(NotificationUrgency::Critical)
236            .with_category("update");
237        assert_eq!(n.title, "t");
238        assert_eq!(n.category.as_deref(), Some("update"));
239        assert_eq!(n.urgency, NotificationUrgency::Critical);
240    }
241
242    #[test]
243    fn open_url_rejects_shell_metachar() {
244        assert!(open_url("javascript:alert(1)").is_err());
245        assert!(open_url("").is_err());
246    }
247}