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    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}