browser_commander/browser/
default_browser.rs1use 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
28pub type RunCommand = Arc<dyn Fn(&str, &[&str], &Environment) -> Result<String> + Send + Sync>;
33
34pub 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
58pub 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
79pub 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
94pub 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
166pub 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 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}