leviath_sys/browser.rs
1//! Opening a URL in the user's default browser.
2//!
3//! The launcher differs per OS (`open`, `xdg-open`, `cmd /c start`). The
4//! platform selection is a pure function tested against every OS string, and
5//! the actual process spawn is injected, so nothing here needs a `#[cfg]` and
6//! every branch is reachable under test on a single platform.
7
8use std::process::Command;
9
10/// The launcher command and arguments for `url` on `os`.
11///
12/// `os` is the value of `std::env::consts::OS`. An unrecognized OS falls back
13/// to `xdg-open`, the freedesktop standard, which covers the BSDs and other
14/// Unixes.
15///
16/// On Windows the empty `""` title argument to `start` matters: `start` treats
17/// a single quoted argument as a window title, so a URL would be swallowed
18/// without a placeholder title ahead of it.
19pub fn open_command_for(os: &str, url: &str) -> (String, Vec<String>) {
20 match os {
21 "macos" => ("open".to_string(), vec![url.to_string()]),
22 "windows" => (
23 "cmd".to_string(),
24 vec![
25 "/C".to_string(),
26 "start".to_string(),
27 String::new(),
28 url.to_string(),
29 ],
30 ),
31 _ => ("xdg-open".to_string(), vec![url.to_string()]),
32 }
33}
34
35/// Open `url` in the default browser, spawning via `spawn`.
36///
37/// `spawn` is injected so the real process launch is isolated from the logic:
38/// production passes [`spawn_detached`], tests pass a recording stub. Returns
39/// whether the launcher was spawned successfully - not whether the user
40/// actually saw the page, which is unknowable.
41pub fn open_url_via(spawn: fn(&mut Command) -> std::io::Result<()>, url: &str) -> bool {
42 let (program, args) = open_command_for(std::env::consts::OS, url);
43 let mut cmd = Command::new(program);
44 cmd.args(args);
45 match spawn(&mut cmd) {
46 Ok(()) => true,
47 Err(e) => {
48 tracing::warn!(error = %e, "Failed to launch browser");
49 false
50 }
51 }
52}
53
54/// Spawn `cmd` fire-and-forget, discarding its output.
55///
56/// The browser launcher is detached: we neither wait for it nor read its
57/// pipes, since it may outlive this process.
58///
59/// The Windows launcher is `cmd /C start`, which would otherwise flash a
60/// console on its way to opening the browser. `start` hands the URL to the
61/// shell association and needs no console of its own, so suppressing the window
62/// costs nothing - the browser still opens.
63pub fn spawn_detached(cmd: &mut Command) -> std::io::Result<()> {
64 crate::process::hide_console_window(cmd);
65 cmd.stdin(std::process::Stdio::null())
66 .stdout(std::process::Stdio::null())
67 .stderr(std::process::Stdio::null())
68 .spawn()
69 .map(|_child| ())
70}
71
72/// Open `url` in the default browser.
73pub fn open_url(url: &str) -> bool {
74 open_url_via(spawn_detached, url)
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80 use leviath_testkit::with_tracing;
81
82 #[test]
83 fn macos_uses_open() {
84 let (program, args) = open_command_for("macos", "https://x.example");
85 assert_eq!(program, "open");
86 assert_eq!(args, vec!["https://x.example"]);
87 }
88
89 #[test]
90 fn windows_uses_start_with_a_placeholder_title() {
91 let (program, args) = open_command_for("windows", "https://x.example");
92 assert_eq!(program, "cmd");
93 // The empty title placeholder must sit before the URL, or `start`
94 // consumes the URL as the window title.
95 assert_eq!(args, vec!["/C", "start", "", "https://x.example"]);
96 }
97
98 #[test]
99 fn other_unixes_fall_back_to_xdg_open() {
100 let (program, args) = open_command_for("linux", "https://x.example");
101 assert_eq!(program, "xdg-open");
102 assert_eq!(args, vec!["https://x.example"]);
103
104 // An unknown OS also uses the freedesktop launcher.
105 assert_eq!(open_command_for("dragonfly", "https://x").0, "xdg-open");
106 }
107
108 #[test]
109 fn open_url_via_reports_spawn_success() {
110 fn ok(_: &mut Command) -> std::io::Result<()> {
111 Ok(())
112 }
113 assert!(open_url_via(ok, "https://x.example"));
114 }
115
116 /// The failure arm logs, and `tracing::warn!` evaluates its field values
117 /// only when a subscriber is interested. Without one installed the `%e`
118 /// field closure never runs - so this test exercised the branch while
119 /// leaving the logging inside it unexecuted. `with_tracing` is the same
120 /// always-on-subscriber shim `leviath-cli` uses for exactly this.
121 #[test]
122 fn open_url_via_reports_spawn_failure() {
123 fn boom(_: &mut Command) -> std::io::Result<()> {
124 Err(std::io::Error::other("no browser"))
125 }
126 with_tracing(|| assert!(!open_url_via(boom, "https://x.example")));
127 }
128
129 #[test]
130 fn spawn_detached_launches_a_real_process() {
131 // `true` exits immediately; this exercises the real spawn path without
132 // opening anything. On the off chance `true` is absent, a spawn error
133 // is still a valid Ok/Err from the function under test.
134 let mut cmd = Command::new("true");
135 let _ = spawn_detached(&mut cmd);
136 }
137
138 #[test]
139 fn spawn_detached_errors_on_a_missing_program() {
140 let mut cmd = Command::new("/nonexistent/browser/launcher");
141 assert!(spawn_detached(&mut cmd).is_err());
142 }
143
144 #[test]
145 fn open_url_runs_the_real_launcher_without_opening_a_browser() {
146 // Drives the real public entry point. The target is a bare, non-existent
147 // name rather than a URL, so whichever launcher the host resolves
148 // (`open`, `xdg-open`, or `cmd /C start`) errors on a missing file
149 // instead of opening a browser. This exercises the real `open_url`
150 // delegation on every platform without launching anything. The return
151 // value is host-dependent (whether the launcher itself is present), so
152 // we only require the call not to panic.
153 let _ = open_url("leviath-open-url-test-target");
154 }
155}