Skip to main content

browser_commander/browser/
open_in_user_browser.rs

1//! Open a URL with the operating system's default browser, without automation.
2
3use anyhow::{anyhow, Result};
4use serde::{Deserialize, Serialize};
5use url::Url;
6
7use crate::utilities::subprocess::{run_command, RunCommandOptions};
8
9const ALLOWED_SCHEMES: [&str; 8] = [
10    "http",
11    "https",
12    "file",
13    "ftp",
14    "about",
15    "chrome",
16    "edge",
17    "view-source",
18];
19
20/// The URL handed to the system browser and the exact opener invocation.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct OpenInUserBrowserResult {
23    pub opened: String,
24    pub command: Vec<String>,
25}
26
27/// Validate that an opener will receive a web URL rather than a command option.
28pub fn validate_open_url(url: &str) -> Result<&str> {
29    if url.is_empty() {
30        return Err(anyhow!("open_in_user_browser requires a URL string"));
31    }
32    if url.starts_with('-') {
33        return Err(anyhow!(
34            "Refusing to open {url:?}: a URL cannot start with '-'"
35        ));
36    }
37    let parsed = Url::parse(url)
38        .map_err(|_| anyhow!("Refusing to open {url:?}: it is not a valid absolute URL"))?;
39    if !ALLOWED_SCHEMES.contains(&parsed.scheme()) {
40        return Err(anyhow!(
41            "Refusing to open {url:?}: {} is not an allowed web scheme",
42            parsed.scheme()
43        ));
44    }
45    Ok(url)
46}
47
48/// Build the platform opener's argv. The URL is always the last argument.
49pub fn build_open_command(url: &str, platform: &str) -> Result<Vec<String>> {
50    validate_open_url(url)?;
51    let mut command = match platform {
52        "linux" => vec!["xdg-open".to_string()],
53        "macos" | "darwin" => vec!["open".to_string()],
54        "windows" | "win32" => vec!["explorer.exe".to_string()],
55        _ => {
56            return Err(anyhow!(
57                "open_in_user_browser is not supported on {platform}"
58            ))
59        }
60    };
61    command.push(url.to_string());
62    Ok(command)
63}
64
65/// Show a URL in the user's default browser; no CDP port or profile is made.
66pub async fn open_in_user_browser(url: &str) -> Result<OpenInUserBrowserResult> {
67    let command = build_open_command(url, std::env::consts::OS)?;
68    run_command(&command[0], &command[1..], RunCommandOptions::default()).await?;
69    Ok(OpenInUserBrowserResult {
70        opened: url.to_string(),
71        command,
72    })
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    // feature-parity: attach.open
80    #[test]
81    fn builds_each_system_opener_without_a_shell() {
82        assert_eq!(
83            build_open_command("https://example.com/", "linux").unwrap(),
84            ["xdg-open", "https://example.com/"]
85        );
86        assert_eq!(
87            build_open_command("https://example.com/", "darwin").unwrap(),
88            ["open", "https://example.com/"]
89        );
90        assert_eq!(
91            build_open_command("https://example.com/", "win32").unwrap(),
92            ["explorer.exe", "https://example.com/"]
93        );
94        assert_eq!(
95            build_open_command("https://example.com/?a=1&b=2", "win32").unwrap(),
96            ["explorer.exe", "https://example.com/?a=1&b=2"]
97        );
98    }
99
100    #[test]
101    fn rejects_non_web_schemes_and_options() {
102        assert!(validate_open_url("javascript:alert(1)").is_err());
103        assert!(validate_open_url("--version").is_err());
104        assert!(validate_open_url("not a url").is_err());
105    }
106}