1use std::process::Command;
7
8#[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#[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 #[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
61pub 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
78pub fn open_url(url: &str) -> anyhow::Result<()> {
80 let url = url.trim();
81 if url.is_empty() {
82 anyhow::bail!("empty URL");
83 }
84 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 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 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 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 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 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('&', "&")
223 .replace('<', "<")
224 .replace('>', ">")
225 .replace('"', """)
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}