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 MAX_LANGUAGES: usize = 16;
37
38pub 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
61pub const OPEN_URL_SCHEMES: &[&str] = &["http", "https", "mailto"];
64
65pub 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
81pub fn open_url(url: &str) -> Result<()> {
83 check_url(url)?;
84 crate::guarded("opening a link", || imp::open_url(url))
85}
86
87pub fn open_path(path: &Path) -> Result<()> {
89 let path = existing(path)?;
90 crate::guarded("opening a file", || imp::open_path(&path))
91}
92
93pub fn reveal(path: &Path) -> Result<()> {
95 let path = existing(path)?;
96 crate::guarded("revealing a file", || imp::reveal(&path))
97}
98
99pub 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 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 #[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 const MAX_UNITS: u32 = 4096;
202
203 pub fn preferred_languages() -> Vec<String> {
204 let (mut count, mut units) = (0u32, 0u32);
205 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 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 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 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 let r = unsafe { ShellExecuteW(Some(HWND::default()), w!("open"), target, PCWSTR::null(), PCWSTR::null(), SW_SHOWNORMAL) };
253 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 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 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 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 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 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}