Skip to main content

ashlar_native/
system.rs

1//! System preferences the UI follows (light / dark, accent colour, reduced motion, more
2//! contrast) and hand-offs to the OS (open a URL in the browser, open a file in its default app,
3//! reveal a file in the file manager).
4//!
5//! Preferences are read on demand and cheap enough for a settings change or window focus, not
6//! for every frame: cache them and re-read when the window gains focus.
7
8use 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/// What the user chose in the OS. `None` = unknown here.
20#[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    /// The accent colour as sRGB.
25    pub accent: Option<[u8; 3]>,
26    pub reduced_motion: Option<bool>,
27    pub increased_contrast: Option<bool>,
28}
29
30/// The OS preferences now.
31pub fn preferences() -> Preferences {
32    crate::guarded("reading system preferences", || Ok(imp::preferences())).unwrap_or_default()
33}
34
35/// URL schemes [`open_url`] hands to the OS. Anything else (`file:`, `javascript:`, custom app
36/// schemes) could start arbitrary programs, so it is refused.
37pub const OPEN_URL_SCHEMES: &[&str] = &["http", "https", "mailto"];
38
39/// Check a URL for [`open_url`]: an allowed scheme, one line, bounded.
40pub 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
55/// Open a web or mail link in the user's default app.
56pub fn open_url(url: &str) -> Result<()> {
57    check_url(url)?;
58    crate::guarded("opening a link", || imp::open_url(url))
59}
60
61/// Open a file or folder in its default app.
62pub fn open_path(path: &Path) -> Result<()> {
63    let path = existing(path)?;
64    crate::guarded("opening a file", || imp::open_path(&path))
65}
66
67/// Show a file selected in Finder / Explorer / the file manager.
68pub 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    // Reap it in the background so no zombie is left.
86    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        // 0xAABBGGRR
150        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        // SAFETY: SPI_GETCLIENTAREAANIMATION writes one BOOL to `pvParam`, which points at a live
157        // local BOOL for the duration of the call; `uiParam` is unused (0).
158        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        // SAFETY: every pointer argument is either null or a NUL-terminated wide string (`w!`,
176        // `HSTRING`) that outlives the call; ShellExecuteW does not keep them.
177        let r = unsafe { ShellExecuteW(Some(HWND::default()), w!("open"), target, PCWSTR::null(), PCWSTR::null(), SW_SHOWNORMAL) };
178        // Values above 32 mean success.
179        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    /// Unwrap nested variants (`Read` wraps the value twice).
224    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    /// `file://` URI of an absolute path, percent-encoding everything but unreserved characters
272    /// and `/`.
273    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        // No file manager service: open the folder instead.
301        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}