Skip to main content

browser_commander/browser/
default_browser.rs

1//! Resolve the operating-system default web browser to a canonical catalogue id
2//! (#114), so `browser: "default"` imports from whichever browser a person
3//! actually uses. Mirrors `js/src/browser/default-browser.js`.
4//!
5//! Each platform records the default differently:
6//!
7//! - macOS keeps it in the LaunchServices database as the `https` URL-scheme
8//!   handler's bundle id (`defaults read
9//!   com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers`).
10//! - Linux reports it through `xdg-settings get default-web-browser` (a
11//!   `.desktop` file name), falling back to the `x-scheme-handler/https`
12//!   association from `xdg-mime`.
13//! - Windows stores the `https` UserChoice ProgId under
14//!   `HKCU\...\UrlAssociations\https\UserChoice`.
15//!
16//! Every lookup runs through an injected [`RunCommand`] so the resolver is
17//! deterministic in tests, and the raw identifier is matched against the
18//! `default` identifiers each browser declares in `browser-sources.json`.
19
20use std::sync::{Arc, LazyLock};
21
22use anyhow::Result;
23use regex::Regex;
24
25use super::browser_sources::{browser_sources, Environment};
26use crate::utilities::subprocess::{run_command_blocking, RunCommandOptions};
27
28/// A command runner: `(command, args, environment) -> stdout`. A spawn failure
29/// (for example a missing binary) is an `Err`; any exit code, even non-zero, is
30/// an `Ok` with the captured stdout. Injected so the resolver is deterministic
31/// in tests.
32pub type RunCommand = Arc<dyn Fn(&str, &[&str], &Environment) -> Result<String> + Send + Sync>;
33
34/// The default [`RunCommand`], backed by a real (shell-free) subprocess.
35pub fn default_run_command() -> RunCommand {
36    Arc::new(|command, args, environment| {
37        let output = run_command_blocking(
38            command,
39            args,
40            RunCommandOptions {
41                env: Some(environment.clone()),
42                check: false,
43                ..RunCommandOptions::default()
44            },
45        )?;
46        Ok(output.stdout)
47    })
48}
49
50static MAC_HTTPS: LazyLock<Regex> =
51    LazyLock::new(|| Regex::new(r#"LSHandlerURLScheme\s*=\s*"?https"?\s*;"#).expect("valid regex"));
52static MAC_ROLE: LazyLock<Regex> = LazyLock::new(|| {
53    Regex::new(r#"LSHandlerRoleAll\s*=\s*"?([^";]+)"?\s*;"#).expect("valid regex")
54});
55static WIN_PROGID: LazyLock<Regex> =
56    LazyLock::new(|| Regex::new(r"(?i)\bProgId\b").expect("valid regex"));
57
58/// Match a raw OS identifier (bundle id, `.desktop` name or ProgId) to a
59/// canonical browser id. Comparison is case-insensitive because the registry
60/// and the operating systems disagree on casing.
61pub fn browser_for_identifier(identifier: &str, platform: &str) -> Option<&'static str> {
62    let needle = identifier.trim().to_lowercase();
63    if needle.is_empty() {
64        return None;
65    }
66    for source in browser_sources() {
67        if let Some(identifiers) = source.default.get(platform) {
68            if identifiers
69                .iter()
70                .any(|candidate| candidate.to_lowercase() == needle)
71            {
72                return Some(source.id.as_str());
73            }
74        }
75    }
76    None
77}
78
79/// Read the `https` handler bundle id out of `defaults read ... LSHandlers`
80/// output. The output is NeXTSTEP-style plist text; each handler is a brace
81/// block that may carry an `LSHandlerURLScheme` and an `LSHandlerRoleAll`.
82pub fn parse_mac_launch_services_handler(output: &str) -> Option<String> {
83    for block in output.split('}') {
84        if !MAC_HTTPS.is_match(block) {
85            continue;
86        }
87        if let Some(captures) = MAC_ROLE.captures(block) {
88            return Some(captures[1].trim().to_string());
89        }
90    }
91    None
92}
93
94/// Read the ProgId out of `reg query ... /v ProgId` output: `reg` prints
95/// `    ProgId    REG_SZ    FirefoxHTML`, so take the last whitespace token of
96/// the ProgId line.
97pub fn parse_windows_prog_id(output: &str) -> Option<String> {
98    for raw_line in output.split('\n') {
99        let line = raw_line.trim_end_matches('\r');
100        if WIN_PROGID.is_match(line) {
101            return line.split_whitespace().last().map(str::to_string);
102        }
103    }
104    None
105}
106
107fn resolve_darwin_default(
108    run_command: &RunCommand,
109    environment: &Environment,
110) -> Result<Option<&'static str>> {
111    let output = run_command(
112        "defaults",
113        &[
114            "read",
115            "com.apple.LaunchServices/com.apple.launchservices.secure",
116            "LSHandlers",
117        ],
118        environment,
119    )?;
120    Ok(parse_mac_launch_services_handler(&output)
121        .and_then(|identifier| browser_for_identifier(&identifier, "darwin")))
122}
123
124fn resolve_linux_default(
125    run_command: &RunCommand,
126    environment: &Environment,
127) -> Result<Option<&'static str>> {
128    let desktop = run_command("xdg-settings", &["get", "default-web-browser"], environment)
129        .map(|stdout| stdout.trim().to_string())
130        .unwrap_or_default();
131    if let Some(resolved) = browser_for_identifier(&desktop, "linux") {
132        return Ok(Some(resolved));
133    }
134    let fallback = match run_command(
135        "xdg-mime",
136        &["query", "default", "x-scheme-handler/https"],
137        environment,
138    ) {
139        Ok(stdout) => stdout.trim().to_string(),
140        Err(_) => return Ok(None),
141    };
142    Ok(browser_for_identifier(&fallback, "linux"))
143}
144
145fn resolve_windows_default(
146    run_command: &RunCommand,
147    environment: &Environment,
148) -> Result<Option<&'static str>> {
149    let output = match run_command(
150        "reg",
151        &[
152            "query",
153            "HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\https\\UserChoice",
154            "/v",
155            "ProgId",
156        ],
157        environment,
158    ) {
159        Ok(output) => output,
160        Err(_) => return Ok(None),
161    };
162    Ok(parse_windows_prog_id(&output)
163        .and_then(|identifier| browser_for_identifier(&identifier, "win32")))
164}
165
166/// Resolve the system default browser to a canonical catalogue id, or `None`
167/// when it cannot be determined. On macOS a failure of the lookup tool
168/// propagates as an error (there is no fallback); on Linux and Windows a missing
169/// tool resolves to `None`.
170pub fn resolve_default_browser(
171    platform: &str,
172    environment: &Environment,
173    run_command: &RunCommand,
174) -> Result<Option<&'static str>> {
175    match platform {
176        "darwin" => resolve_darwin_default(run_command, environment),
177        "linux" => resolve_linux_default(run_command, environment),
178        "win32" => resolve_windows_default(run_command, environment),
179        _ => Ok(None),
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    // feature-parity: sources.default-browser@native-typed
186    use super::*;
187    use std::collections::HashMap;
188
189    fn runner_returning(pairs: &[(&'static str, &'static str)]) -> RunCommand {
190        let map: HashMap<String, String> = pairs
191            .iter()
192            .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
193            .collect();
194        Arc::new(move |command: &str, args: &[&str], _env: &Environment| {
195            let key = std::iter::once(command)
196                .chain(args.iter().copied())
197                .collect::<Vec<_>>()
198                .join(" ");
199            map.get(&key)
200                .cloned()
201                .ok_or_else(|| anyhow::anyhow!("unexpected command: {key}"))
202        })
203    }
204
205    fn resolve(platform: &str, run_command: &RunCommand) -> Option<&'static str> {
206        resolve_default_browser(platform, &Environment::new(), run_command).unwrap()
207    }
208
209    #[test]
210    fn maps_a_macos_launch_services_https_handler_to_a_browser_id() {
211        let output = "(\n      {\n        LSHandlerRoleAll = \"com.apple.safari\";\n        \
212             LSHandlerURLScheme = mailto;\n      },\n      {\n        \
213             LSHandlerRoleAll = \"com.google.chrome\";\n        \
214             LSHandlerURLScheme = https;\n      }\n    )";
215        let run_command = runner_returning(&[(
216            "defaults read com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers",
217            output,
218        )]);
219        assert_eq!(resolve("darwin", &run_command), Some("chrome"));
220    }
221
222    #[test]
223    fn maps_a_linux_xdg_settings_desktop_name_to_a_browser_id() {
224        let run_command =
225            runner_returning(&[("xdg-settings get default-web-browser", "firefox.desktop\n")]);
226        assert_eq!(resolve("linux", &run_command), Some("firefox"));
227    }
228
229    #[test]
230    fn falls_back_to_xdg_mime_when_xdg_settings_is_unknown() {
231        let run_command = runner_returning(&[
232            (
233                "xdg-settings get default-web-browser",
234                "some-unknown.desktop\n",
235            ),
236            (
237                "xdg-mime query default x-scheme-handler/https",
238                "brave-browser.desktop\n",
239            ),
240        ]);
241        assert_eq!(resolve("linux", &run_command), Some("brave"));
242    }
243
244    #[test]
245    fn maps_a_windows_userchoice_progid_to_a_browser_id() {
246        let output = [
247            "",
248            "HKEY_CURRENT_USER\\...\\https\\UserChoice",
249            "    ProgId    REG_SZ    MSEdgeHTM",
250            "",
251        ]
252        .join("\r\n");
253        let run_command = runner_returning(&[(
254            "reg query HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\https\\UserChoice /v ProgId",
255            Box::leak(output.into_boxed_str()),
256        )]);
257        assert_eq!(resolve("win32", &run_command), Some("edge"));
258    }
259
260    #[test]
261    fn returns_none_when_nothing_matches_or_the_tool_fails() {
262        let failing: RunCommand = Arc::new(|_command: &str, _args: &[&str], _env: &Environment| {
263            Err(anyhow::anyhow!("no xdg"))
264        });
265        assert_eq!(resolve("linux", &failing), None);
266
267        let empty: RunCommand =
268            Arc::new(|_command: &str, _args: &[&str], _env: &Environment| Ok(String::new()));
269        assert_eq!(resolve("sunos", &empty), None);
270    }
271
272    #[test]
273    fn parses_handler_blocks_and_progid_lines_directly() {
274        assert_eq!(
275            parse_mac_launch_services_handler(
276                "{ LSHandlerURLScheme = https; LSHandlerRoleAll = \"com.brave.browser\"; }"
277            ),
278            Some("com.brave.browser".to_string())
279        );
280        assert_eq!(
281            parse_windows_prog_id("  ProgId   REG_SZ   FirefoxHTML"),
282            Some("FirefoxHTML".to_string())
283        );
284        assert_eq!(
285            browser_for_identifier("COM.GOOGLE.CHROME", "darwin"),
286            Some("chrome")
287        );
288        assert_eq!(browser_for_identifier("", "darwin"), None);
289    }
290}