1use std::collections::HashMap;
17use std::path::PathBuf;
18use std::sync::LazyLock;
19
20use anyhow::{anyhow, Result};
21use serde::{Deserialize, Serialize};
22
23use super::browser_profiles::normalize_platform;
24
25pub const BROWSER_SOURCES_SOURCE: &str = include_str!("browser-sources.json");
27
28pub type Environment = HashMap<String, String>;
30
31pub(crate) fn current_environment() -> Environment {
35 std::env::vars().collect()
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct SafeStorageIdentity {
41 pub service: String,
43 pub application: String,
45 pub folder: String,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct BrowserSource {
53 pub id: String,
55 pub family: String,
57 #[serde(default)]
59 pub aliases: Vec<String>,
60 #[serde(default)]
62 pub single_profile: bool,
63 #[serde(default)]
65 pub roots: HashMap<String, Vec<String>>,
66 #[serde(default)]
68 pub safe_storage: Option<SafeStorageIdentity>,
69 #[serde(default)]
71 pub default: HashMap<String, Vec<String>>,
72 #[serde(default)]
74 pub executable_names: Vec<String>,
75 #[serde(default)]
77 pub executables: HashMap<String, Vec<String>>,
78 #[serde(default)]
80 pub control_protocol: Option<String>,
81}
82
83#[derive(Debug, Deserialize)]
84struct Catalogue {
85 browsers: Vec<BrowserSource>,
86}
87
88struct Registry {
89 browsers: Vec<BrowserSource>,
90 by_name: HashMap<String, usize>,
91}
92
93static REGISTRY: LazyLock<Registry> = LazyLock::new(|| {
94 let catalogue: Catalogue = serde_json::from_str(BROWSER_SOURCES_SOURCE)
95 .expect("browser-sources.json is embedded at compile time and has to parse");
96 let mut by_name = HashMap::new();
97 for (index, browser) in catalogue.browsers.iter().enumerate() {
98 by_name.insert(browser.id.clone(), index);
99 for alias in &browser.aliases {
100 by_name.insert(alias.clone(), index);
101 }
102 }
103 Registry {
104 browsers: catalogue.browsers,
105 by_name,
106 }
107});
108
109pub static SUPPORTED_COOKIE_BROWSERS: LazyLock<Vec<&'static str>> = LazyLock::new(browser_ids);
112
113pub fn browser_sources() -> &'static [BrowserSource] {
115 ®ISTRY.browsers
116}
117
118pub fn browser_ids() -> Vec<&'static str> {
120 REGISTRY.browsers.iter().map(|b| b.id.as_str()).collect()
121}
122
123pub fn find_browser_source(name: &str) -> Option<&'static BrowserSource> {
125 REGISTRY
126 .by_name
127 .get(name)
128 .or_else(|| REGISTRY.by_name.get(&name.to_lowercase()))
129 .map(|&index| ®ISTRY.browsers[index])
130}
131
132fn normalize_browser_source(name: &str) -> Result<&'static BrowserSource> {
133 find_browser_source(name).ok_or_else(|| {
134 anyhow!(
135 "Unsupported browser: {name}. Expected one of {}",
136 browser_ids().join(", ")
137 )
138 })
139}
140
141pub fn normalize_browser_id(name: &str) -> Result<&'static str> {
143 Ok(normalize_browser_source(name)?.id.as_str())
144}
145
146pub fn browser_family(name: &str) -> Result<&'static str> {
148 Ok(normalize_browser_source(name)?.family.as_str())
149}
150
151pub fn is_single_profile_browser(name: &str) -> Result<bool> {
153 Ok(normalize_browser_source(name)?.single_profile)
154}
155
156pub fn safe_storage_identity(name: &str) -> Result<Option<&'static SafeStorageIdentity>> {
159 Ok(normalize_browser_source(name)?.safe_storage.as_ref())
160}
161
162pub fn default_browser_identifiers(name: &str, platform: &str) -> Result<Vec<&'static str>> {
165 Ok(normalize_browser_source(name)?
166 .default
167 .get(platform)
168 .map(|ids| ids.iter().map(String::as_str).collect())
169 .unwrap_or_default())
170}
171
172fn separator(platform: &str) -> char {
173 if platform == "win32" {
174 '\\'
175 } else {
176 '/'
177 }
178}
179
180fn join_with(base: &str, parts: &[&str], separator: char) -> String {
181 let mut result = base.to_string();
182 for part in parts {
183 result.push(separator);
184 result.push_str(part);
185 }
186 result
187}
188
189fn template_variables(
190 platform: &str,
191 home_dir: &str,
192 environment: &Environment,
193) -> HashMap<&'static str, String> {
194 let mut variables = HashMap::new();
195 variables.insert("home", home_dir.to_string());
196 match platform {
197 "darwin" => {
198 variables.insert(
199 "appSupport",
200 join_with(home_dir, &["Library", "Application Support"], '/'),
201 );
202 }
203 "win32" => {
204 for (variable, key) in [
205 ("programFiles", "PROGRAMFILES"),
206 ("programFilesX86", "PROGRAMFILES(X86)"),
207 ] {
208 if let Some(value) = environment.get(key) {
209 variables.insert(variable, value.clone());
210 }
211 }
212 variables.insert(
213 "localAppData",
214 environment
215 .get("LOCALAPPDATA")
216 .cloned()
217 .unwrap_or_else(|| join_with(home_dir, &["AppData", "Local"], '\\')),
218 );
219 variables.insert(
220 "appData",
221 environment
222 .get("APPDATA")
223 .cloned()
224 .unwrap_or_else(|| join_with(home_dir, &["AppData", "Roaming"], '\\')),
225 );
226 }
227 _ => {
228 variables.insert(
229 "config",
230 environment
231 .get("XDG_CONFIG_HOME")
232 .cloned()
233 .unwrap_or_else(|| join_with(home_dir, &[".config"], '/')),
234 );
235 }
236 }
237 variables
238}
239
240fn expand_template(
241 template: &str,
242 variables: &HashMap<&'static str, String>,
243 separator: char,
244) -> Option<String> {
245 if !template.starts_with('{') {
246 return Some(template.to_string());
247 }
248 let end = template.find('}')?;
249 let key = &template[1..end];
250 let base = variables.get(key)?;
251 let parts: Vec<&str> = template[end + 1..]
252 .split('/')
253 .filter(|segment| !segment.is_empty())
254 .collect();
255 Some(join_with(base, &parts, separator))
256}
257
258pub fn resolve_browser_roots(
263 name: &str,
264 platform: &str,
265 home_dir: &str,
266 environment: &Environment,
267) -> Result<Vec<PathBuf>> {
268 let platform = normalize_platform(platform);
269 let source = normalize_browser_source(name)?;
270 let Some(templates) = source.roots.get(platform) else {
271 return Ok(Vec::new());
272 };
273 let variables = template_variables(platform, home_dir, environment);
274 let separator = separator(platform);
275 Ok(templates
276 .iter()
277 .filter_map(|template| expand_template(template, &variables, separator))
278 .map(PathBuf::from)
279 .collect())
280}
281
282pub fn resolve_browser_executables(
284 name: &str,
285 platform: &str,
286 home_dir: &str,
287 environment: &Environment,
288) -> Result<Vec<PathBuf>> {
289 let platform = normalize_platform(platform);
290 let source = normalize_browser_source(name)?;
291 let variables = template_variables(platform, home_dir, environment);
292 let sep = separator(platform);
293 let mut candidates: Vec<PathBuf> = source
294 .executables
295 .get(platform)
296 .into_iter()
297 .flatten()
298 .filter_map(|template| expand_template(template, &variables, sep))
299 .map(PathBuf::from)
300 .collect();
301 for directory in environment
302 .get("PATH")
303 .map(String::as_str)
304 .unwrap_or_default()
305 .split(if platform == "win32" { ';' } else { ':' })
306 .filter(|entry| !entry.is_empty())
307 {
308 for name in &source.executable_names {
309 let executable = if platform == "win32" {
310 format!("{name}.exe")
311 } else {
312 name.clone()
313 };
314 candidates.push(PathBuf::from(join_with(directory, &[&executable], sep)));
315 }
316 }
317 let mut seen = std::collections::HashSet::new();
318 candidates.retain(|entry| seen.insert(entry.clone()));
319 Ok(candidates)
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
326
327 fn env(pairs: &[(&str, &str)]) -> Environment {
328 pairs
329 .iter()
330 .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
331 .collect()
332 }
333
334 fn roots(name: &str, platform: &str, home: &str, environment: &[(&str, &str)]) -> Vec<String> {
335 resolve_browser_roots(name, platform, home, &env(environment))
336 .unwrap()
337 .into_iter()
338 .map(|path| path.to_string_lossy().into_owned())
339 .collect()
340 }
341
342 #[test]
343 fn keeps_the_original_five_browsers_resolvable_by_id_and_alias() {
344 assert_eq!(normalize_browser_id("chrome").unwrap(), "chrome");
345 assert_eq!(normalize_browser_id("msedge").unwrap(), "edge");
346 assert_eq!(normalize_browser_id("microsoft-edge").unwrap(), "edge");
347 assert_eq!(normalize_browser_id("google-chrome").unwrap(), "chrome");
348 assert_eq!(normalize_browser_id("CHROME").unwrap(), "chrome");
349 }
350
351 #[test]
352 fn adds_the_new_source_browsers_from_114() {
353 for id in [
354 "opera",
355 "opera-gx",
356 "vivaldi",
357 "arc",
358 "yandex",
359 "chrome-beta",
360 "chrome-dev",
361 "chrome-canary",
362 "edge-beta",
363 "edge-dev",
364 "librewolf",
365 "waterfox",
366 "zen",
367 "floorp",
368 "firefox-developer",
369 "firefox-nightly",
370 ] {
371 assert!(browser_ids().contains(&id), "missing {id}");
372 assert!(find_browser_source(id).is_some(), "unresolvable {id}");
373 }
374 }
375
376 #[test]
377 fn rejects_an_unknown_browser_with_the_catalogue_listed() {
378 let error = normalize_browser_id("netscape").unwrap_err().to_string();
379 assert!(
380 error.starts_with("Unsupported browser: netscape. Expected one of"),
381 "{error}"
382 );
383 assert!(error.contains("chrome"), "{error}");
384 }
385
386 #[test]
387 fn classifies_browser_families() {
388 assert_eq!(browser_family("chrome").unwrap(), "chromium");
389 assert_eq!(browser_family("opera").unwrap(), "chromium");
390 assert_eq!(browser_family("firefox").unwrap(), "firefox");
391 assert_eq!(browser_family("librewolf").unwrap(), "firefox");
392 }
393
394 #[test]
395 fn expands_per_platform_roots_with_the_home_directory() {
396 assert_eq!(
397 roots("chrome", "linux", "/home/me", &[]),
398 ["/home/me/.config/google-chrome"]
399 );
400 assert_eq!(
401 roots("firefox", "darwin", "/Users/me", &[]),
402 ["/Users/me/Library/Application Support/Firefox"]
403 );
404 }
405
406 #[test]
407 fn honours_xdg_config_home_on_linux() {
408 assert_eq!(
409 roots(
410 "chromium",
411 "linux",
412 "/home/me",
413 &[("XDG_CONFIG_HOME", "/cfg")]
414 ),
415 ["/cfg/chromium"]
416 );
417 }
418
419 #[test]
420 fn builds_windows_roots_with_backslashes() {
421 assert_eq!(
422 roots(
423 "opera",
424 "win32",
425 "C:\\Users\\me",
426 &[("APPDATA", "C:\\Users\\me\\AppData\\Roaming")]
427 ),
428 ["C:\\Users\\me\\AppData\\Roaming\\Opera Software\\Opera Stable"]
429 );
430 assert_eq!(
431 roots(
432 "chrome",
433 "win32",
434 "C:\\Users\\me",
435 &[("LOCALAPPDATA", "C:\\Users\\me\\AppData\\Local")]
436 ),
437 ["C:\\Users\\me\\AppData\\Local\\Google\\Chrome\\User Data"]
438 );
439 }
440
441 #[test]
442 fn returns_no_root_where_a_browser_does_not_run() {
443 assert!(roots("chrome-canary", "linux", "/home/me", &[]).is_empty());
444 assert!(roots("arc", "linux", "/home/me", &[]).is_empty());
445 }
446
447 #[test]
448 fn marks_opera_style_browsers_as_single_profile() {
449 assert!(is_single_profile_browser("opera").unwrap());
450 assert!(is_single_profile_browser("opera-gx").unwrap());
451 assert!(!is_single_profile_browser("chrome").unwrap());
452 assert!(!is_single_profile_browser("vivaldi").unwrap());
453 }
454
455 #[test]
456 fn exposes_a_safe_storage_identity_for_chromium_and_none_for_firefox() {
457 assert_eq!(
458 safe_storage_identity("brave").unwrap(),
459 Some(&SafeStorageIdentity {
460 service: "Brave Safe Storage".into(),
461 application: "brave".into(),
462 folder: "Brave Keys".into(),
463 })
464 );
465 assert_eq!(safe_storage_identity("firefox").unwrap(), None);
466 }
467
468 #[test]
469 fn exposes_default_browser_identifiers_per_platform() {
470 assert_eq!(
471 default_browser_identifiers("chrome", "darwin").unwrap(),
472 ["com.google.chrome"]
473 );
474 assert_eq!(
475 default_browser_identifiers("firefox", "win32").unwrap(),
476 ["FirefoxHTML", "FirefoxURL"]
477 );
478 assert!(default_browser_identifiers("chrome", "nope")
479 .unwrap()
480 .is_empty());
481 }
482}