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/// The most languages [`preferred_languages`] returns.
36pub const MAX_LANGUAGES: usize = 16;
37
38/// The user's preferred UI languages, most preferred first, as BCP 47 tags (`en-GB`, `de`,
39/// `zh-Hans-CN`): macOS `NSLocale.preferredLanguages`, Windows `GetUserPreferredUILanguages`;
40/// empty elsewhere (Linux has `LANG`/`LANGUAGE`) and when unknown. Desktop launches often have no
41/// `LANG`, so apps read this as well. At most [`MAX_LANGUAGES`] tags of at most 64 bytes.
42pub fn preferred_languages() -> Vec<String> {
43    let tags = crate::guarded("reading the preferred languages", || Ok(imp::preferred_languages())).unwrap_or_default();
44    clean_languages(tags)
45}
46
47fn clean_languages(tags: Vec<String>) -> Vec<String> {
48    let mut out: Vec<String> = Vec::new();
49    for t in tags {
50        let t = t.trim();
51        if !t.is_empty() && t.len() <= 64 && !out.iter().any(|o| o == t) {
52            out.push(t.to_string());
53        }
54        if out.len() >= MAX_LANGUAGES {
55            break;
56        }
57    }
58    out
59}
60
61/// URL schemes [`open_url`] hands to the OS. Anything else (`file:`, `javascript:`, custom app
62/// schemes) could start arbitrary programs, so it is refused.
63pub const OPEN_URL_SCHEMES: &[&str] = &["http", "https", "mailto"];
64
65/// Check a URL for [`open_url`]: an allowed scheme, one line, bounded.
66pub fn check_url(url: &str) -> Result<()> {
67    let bad = |why: &str| NativeError::Invalid(format!("{why}: `{}`", url.chars().take(80).collect::<String>()));
68    if url.len() > 8192 || url.chars().any(|c| c.is_control() || c == ' ') {
69        return Err(bad("not a URL"));
70    }
71    let (scheme, rest) = url.split_once(':').ok_or_else(|| bad("not a URL"))?;
72    if !OPEN_URL_SCHEMES.iter().any(|s| s.eq_ignore_ascii_case(scheme)) {
73        return Err(bad("only web and mail links are opened"));
74    }
75    if rest.is_empty() || (!scheme.eq_ignore_ascii_case("mailto") && !rest.starts_with("//")) {
76        return Err(bad("not a URL"));
77    }
78    Ok(())
79}
80
81/// Open a web or mail link in the user's default app.
82pub fn open_url(url: &str) -> Result<()> {
83    check_url(url)?;
84    crate::guarded("opening a link", || imp::open_url(url))
85}
86
87/// Open a file or folder in its default app.
88pub fn open_path(path: &Path) -> Result<()> {
89    let path = existing(path)?;
90    crate::guarded("opening a file", || imp::open_path(&path))
91}
92
93/// Show a file selected in Finder / Explorer / the file manager.
94pub fn reveal(path: &Path) -> Result<()> {
95    let path = existing(path)?;
96    crate::guarded("revealing a file", || imp::reveal(&path))
97}
98
99/// Bring the app and `window` to the front and give it the keyboard focus, restoring it if it
100/// is minimized. For "the second launch hands over to the first" and tray / global-shortcut
101/// activations, where the windowing library's focus request alone may be refused.
102///
103/// macOS activates the app (winit then orders the key window front). Windows restores the window
104/// and asks for the foreground, borrowing the foreground thread's input when the foreground lock
105/// refuses; a launch that forwarded to this one through [`crate::single_instance`] has already
106/// allowed it. Elsewhere [`NativeError::Unsupported`]: use the windowing library's focus request.
107pub fn bring_to_front(window: &impl raw_window_handle::HasWindowHandle) -> Result<()> {
108    let handle = window.window_handle().map_err(|e| NativeError::Os(crate::os_text(e)))?;
109    crate::guarded("bringing the window to the front", || imp::bring_to_front(handle.as_raw()))
110}
111
112fn existing(path: &Path) -> Result<std::path::PathBuf> {
113    let abs = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) };
114    if !abs.exists() {
115        return Err(NativeError::Invalid(format!("`{}` does not exist", abs.display())));
116    }
117    Ok(abs)
118}
119
120#[cfg(any(target_os = "windows", target_os = "linux"))]
121fn spawn(cmd: &mut std::process::Command) -> Result<()> {
122    cmd.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
123    let mut child = cmd.spawn().map_err(|e| NativeError::Os(crate::os_text(e)))?;
124    // Reap it in the background so no zombie is left.
125    std::thread::spawn(move || child.wait());
126    Ok(())
127}
128
129#[cfg(target_os = "macos")]
130mod imp {
131    use objc2_app_kit::{NSColor, NSColorSpace, NSWorkspace};
132    use objc2_foundation::{NSArray, NSLocale, NSString, NSURL, NSUserDefaults};
133
134    use super::*;
135
136    pub fn preferred_languages() -> Vec<String> {
137        NSLocale::preferredLanguages().iter().take(MAX_LANGUAGES).map(|s| s.to_string()).collect()
138    }
139
140    fn channel(v: f64) -> u8 {
141        (v.clamp(0.0, 1.0) * 255.0).round() as u8
142    }
143
144    pub fn preferences() -> Preferences {
145        let style = NSUserDefaults::standardUserDefaults().stringForKey(&NSString::from_str("AppleInterfaceStyle"));
146        let dark = style.is_some_and(|s| s.to_string().eq_ignore_ascii_case("dark"));
147        let accent = NSColor::controlAccentColor()
148            .colorUsingColorSpace(&NSColorSpace::sRGBColorSpace())
149            .map(|c| [channel(c.redComponent()), channel(c.greenComponent()), channel(c.blueComponent())]);
150        let ws = NSWorkspace::sharedWorkspace();
151        Preferences {
152            appearance: Some(if dark { Appearance::Dark } else { Appearance::Light }),
153            accent,
154            reduced_motion: Some(ws.accessibilityDisplayShouldReduceMotion()),
155            increased_contrast: Some(ws.accessibilityDisplayShouldIncreaseContrast()),
156        }
157    }
158
159    pub fn open_url(url: &str) -> Result<()> {
160        let u = NSURL::URLWithString(&NSString::from_str(url)).ok_or_else(|| NativeError::Invalid("not a URL".into()))?;
161        if NSWorkspace::sharedWorkspace().openURL(&u) { Ok(()) } else { Err(NativeError::Os("no app opened the link".into())) }
162    }
163
164    pub fn open_path(path: &Path) -> Result<()> {
165        let u = NSURL::from_file_path(path).ok_or_else(|| NativeError::Invalid("not a file path".into()))?;
166        if NSWorkspace::sharedWorkspace().openURL(&u) { Ok(()) } else { Err(NativeError::Os("no app opened the file".into())) }
167    }
168
169    pub fn reveal(path: &Path) -> Result<()> {
170        let u = NSURL::from_file_path(path).ok_or_else(|| NativeError::Invalid("not a file path".into()))?;
171        NSWorkspace::sharedWorkspace().activateFileViewerSelectingURLs(&NSArray::from_retained_slice(&[u]));
172        Ok(())
173    }
174
175    pub fn bring_to_front(_window: raw_window_handle::RawWindowHandle) -> Result<()> {
176        let mtm = objc2::MainThreadMarker::new().ok_or_else(|| NativeError::Os("bring the window to the front from the main thread".into()))?;
177        let app = objc2_app_kit::NSApplication::sharedApplication(mtm);
178        app.unhide(None);
179        // `activate` is macOS 14+; this one works on every version the crate supports.
180        #[allow(deprecated)]
181        app.activateIgnoringOtherApps(true);
182        Ok(())
183    }
184}
185
186#[cfg(target_os = "windows")]
187#[allow(unsafe_code)]
188mod imp {
189    use windows::Win32::Foundation::HWND;
190    use windows::Win32::Globalization::{GetUserPreferredUILanguages, MUI_LANGUAGE_NAME};
191    use windows::Win32::UI::Shell::ShellExecuteW;
192    use windows::Win32::UI::WindowsAndMessaging::{
193        SPI_GETCLIENTAREAANIMATION, SW_SHOWNORMAL, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, SystemParametersInfoW,
194    };
195    use windows::core::{BOOL, HSTRING, PCWSTR, PWSTR, w};
196    use windows_registry::CURRENT_USER;
197
198    use super::*;
199
200    /// The longest language list read, in UTF-16 units (a longer one reads as empty).
201    const MAX_UNITS: u32 = 4096;
202
203    pub fn preferred_languages() -> Vec<String> {
204        let (mut count, mut units) = (0u32, 0u32);
205        // SAFETY: with no buffer the call only writes the language count and the needed buffer
206        // size to the two live local u32s it is given.
207        if unsafe { GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, &mut count, None, &mut units) }.is_err() || units == 0 || units > MAX_UNITS {
208            return Vec::new();
209        }
210        let mut buf = vec![0u16; units as usize];
211        let mut len = units;
212        // SAFETY: `buf` holds `len` u16s and outlives the call, which writes at most `len` units
213        // (a NUL-separated list ending in two NULs) and updates `count` and `len`.
214        if unsafe { GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, &mut count, Some(PWSTR(buf.as_mut_ptr())), &mut len) }.is_err() {
215            return Vec::new();
216        }
217        let used = buf.get(..len as usize).unwrap_or(&buf);
218        used.split(|u| *u == 0).filter(|s| !s.is_empty()).take(MAX_LANGUAGES).map(String::from_utf16_lossy).collect()
219    }
220
221    pub fn preferences() -> Preferences {
222        let light =
223            CURRENT_USER.open(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize").and_then(|k| k.get_u32("AppsUseLightTheme")).ok();
224        // 0xAABBGGRR
225        let accent = CURRENT_USER
226            .open(r"Software\Microsoft\Windows\DWM")
227            .and_then(|k| k.get_u32("AccentColor"))
228            .ok()
229            .map(|c| [c as u8, (c >> 8) as u8, (c >> 16) as u8]);
230        let mut animate = BOOL(1);
231        // SAFETY: SPI_GETCLIENTAREAANIMATION writes one BOOL to `pvParam`, which points at a live
232        // local BOOL for the duration of the call; `uiParam` is unused (0).
233        let ok = unsafe {
234            SystemParametersInfoW(
235                SPI_GETCLIENTAREAANIMATION,
236                0,
237                Some(&mut animate as *mut BOOL as *mut core::ffi::c_void),
238                SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
239            )
240        };
241        Preferences {
242            appearance: light.map(|l| if l == 0 { Appearance::Dark } else { Appearance::Light }),
243            accent,
244            reduced_motion: ok.is_ok().then_some(!animate.as_bool()),
245            increased_contrast: None,
246        }
247    }
248
249    fn shell_open(target: &HSTRING) -> Result<()> {
250        // SAFETY: every pointer argument is either null or a NUL-terminated wide string (`w!`,
251        // `HSTRING`) that outlives the call; ShellExecuteW does not keep them.
252        let r = unsafe { ShellExecuteW(Some(HWND::default()), w!("open"), target, PCWSTR::null(), PCWSTR::null(), SW_SHOWNORMAL) };
253        // Values above 32 mean success.
254        if r.0 as isize > 32 { Ok(()) } else { Err(NativeError::Os(format!("Windows could not open it (code {})", r.0 as isize))) }
255    }
256
257    pub fn open_url(url: &str) -> Result<()> {
258        shell_open(&HSTRING::from(url))
259    }
260
261    pub fn open_path(path: &Path) -> Result<()> {
262        shell_open(&HSTRING::from(path.as_os_str()))
263    }
264
265    pub fn reveal(path: &Path) -> Result<()> {
266        use std::os::windows::process::CommandExt;
267        let p = path.to_string_lossy();
268        if p.contains('"') {
269            return Err(NativeError::Invalid("a path with a quote".into()));
270        }
271        spawn(std::process::Command::new("explorer.exe").raw_arg(format!("/select,\"{p}\"")))
272    }
273
274    pub fn bring_to_front(window: raw_window_handle::RawWindowHandle) -> Result<()> {
275        use windows::Win32::System::Threading::{AttachThreadInput, GetCurrentThreadId};
276        use windows::Win32::UI::WindowsAndMessaging::{
277            BringWindowToTop, GetForegroundWindow, GetWindowThreadProcessId, IsIconic, SW_RESTORE, SetForegroundWindow, ShowWindow,
278        };
279        let raw_window_handle::RawWindowHandle::Win32(h) = window else {
280            return Err(NativeError::Invalid("not a Win32 window".into()));
281        };
282        let hwnd = HWND(h.hwnd.get() as *mut core::ffi::c_void);
283        // SAFETY: `hwnd` comes from a live window handle (`HasWindowHandle` borrows the window);
284        // these calls only take the handle by value and fail cleanly on a stale one.
285        unsafe {
286            if IsIconic(hwnd).as_bool() {
287                let _ = ShowWindow(hwnd, SW_RESTORE);
288            }
289            if SetForegroundWindow(hwnd).as_bool() {
290                return Ok(());
291            }
292        }
293        // The foreground lock refused: share the foreground thread's input for the call.
294        // SAFETY: plain Win32 calls on thread ids and the handle above; the attachment is undone
295        // before returning.
296        let ok = unsafe {
297            let me = GetCurrentThreadId();
298            let fg = GetWindowThreadProcessId(GetForegroundWindow(), None);
299            let attached = fg != 0 && fg != me && AttachThreadInput(me, fg, true).as_bool();
300            let _ = BringWindowToTop(hwnd);
301            let ok = SetForegroundWindow(hwnd).as_bool();
302            if attached {
303                let _ = AttachThreadInput(me, fg, false);
304            }
305            ok
306        };
307        if ok { Ok(()) } else { Err(NativeError::Denied("Windows kept another window in front".into())) }
308    }
309}
310
311#[cfg(target_os = "linux")]
312mod imp {
313    use zbus::zvariant::Value;
314
315    use super::*;
316
317    pub fn preferred_languages() -> Vec<String> {
318        Vec::new()
319    }
320
321    fn read(conn: &zbus::blocking::Connection, namespace: &str, key: &str) -> Option<zbus::zvariant::OwnedValue> {
322        let call = |method: &str| {
323            conn.call_method(
324                Some("org.freedesktop.portal.Desktop"),
325                "/org/freedesktop/portal/desktop",
326                Some("org.freedesktop.portal.Settings"),
327                method,
328                &(namespace, key),
329            )
330            .ok()?
331            .body()
332            .deserialize::<zbus::zvariant::OwnedValue>()
333            .ok()
334        };
335        call("ReadOne").or_else(|| call("Read"))
336    }
337
338    /// Unwrap nested variants (`Read` wraps the value twice).
339    fn inner<'a>(v: &'a Value<'a>, depth: usize) -> &'a Value<'a> {
340        match v {
341            Value::Value(b) if depth < 4 => inner(b, depth + 1),
342            other => other,
343        }
344    }
345
346    fn channel(v: &Value<'_>) -> Option<u8> {
347        match inner(v, 0) {
348            Value::F64(f) if (0.0..=1.0).contains(f) => Some((f * 255.0).round() as u8),
349            _ => None,
350        }
351    }
352
353    pub fn preferences() -> Preferences {
354        let Ok(conn) = zbus::blocking::Connection::session() else { return Preferences::default() };
355        let appearance = read(&conn, "org.freedesktop.appearance", "color-scheme").and_then(|v| match inner(&v, 0) {
356            Value::U32(1) => Some(Appearance::Dark),
357            Value::U32(2) => Some(Appearance::Light),
358            _ => None,
359        });
360        let accent = read(&conn, "org.freedesktop.appearance", "accent-color").and_then(|v| match inner(&v, 0) {
361            Value::Structure(s) => match s.fields() {
362                [r, g, b] => Some([channel(r)?, channel(g)?, channel(b)?]),
363                _ => None,
364            },
365            _ => None,
366        });
367        let animations = read(&conn, "org.gnome.desktop.interface", "enable-animations").and_then(|v| match inner(&v, 0) {
368            Value::Bool(b) => Some(*b),
369            _ => None,
370        });
371        let contrast = read(&conn, "org.freedesktop.appearance", "contrast").and_then(|v| match inner(&v, 0) {
372            Value::U32(c) => Some(*c == 1),
373            _ => None,
374        });
375        Preferences { appearance, accent, reduced_motion: animations.map(|a| !a), increased_contrast: contrast }
376    }
377
378    pub fn open_url(url: &str) -> Result<()> {
379        spawn(std::process::Command::new("xdg-open").arg(url))
380    }
381
382    pub fn open_path(path: &Path) -> Result<()> {
383        spawn(std::process::Command::new("xdg-open").arg(path))
384    }
385
386    /// `file://` URI of an absolute path, percent-encoding everything but unreserved characters
387    /// and `/`.
388    fn file_uri(path: &Path) -> String {
389        use std::os::unix::ffi::OsStrExt;
390        let mut out = String::from("file://");
391        for &b in path.as_os_str().as_bytes() {
392            if b.is_ascii_alphanumeric() || b"-._~/".contains(&b) {
393                out.push(b as char);
394            } else {
395                out.push_str(&format!("%{b:02X}"));
396            }
397        }
398        out
399    }
400
401    pub fn reveal(path: &Path) -> Result<()> {
402        let shown = zbus::blocking::Connection::session().ok().and_then(|conn| {
403            conn.call_method(
404                Some("org.freedesktop.FileManager1"),
405                "/org/freedesktop/FileManager1",
406                Some("org.freedesktop.FileManager1"),
407                "ShowItems",
408                &(vec![file_uri(path)], ""),
409            )
410            .ok()
411        });
412        if shown.is_some() {
413            return Ok(());
414        }
415        // No file manager service: open the folder instead.
416        spawn(std::process::Command::new("xdg-open").arg(path.parent().unwrap_or(path)))
417    }
418
419    pub fn bring_to_front(_window: raw_window_handle::RawWindowHandle) -> Result<()> {
420        Err(NativeError::Unsupported)
421    }
422
423    #[cfg(test)]
424    #[test]
425    fn file_uris_are_encoded() {
426        assert_eq!(file_uri(Path::new("/a b/ΓΌ#.txt")), "file:///a%20b/%C3%BC%23.txt");
427    }
428}
429
430#[cfg(target_arch = "wasm32")]
431mod imp {
432    use super::*;
433
434    pub fn preferred_languages() -> Vec<String> {
435        Vec::new()
436    }
437
438    fn matches(query: &str) -> Option<bool> {
439        web_sys::window()?.match_media(query).ok()??.matches().into()
440    }
441
442    pub fn preferences() -> Preferences {
443        Preferences {
444            appearance: matches("(prefers-color-scheme: dark)").map(|d| if d { Appearance::Dark } else { Appearance::Light }),
445            accent: None,
446            reduced_motion: matches("(prefers-reduced-motion: reduce)"),
447            increased_contrast: matches("(prefers-contrast: more)"),
448        }
449    }
450
451    pub fn open_url(url: &str) -> Result<()> {
452        let w = web_sys::window().ok_or(NativeError::Unsupported)?;
453        match w.open_with_url_and_target(url, "_blank") {
454            Ok(Some(_)) => Ok(()),
455            _ => Err(NativeError::Denied("the browser blocked the new tab".into())),
456        }
457    }
458
459    pub fn open_path(_: &Path) -> Result<()> {
460        Err(NativeError::Unsupported)
461    }
462
463    pub fn reveal(_: &Path) -> Result<()> {
464        Err(NativeError::Unsupported)
465    }
466
467    pub fn bring_to_front(_window: raw_window_handle::RawWindowHandle) -> Result<()> {
468        Err(NativeError::Unsupported)
469    }
470}
471
472#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux", target_arch = "wasm32")))]
473mod imp {
474    use super::*;
475
476    pub fn preferred_languages() -> Vec<String> {
477        Vec::new()
478    }
479
480    pub fn preferences() -> Preferences {
481        Preferences::default()
482    }
483    pub fn open_url(_: &str) -> Result<()> {
484        Err(NativeError::Unsupported)
485    }
486    pub fn open_path(_: &Path) -> Result<()> {
487        Err(NativeError::Unsupported)
488    }
489    pub fn reveal(_: &Path) -> Result<()> {
490        Err(NativeError::Unsupported)
491    }
492
493    pub fn bring_to_front(_window: raw_window_handle::RawWindowHandle) -> Result<()> {
494        Err(NativeError::Unsupported)
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    #[test]
501    fn preferred_languages_are_bounded_and_clean() {
502        let tags = super::preferred_languages();
503        assert!(tags.len() <= super::MAX_LANGUAGES);
504        assert!(tags.iter().all(|t| !t.is_empty() && t.len() <= 64), "{tags:?}");
505        let long = "x".repeat(65);
506        let cleaned = super::clean_languages(vec![" en-GB ".into(), "en-GB".into(), String::new(), long]);
507        assert_eq!(cleaned, vec!["en-GB".to_string()]);
508    }
509
510    use super::*;
511
512    #[test]
513    fn only_web_and_mail_links_are_opened() {
514        for ok in ["https://example.com/a?b=c", "http://localhost:8080", "mailto:someone@example.com", "HTTPS://EXAMPLE.COM"] {
515            assert!(check_url(ok).is_ok(), "{ok}");
516        }
517        for bad in [
518            "file:///etc/passwd",
519            "javascript:alert(1)",
520            "myapp://x",
521            "https:",
522            "https:no-slashes",
523            "https://a b",
524            "https://a\nb",
525            "",
526            "no scheme",
527            "C:\\Windows\\notepad.exe",
528        ] {
529            assert!(matches!(open_url(bad), Err(NativeError::Invalid(_))), "{bad:?}");
530        }
531    }
532
533    #[test]
534    fn missing_files_are_not_handed_to_the_os() {
535        assert!(matches!(reveal(Path::new("/definitely/not/here/ashlar")), Err(NativeError::Invalid(_))));
536        assert!(matches!(open_path(Path::new("/definitely/not/here/ashlar")), Err(NativeError::Invalid(_))));
537    }
538
539    #[test]
540    fn preferences_are_read_without_failing() {
541        let p = preferences();
542        if cfg!(target_os = "macos") {
543            assert!(p.appearance.is_some() && p.reduced_motion.is_some() && p.accent.is_some(), "{p:?}");
544        }
545        let json = serde_json::to_value(p).unwrap();
546        assert!(json.get("reducedMotion").is_some());
547    }
548}