Skip to main content

browser_cat/
browser.rs

1/// Cross-platform browser launcher.
2///
3/// Ported from bcat's lib/bcat/browser.rb by Ryan Tomayko.
4use std::io;
5use std::process::{Child, Command, Stdio};
6
7// ── Platform detection ────────────────────────────────────────────────────────
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10enum Platform {
11    Macos,
12    Linux,
13    Windows,
14    Unknown,
15}
16
17fn platform() -> Platform {
18    if cfg!(target_os = "macos") {
19        Platform::Macos
20    } else if cfg!(target_os = "windows") {
21        Platform::Windows
22    } else if cfg!(target_os = "linux") {
23        Platform::Linux
24    } else {
25        Platform::Unknown
26    }
27}
28
29// ── Browser command table ─────────────────────────────────────────────────────
30
31/// Resolve a browser name to the argv vec used to launch it.
32/// Returns `None` for unknown names (fall back to system default).
33fn browser_argv(name: &str) -> Option<Vec<&'static str>> {
34    // Normalise aliases first.
35    let name = match name {
36        "google-chrome" | "google_chrome" => "chrome",
37        "chromium-browser" => "chromium",
38        other => other,
39    };
40
41    match platform() {
42        Platform::Macos => Some(match name {
43            "default" => vec!["open"],
44            "safari" => vec!["open", "-a", "Safari"],
45            "firefox" => vec!["open", "-a", "Firefox"],
46            "chrome" => vec!["open", "-a", "Google Chrome"],
47            "chromium" => vec!["open", "-a", "Chromium"],
48            "opera" => vec!["open", "-a", "Opera"],
49            "curl" => vec!["curl", "-s"],
50            _ => return None,
51        }),
52        Platform::Linux | Platform::Unknown => Some(match name {
53            "default" => vec!["xdg-open"],
54            "firefox" => vec!["firefox"],
55            "chrome" => vec!["google-chrome"],
56            "chromium" => vec!["chromium"],
57            "mozilla" => vec!["mozilla"],
58            "epiphany" => vec!["epiphany"],
59            "curl" => vec!["curl", "-s"],
60            _ => return None,
61        }),
62        Platform::Windows => Some(match name {
63            "default" | "chrome" | "firefox" | "edge" => vec!["cmd", "/c", "start", ""],
64            "curl" => vec!["curl", "-s"],
65            _ => return None,
66        }),
67    }
68}
69
70// ── Browser ───────────────────────────────────────────────────────────────────
71
72/// Launches a browser and returns the child process handle.
73pub struct Browser {
74    name: String,
75}
76
77impl Browser {
78    /// Create a browser launcher.
79    ///
80    /// `name` is one of the named browsers (`"safari"`, `"firefox"`,
81    /// `"chrome"`, `"chromium"`, `"opera"`, `"curl"`) or `"default"` to use
82    /// the system default. Checked against `BCAT_BROWSER` env var at
83    /// construction time by the caller.
84    pub fn new(name: impl Into<String>) -> Self {
85        Self { name: name.into() }
86    }
87
88    /// Open `url` in the configured browser. Returns the child process.
89    pub fn open(&self, url: &str) -> io::Result<Child> {
90        let argv = browser_argv(&self.name).unwrap_or_else(|| {
91            // Unknown name — treat as a literal command path.
92            vec![] // handled below
93        });
94
95        if argv.is_empty() {
96            // Literal command (not in our table): just exec it with the URL.
97            return Command::new(&self.name)
98                .arg(url)
99                .stdin(Stdio::null())
100                .stdout(Stdio::null())
101                .stderr(Stdio::null())
102                .spawn();
103        }
104
105        // Windows `start` needs the URL as a separate arg after the title arg.
106        let mut cmd = Command::new(argv[0]);
107        for arg in &argv[1..] {
108            cmd.arg(arg);
109        }
110        cmd.arg(url)
111            .stdin(Stdio::null())
112            .stdout(Stdio::null())
113            .stderr(Stdio::null())
114            .spawn()
115    }
116
117    /// The resolved command string (for debug logging).
118    pub fn command(&self) -> String {
119        let argv = browser_argv(&self.name);
120        match argv {
121            Some(v) => v.join(" "),
122            None => self.name.clone(),
123        }
124    }
125}
126
127// ── Tests ─────────────────────────────────────────────────────────────────────
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn default_browser_has_command() {
135        let b = Browser::new("default");
136        assert!(!b.command().is_empty());
137    }
138
139    #[test]
140    fn alias_google_chrome() {
141        // The alias should resolve to the same command as "chrome".
142        let argv_alias = browser_argv("google-chrome");
143        let argv_canon = browser_argv("chrome");
144        assert_eq!(argv_alias, argv_canon);
145    }
146
147    #[test]
148    fn unknown_browser_returns_none_from_table() {
149        assert!(browser_argv("netscape").is_none());
150    }
151
152    #[test]
153    fn curl_browser_known() {
154        let argv = browser_argv("curl");
155        assert!(argv.is_some());
156        let v = argv.unwrap();
157        assert_eq!(v[0], "curl");
158    }
159}