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
81 #[test]
82 fn macos_uses_open() {
83 let (program, args) = open_command_for("macos", "https://x.example");
84 assert_eq!(program, "open");
85 assert_eq!(args, vec!["https://x.example"]);
86 }
87
88 #[test]
89 fn windows_uses_start_with_a_placeholder_title() {
90 let (program, args) = open_command_for("windows", "https://x.example");
91 assert_eq!(program, "cmd");
92 // The empty title placeholder must sit before the URL, or `start`
93 // consumes the URL as the window title.
94 assert_eq!(args, vec!["/C", "start", "", "https://x.example"]);
95 }
96
97 #[test]
98 fn other_unixes_fall_back_to_xdg_open() {
99 let (program, args) = open_command_for("linux", "https://x.example");
100 assert_eq!(program, "xdg-open");
101 assert_eq!(args, vec!["https://x.example"]);
102
103 // An unknown OS also uses the freedesktop launcher.
104 assert_eq!(open_command_for("dragonfly", "https://x").0, "xdg-open");
105 }
106
107 #[test]
108 fn open_url_via_reports_spawn_success() {
109 fn ok(_: &mut Command) -> std::io::Result<()> {
110 Ok(())
111 }
112 assert!(open_url_via(ok, "https://x.example"));
113 }
114
115 /// The failure arm logs, and `tracing::warn!` evaluates its field values
116 /// only when a subscriber is interested. Without one installed the `%e`
117 /// field closure never runs - so this test exercised the branch while
118 /// leaving the logging inside it unexecuted. `with_tracing` is the same
119 /// always-on-subscriber shim `leviath-cli` uses for exactly this.
120 #[test]
121 fn open_url_via_reports_spawn_failure() {
122 fn boom(_: &mut Command) -> std::io::Result<()> {
123 Err(std::io::Error::other("no browser"))
124 }
125 with_tracing(|| assert!(!open_url_via(boom, "https://x.example")));
126 }
127
128 /// An always-enabled [`tracing::Subscriber`], so macro bodies actually run.
129 struct AlwaysOnSubscriber;
130
131 impl tracing::Subscriber for AlwaysOnSubscriber {
132 fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
133 true
134 }
135 fn register_callsite(
136 &self,
137 _metadata: &'static tracing::Metadata<'static>,
138 ) -> tracing::subscriber::Interest {
139 tracing::subscriber::Interest::always()
140 }
141 fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
142 tracing::span::Id::from_u64(1)
143 }
144 fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
145 fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
146 fn event(&self, event: &tracing::Event<'_>) {
147 // Callsites are cached as always-enabled, so the macros never call
148 // `enabled`; call it here so it is exercised.
149 assert!(self.enabled(event.metadata()));
150 }
151 fn enter(&self, _span: &tracing::span::Id) {}
152 fn exit(&self, _span: &tracing::span::Id) {}
153 fn max_level_hint(&self) -> Option<tracing::metadata::LevelFilter> {
154 Some(tracing::metadata::LevelFilter::TRACE)
155 }
156 }
157
158 /// Exercise the span methods the logging path never reaches on its own, so
159 /// the shim above is fully covered rather than only its `event` arm.
160 #[test]
161 fn always_on_subscriber_span_methods_are_all_no_ops() {
162 with_tracing(|| {
163 let span = tracing::info_span!("test-span", field = tracing::field::Empty);
164 span.record("field", 1);
165 let other = tracing::info_span!("other-span");
166 span.follows_from(&other);
167 let _enter = span.enter();
168 tracing::info!(parent: &span, "inside span");
169 });
170 }
171
172 /// Install [`AlwaysOnSubscriber`] once per test binary and run `f` under it.
173 fn with_tracing<T>(f: impl FnOnce() -> T) -> T {
174 static INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
175 INSTALLED.get_or_init(|| {
176 // Required: callsites registered before the global default is set
177 // are cached as interest=never, so without this the macro bodies
178 // stay unreachable.
179 let _ = tracing::subscriber::set_global_default(AlwaysOnSubscriber);
180 tracing::callsite::rebuild_interest_cache();
181 });
182 f()
183 }
184
185 #[test]
186 fn spawn_detached_launches_a_real_process() {
187 // `true` exits immediately; this exercises the real spawn path without
188 // opening anything. On the off chance `true` is absent, a spawn error
189 // is still a valid Ok/Err from the function under test.
190 let mut cmd = Command::new("true");
191 let _ = spawn_detached(&mut cmd);
192 }
193
194 #[test]
195 fn spawn_detached_errors_on_a_missing_program() {
196 let mut cmd = Command::new("/nonexistent/browser/launcher");
197 assert!(spawn_detached(&mut cmd).is_err());
198 }
199
200 #[test]
201 fn open_url_runs_the_real_launcher_without_opening_a_browser() {
202 // Drives the real public entry point. The target is a bare, non-existent
203 // name rather than a URL, so whichever launcher the host resolves
204 // (`open`, `xdg-open`, or `cmd /C start`) errors on a missing file
205 // instead of opening a browser. This exercises the real `open_url`
206 // delegation on every platform without launching anything. The return
207 // value is host-dependent (whether the launcher itself is present), so
208 // we only require the call not to panic.
209 let _ = open_url("leviath-open-url-test-target");
210 }
211}