Skip to main content

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}