1use std::path::Path;
9
10use crate::{NativeError, Result};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub enum Appearance {
15 Light,
16 Dark,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct Preferences {
23 pub appearance: Option<Appearance>,
24 pub accent: Option<[u8; 3]>,
26 pub reduced_motion: Option<bool>,
27 pub increased_contrast: Option<bool>,
28}
29
30pub fn preferences() -> Preferences {
32 crate::guarded("reading system preferences", || Ok(imp::preferences())).unwrap_or_default()
33}
34
35pub const OPEN_URL_SCHEMES: &[&str] = &["http", "https", "mailto"];
38
39pub fn check_url(url: &str) -> Result<()> {
41 let bad = |why: &str| NativeError::Invalid(format!("{why}: `{}`", url.chars().take(80).collect::<String>()));
42 if url.len() > 8192 || url.chars().any(|c| c.is_control() || c == ' ') {
43 return Err(bad("not a URL"));
44 }
45 let (scheme, rest) = url.split_once(':').ok_or_else(|| bad("not a URL"))?;
46 if !OPEN_URL_SCHEMES.iter().any(|s| s.eq_ignore_ascii_case(scheme)) {
47 return Err(bad("only web and mail links are opened"));
48 }
49 if rest.is_empty() || (!scheme.eq_ignore_ascii_case("mailto") && !rest.starts_with("//")) {
50 return Err(bad("not a URL"));
51 }
52 Ok(())
53}
54
55pub fn open_url(url: &str) -> Result<()> {
57 check_url(url)?;
58 crate::guarded("opening a link", || imp::open_url(url))
59}
60
61pub fn open_path(path: &Path) -> Result<()> {
63 let path = existing(path)?;
64 crate::guarded("opening a file", || imp::open_path(&path))
65}
66
67pub fn reveal(path: &Path) -> Result<()> {
69 let path = existing(path)?;
70 crate::guarded("revealing a file", || imp::reveal(&path))
71}
72
73fn existing(path: &Path) -> Result<std::path::PathBuf> {
74 let abs = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) };
75 if !abs.exists() {
76 return Err(NativeError::Invalid(format!("`{}` does not exist", abs.display())));
77 }
78 Ok(abs)
79}
80
81#[cfg(any(target_os = "windows", target_os = "linux"))]
82fn spawn(cmd: &mut std::process::Command) -> Result<()> {
83 cmd.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
84 let mut child = cmd.spawn().map_err(|e| NativeError::Os(crate::os_text(e)))?;
85 std::thread::spawn(move || child.wait());
87 Ok(())
88}
89
90#[cfg(target_os = "macos")]
91mod imp {
92 use objc2_app_kit::{NSColor, NSColorSpace, NSWorkspace};
93 use objc2_foundation::{NSArray, NSString, NSURL, NSUserDefaults};
94
95 use super::*;
96
97 fn channel(v: f64) -> u8 {
98 (v.clamp(0.0, 1.0) * 255.0).round() as u8
99 }
100
101 pub fn preferences() -> Preferences {
102 let style = NSUserDefaults::standardUserDefaults().stringForKey(&NSString::from_str("AppleInterfaceStyle"));
103 let dark = style.is_some_and(|s| s.to_string().eq_ignore_ascii_case("dark"));
104 let accent = NSColor::controlAccentColor()
105 .colorUsingColorSpace(&NSColorSpace::sRGBColorSpace())
106 .map(|c| [channel(c.redComponent()), channel(c.greenComponent()), channel(c.blueComponent())]);
107 let ws = NSWorkspace::sharedWorkspace();
108 Preferences {
109 appearance: Some(if dark { Appearance::Dark } else { Appearance::Light }),
110 accent,
111 reduced_motion: Some(ws.accessibilityDisplayShouldReduceMotion()),
112 increased_contrast: Some(ws.accessibilityDisplayShouldIncreaseContrast()),
113 }
114 }
115
116 pub fn open_url(url: &str) -> Result<()> {
117 let u = NSURL::URLWithString(&NSString::from_str(url)).ok_or_else(|| NativeError::Invalid("not a URL".into()))?;
118 if NSWorkspace::sharedWorkspace().openURL(&u) { Ok(()) } else { Err(NativeError::Os("no app opened the link".into())) }
119 }
120
121 pub fn open_path(path: &Path) -> Result<()> {
122 let u = NSURL::from_file_path(path).ok_or_else(|| NativeError::Invalid("not a file path".into()))?;
123 if NSWorkspace::sharedWorkspace().openURL(&u) { Ok(()) } else { Err(NativeError::Os("no app opened the file".into())) }
124 }
125
126 pub fn reveal(path: &Path) -> Result<()> {
127 let u = NSURL::from_file_path(path).ok_or_else(|| NativeError::Invalid("not a file path".into()))?;
128 NSWorkspace::sharedWorkspace().activateFileViewerSelectingURLs(&NSArray::from_retained_slice(&[u]));
129 Ok(())
130 }
131}
132
133#[cfg(target_os = "windows")]
134#[allow(unsafe_code)]
135mod imp {
136 use windows::Win32::Foundation::HWND;
137 use windows::Win32::UI::Shell::ShellExecuteW;
138 use windows::Win32::UI::WindowsAndMessaging::{
139 SPI_GETCLIENTAREAANIMATION, SW_SHOWNORMAL, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, SystemParametersInfoW,
140 };
141 use windows::core::{BOOL, HSTRING, PCWSTR, w};
142 use windows_registry::CURRENT_USER;
143
144 use super::*;
145
146 pub fn preferences() -> Preferences {
147 let light =
148 CURRENT_USER.open(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize").and_then(|k| k.get_u32("AppsUseLightTheme")).ok();
149 let accent = CURRENT_USER
151 .open(r"Software\Microsoft\Windows\DWM")
152 .and_then(|k| k.get_u32("AccentColor"))
153 .ok()
154 .map(|c| [c as u8, (c >> 8) as u8, (c >> 16) as u8]);
155 let mut animate = BOOL(1);
156 let ok = unsafe {
159 SystemParametersInfoW(
160 SPI_GETCLIENTAREAANIMATION,
161 0,
162 Some(&mut animate as *mut BOOL as *mut core::ffi::c_void),
163 SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
164 )
165 };
166 Preferences {
167 appearance: light.map(|l| if l == 0 { Appearance::Dark } else { Appearance::Light }),
168 accent,
169 reduced_motion: ok.is_ok().then_some(!animate.as_bool()),
170 increased_contrast: None,
171 }
172 }
173
174 fn shell_open(target: &HSTRING) -> Result<()> {
175 let r = unsafe { ShellExecuteW(Some(HWND::default()), w!("open"), target, PCWSTR::null(), PCWSTR::null(), SW_SHOWNORMAL) };
178 if r.0 as isize > 32 { Ok(()) } else { Err(NativeError::Os(format!("Windows could not open it (code {})", r.0 as isize))) }
180 }
181
182 pub fn open_url(url: &str) -> Result<()> {
183 shell_open(&HSTRING::from(url))
184 }
185
186 pub fn open_path(path: &Path) -> Result<()> {
187 shell_open(&HSTRING::from(path.as_os_str()))
188 }
189
190 pub fn reveal(path: &Path) -> Result<()> {
191 use std::os::windows::process::CommandExt;
192 let p = path.to_string_lossy();
193 if p.contains('"') {
194 return Err(NativeError::Invalid("a path with a quote".into()));
195 }
196 spawn(std::process::Command::new("explorer.exe").raw_arg(format!("/select,\"{p}\"")))
197 }
198}
199
200#[cfg(target_os = "linux")]
201mod imp {
202 use zbus::zvariant::Value;
203
204 use super::*;
205
206 fn read(conn: &zbus::blocking::Connection, namespace: &str, key: &str) -> Option<zbus::zvariant::OwnedValue> {
207 let call = |method: &str| {
208 conn.call_method(
209 Some("org.freedesktop.portal.Desktop"),
210 "/org/freedesktop/portal/desktop",
211 Some("org.freedesktop.portal.Settings"),
212 method,
213 &(namespace, key),
214 )
215 .ok()?
216 .body()
217 .deserialize::<zbus::zvariant::OwnedValue>()
218 .ok()
219 };
220 call("ReadOne").or_else(|| call("Read"))
221 }
222
223 fn inner<'a>(v: &'a Value<'a>, depth: usize) -> &'a Value<'a> {
225 match v {
226 Value::Value(b) if depth < 4 => inner(b, depth + 1),
227 other => other,
228 }
229 }
230
231 fn channel(v: &Value<'_>) -> Option<u8> {
232 match inner(v, 0) {
233 Value::F64(f) if (0.0..=1.0).contains(f) => Some((f * 255.0).round() as u8),
234 _ => None,
235 }
236 }
237
238 pub fn preferences() -> Preferences {
239 let Ok(conn) = zbus::blocking::Connection::session() else { return Preferences::default() };
240 let appearance = read(&conn, "org.freedesktop.appearance", "color-scheme").and_then(|v| match inner(&v, 0) {
241 Value::U32(1) => Some(Appearance::Dark),
242 Value::U32(2) => Some(Appearance::Light),
243 _ => None,
244 });
245 let accent = read(&conn, "org.freedesktop.appearance", "accent-color").and_then(|v| match inner(&v, 0) {
246 Value::Structure(s) => match s.fields() {
247 [r, g, b] => Some([channel(r)?, channel(g)?, channel(b)?]),
248 _ => None,
249 },
250 _ => None,
251 });
252 let animations = read(&conn, "org.gnome.desktop.interface", "enable-animations").and_then(|v| match inner(&v, 0) {
253 Value::Bool(b) => Some(*b),
254 _ => None,
255 });
256 let contrast = read(&conn, "org.freedesktop.appearance", "contrast").and_then(|v| match inner(&v, 0) {
257 Value::U32(c) => Some(*c == 1),
258 _ => None,
259 });
260 Preferences { appearance, accent, reduced_motion: animations.map(|a| !a), increased_contrast: contrast }
261 }
262
263 pub fn open_url(url: &str) -> Result<()> {
264 spawn(std::process::Command::new("xdg-open").arg(url))
265 }
266
267 pub fn open_path(path: &Path) -> Result<()> {
268 spawn(std::process::Command::new("xdg-open").arg(path))
269 }
270
271 fn file_uri(path: &Path) -> String {
274 use std::os::unix::ffi::OsStrExt;
275 let mut out = String::from("file://");
276 for &b in path.as_os_str().as_bytes() {
277 if b.is_ascii_alphanumeric() || b"-._~/".contains(&b) {
278 out.push(b as char);
279 } else {
280 out.push_str(&format!("%{b:02X}"));
281 }
282 }
283 out
284 }
285
286 pub fn reveal(path: &Path) -> Result<()> {
287 let shown = zbus::blocking::Connection::session().ok().and_then(|conn| {
288 conn.call_method(
289 Some("org.freedesktop.FileManager1"),
290 "/org/freedesktop/FileManager1",
291 Some("org.freedesktop.FileManager1"),
292 "ShowItems",
293 &(vec![file_uri(path)], ""),
294 )
295 .ok()
296 });
297 if shown.is_some() {
298 return Ok(());
299 }
300 spawn(std::process::Command::new("xdg-open").arg(path.parent().unwrap_or(path)))
302 }
303
304 #[cfg(test)]
305 #[test]
306 fn file_uris_are_encoded() {
307 assert_eq!(file_uri(Path::new("/a b/ΓΌ#.txt")), "file:///a%20b/%C3%BC%23.txt");
308 }
309}
310
311#[cfg(target_arch = "wasm32")]
312mod imp {
313 use super::*;
314
315 fn matches(query: &str) -> Option<bool> {
316 web_sys::window()?.match_media(query).ok()??.matches().into()
317 }
318
319 pub fn preferences() -> Preferences {
320 Preferences {
321 appearance: matches("(prefers-color-scheme: dark)").map(|d| if d { Appearance::Dark } else { Appearance::Light }),
322 accent: None,
323 reduced_motion: matches("(prefers-reduced-motion: reduce)"),
324 increased_contrast: matches("(prefers-contrast: more)"),
325 }
326 }
327
328 pub fn open_url(url: &str) -> Result<()> {
329 let w = web_sys::window().ok_or(NativeError::Unsupported)?;
330 match w.open_with_url_and_target(url, "_blank") {
331 Ok(Some(_)) => Ok(()),
332 _ => Err(NativeError::Denied("the browser blocked the new tab".into())),
333 }
334 }
335
336 pub fn open_path(_: &Path) -> Result<()> {
337 Err(NativeError::Unsupported)
338 }
339
340 pub fn reveal(_: &Path) -> Result<()> {
341 Err(NativeError::Unsupported)
342 }
343}
344
345#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux", target_arch = "wasm32")))]
346mod imp {
347 use super::*;
348
349 pub fn preferences() -> Preferences {
350 Preferences::default()
351 }
352 pub fn open_url(_: &str) -> Result<()> {
353 Err(NativeError::Unsupported)
354 }
355 pub fn open_path(_: &Path) -> Result<()> {
356 Err(NativeError::Unsupported)
357 }
358 pub fn reveal(_: &Path) -> Result<()> {
359 Err(NativeError::Unsupported)
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn only_web_and_mail_links_are_opened() {
369 for ok in ["https://example.com/a?b=c", "http://localhost:8080", "mailto:someone@example.com", "HTTPS://EXAMPLE.COM"] {
370 assert!(check_url(ok).is_ok(), "{ok}");
371 }
372 for bad in [
373 "file:///etc/passwd",
374 "javascript:alert(1)",
375 "myapp://x",
376 "https:",
377 "https:no-slashes",
378 "https://a b",
379 "https://a\nb",
380 "",
381 "no scheme",
382 "C:\\Windows\\notepad.exe",
383 ] {
384 assert!(matches!(open_url(bad), Err(NativeError::Invalid(_))), "{bad:?}");
385 }
386 }
387
388 #[test]
389 fn missing_files_are_not_handed_to_the_os() {
390 assert!(matches!(reveal(Path::new("/definitely/not/here/ashlar")), Err(NativeError::Invalid(_))));
391 assert!(matches!(open_path(Path::new("/definitely/not/here/ashlar")), Err(NativeError::Invalid(_))));
392 }
393
394 #[test]
395 fn preferences_are_read_without_failing() {
396 let p = preferences();
397 if cfg!(target_os = "macos") {
398 assert!(p.appearance.is_some() && p.reduced_motion.is_some() && p.accent.is_some(), "{p:?}");
399 }
400 let json = serde_json::to_value(p).unwrap();
401 assert!(json.get("reducedMotion").is_some());
402 }
403}