Skip to main content

browser_commander/browser/
browser_sources.rs

1//! Catalogue of installed browsers Browser Commander can import from (#114).
2//!
3//! The data lives in `browser-sources.json`, a shared asset JavaScript, Python
4//! and Rust duplicate byte-for-byte (checked by
5//! `scripts/check-shared-fingerprint-assets.sh`). This module turns that data
6//! into the lookups the rest of the browser code needs: canonical ids with
7//! their aliases, the per-platform profile roots, the Chromium Safe Storage
8//! identity, and the operating-system identifiers that mark a browser as the
9//! system default.
10//!
11//! Keeping it data-driven is what lets a single JSON edit add Opera, Vivaldi,
12//! Arc, a Firefox fork, or a Chrome channel to all three implementations at
13//! once, rather than touching hand-written per-platform maps in each language.
14//! It mirrors `js/src/browser/browser-sources.js`.
15
16use 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
25/// The shared catalogue source, embedded at compile time.
26pub const BROWSER_SOURCES_SOURCE: &str = include_str!("browser-sources.json");
27
28/// Extra environment variables, used when expanding profile-root templates.
29pub type Environment = HashMap<String, String>;
30
31/// A snapshot of the process environment, matching JavaScript's `process.env`.
32/// Used so `%APPDATA%`/`%LOCALAPPDATA%`/`$XDG_CONFIG_HOME` resolve the same way
33/// the default (no-override) code path does.
34pub(crate) fn current_environment() -> Environment {
35    std::env::vars().collect()
36}
37
38/// The Chromium Safe Storage identity for a browser.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct SafeStorageIdentity {
41    /// Keychain/secret service name (for example `Brave Safe Storage`).
42    pub service: String,
43    /// Application id used by the Linux secret stores.
44    pub application: String,
45    /// Keychain folder/account name.
46    pub folder: String,
47}
48
49/// One browser in the shared catalogue.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct BrowserSource {
53    /// Canonical browser id (for example `chrome`, `opera`, `librewolf`).
54    pub id: String,
55    /// Family (`chromium` or `firefox`).
56    pub family: String,
57    /// Alternative names that resolve to this browser.
58    #[serde(default)]
59    pub aliases: Vec<String>,
60    /// Whether the browser keeps one profile in the root itself (Opera-style).
61    #[serde(default)]
62    pub single_profile: bool,
63    /// Per-platform profile-root templates keyed by `darwin`/`win32`/`linux`.
64    #[serde(default)]
65    pub roots: HashMap<String, Vec<String>>,
66    /// Chromium Safe Storage identity, absent for Firefox-family browsers.
67    #[serde(default)]
68    pub safe_storage: Option<SafeStorageIdentity>,
69    /// Per-platform operating-system default-browser identifiers.
70    #[serde(default)]
71    pub default: HashMap<String, Vec<String>>,
72    /// Executable basenames used for PATH discovery.
73    #[serde(default)]
74    pub executable_names: Vec<String>,
75    /// Per-platform executable path templates.
76    #[serde(default)]
77    pub executables: HashMap<String, Vec<String>>,
78    /// Required protocol for installed browser control.
79    #[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
109/// Browsers whose on-disk cookie stores can be imported, in catalogue order.
110/// Mirrors `SUPPORTED_COOKIE_BROWSERS`/`BROWSER_IDS` in the JavaScript library.
111pub static SUPPORTED_COOKIE_BROWSERS: LazyLock<Vec<&'static str>> = LazyLock::new(browser_ids);
112
113/// Every known browser, in catalogue order.
114pub fn browser_sources() -> &'static [BrowserSource] {
115    &REGISTRY.browsers
116}
117
118/// Canonical browser ids, in catalogue order.
119pub fn browser_ids() -> Vec<&'static str> {
120    REGISTRY.browsers.iter().map(|b| b.id.as_str()).collect()
121}
122
123/// Resolve a name (canonical id or alias) to its catalogue entry.
124pub 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| &REGISTRY.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
141/// Resolve a name to its canonical id, erroring with the catalogue listed.
142pub fn normalize_browser_id(name: &str) -> Result<&'static str> {
143    Ok(normalize_browser_source(name)?.id.as_str())
144}
145
146/// The family (`chromium` or `firefox`) of a browser name.
147pub fn browser_family(name: &str) -> Result<&'static str> {
148    Ok(normalize_browser_source(name)?.family.as_str())
149}
150
151/// True when a browser stores one profile in the root itself (Opera-style).
152pub fn is_single_profile_browser(name: &str) -> Result<bool> {
153    Ok(normalize_browser_source(name)?.single_profile)
154}
155
156/// The Chromium Safe Storage identity for a browser, or `None` for a
157/// Firefox-family browser (which does not use OSCrypt).
158pub fn safe_storage_identity(name: &str) -> Result<Option<&'static SafeStorageIdentity>> {
159    Ok(normalize_browser_source(name)?.safe_storage.as_ref())
160}
161
162/// The operating-system identifiers that mark a browser as the system default:
163/// macOS bundle ids, Linux `.desktop` file names, or Windows ProgIds.
164pub 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
258/// The absolute profile roots a browser uses on a platform. Returns an empty
259/// vector when the browser does not run on that platform (for example Chrome
260/// Canary on Linux). Windows roots are built with backslashes regardless of the
261/// host, so the catalogue resolves identically on any platform.
262pub 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
282/// Executable paths from the shared catalogue, with PATH fallback.
283pub 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    // feature-parity: sources.catalogue@native-typed
325    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}