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}
73
74#[derive(Debug, Deserialize)]
75struct Catalogue {
76    browsers: Vec<BrowserSource>,
77}
78
79struct Registry {
80    browsers: Vec<BrowserSource>,
81    by_name: HashMap<String, usize>,
82}
83
84static REGISTRY: LazyLock<Registry> = LazyLock::new(|| {
85    let catalogue: Catalogue = serde_json::from_str(BROWSER_SOURCES_SOURCE)
86        .expect("browser-sources.json is embedded at compile time and has to parse");
87    let mut by_name = HashMap::new();
88    for (index, browser) in catalogue.browsers.iter().enumerate() {
89        by_name.insert(browser.id.clone(), index);
90        for alias in &browser.aliases {
91            by_name.insert(alias.clone(), index);
92        }
93    }
94    Registry {
95        browsers: catalogue.browsers,
96        by_name,
97    }
98});
99
100/// Browsers whose on-disk cookie stores can be imported, in catalogue order.
101/// Mirrors `SUPPORTED_COOKIE_BROWSERS`/`BROWSER_IDS` in the JavaScript library.
102pub static SUPPORTED_COOKIE_BROWSERS: LazyLock<Vec<&'static str>> = LazyLock::new(browser_ids);
103
104/// Every known browser, in catalogue order.
105pub fn browser_sources() -> &'static [BrowserSource] {
106    &REGISTRY.browsers
107}
108
109/// Canonical browser ids, in catalogue order.
110pub fn browser_ids() -> Vec<&'static str> {
111    REGISTRY.browsers.iter().map(|b| b.id.as_str()).collect()
112}
113
114/// Resolve a name (canonical id or alias) to its catalogue entry.
115pub fn find_browser_source(name: &str) -> Option<&'static BrowserSource> {
116    REGISTRY
117        .by_name
118        .get(name)
119        .or_else(|| REGISTRY.by_name.get(&name.to_lowercase()))
120        .map(|&index| &REGISTRY.browsers[index])
121}
122
123fn normalize_browser_source(name: &str) -> Result<&'static BrowserSource> {
124    find_browser_source(name).ok_or_else(|| {
125        anyhow!(
126            "Unsupported browser: {name}. Expected one of {}",
127            browser_ids().join(", ")
128        )
129    })
130}
131
132/// Resolve a name to its canonical id, erroring with the catalogue listed.
133pub fn normalize_browser_id(name: &str) -> Result<&'static str> {
134    Ok(normalize_browser_source(name)?.id.as_str())
135}
136
137/// The family (`chromium` or `firefox`) of a browser name.
138pub fn browser_family(name: &str) -> Result<&'static str> {
139    Ok(normalize_browser_source(name)?.family.as_str())
140}
141
142/// True when a browser stores one profile in the root itself (Opera-style).
143pub fn is_single_profile_browser(name: &str) -> Result<bool> {
144    Ok(normalize_browser_source(name)?.single_profile)
145}
146
147/// The Chromium Safe Storage identity for a browser, or `None` for a
148/// Firefox-family browser (which does not use OSCrypt).
149pub fn safe_storage_identity(name: &str) -> Result<Option<&'static SafeStorageIdentity>> {
150    Ok(normalize_browser_source(name)?.safe_storage.as_ref())
151}
152
153/// The operating-system identifiers that mark a browser as the system default:
154/// macOS bundle ids, Linux `.desktop` file names, or Windows ProgIds.
155pub fn default_browser_identifiers(name: &str, platform: &str) -> Result<Vec<&'static str>> {
156    Ok(normalize_browser_source(name)?
157        .default
158        .get(platform)
159        .map(|ids| ids.iter().map(String::as_str).collect())
160        .unwrap_or_default())
161}
162
163fn separator(platform: &str) -> char {
164    if platform == "win32" {
165        '\\'
166    } else {
167        '/'
168    }
169}
170
171fn join_with(base: &str, parts: &[&str], separator: char) -> String {
172    let mut result = base.to_string();
173    for part in parts {
174        result.push(separator);
175        result.push_str(part);
176    }
177    result
178}
179
180fn template_variables(
181    platform: &str,
182    home_dir: &str,
183    environment: &Environment,
184) -> HashMap<&'static str, String> {
185    let mut variables = HashMap::new();
186    variables.insert("home", home_dir.to_string());
187    match platform {
188        "darwin" => {
189            variables.insert(
190                "appSupport",
191                join_with(home_dir, &["Library", "Application Support"], '/'),
192            );
193        }
194        "win32" => {
195            variables.insert(
196                "localAppData",
197                environment
198                    .get("LOCALAPPDATA")
199                    .cloned()
200                    .unwrap_or_else(|| join_with(home_dir, &["AppData", "Local"], '\\')),
201            );
202            variables.insert(
203                "appData",
204                environment
205                    .get("APPDATA")
206                    .cloned()
207                    .unwrap_or_else(|| join_with(home_dir, &["AppData", "Roaming"], '\\')),
208            );
209        }
210        _ => {
211            variables.insert(
212                "config",
213                environment
214                    .get("XDG_CONFIG_HOME")
215                    .cloned()
216                    .unwrap_or_else(|| join_with(home_dir, &[".config"], '/')),
217            );
218        }
219    }
220    variables
221}
222
223fn expand_template(
224    template: &str,
225    variables: &HashMap<&'static str, String>,
226    separator: char,
227) -> Option<String> {
228    if !template.starts_with('{') {
229        return Some(template.to_string());
230    }
231    let end = template.find('}')?;
232    let key = &template[1..end];
233    let base = variables.get(key)?;
234    let parts: Vec<&str> = template[end + 1..]
235        .split('/')
236        .filter(|segment| !segment.is_empty())
237        .collect();
238    Some(join_with(base, &parts, separator))
239}
240
241/// The absolute profile roots a browser uses on a platform. Returns an empty
242/// vector when the browser does not run on that platform (for example Chrome
243/// Canary on Linux). Windows roots are built with backslashes regardless of the
244/// host, so the catalogue resolves identically on any platform.
245pub fn resolve_browser_roots(
246    name: &str,
247    platform: &str,
248    home_dir: &str,
249    environment: &Environment,
250) -> Result<Vec<PathBuf>> {
251    let platform = normalize_platform(platform);
252    let source = normalize_browser_source(name)?;
253    let Some(templates) = source.roots.get(platform) else {
254        return Ok(Vec::new());
255    };
256    let variables = template_variables(platform, home_dir, environment);
257    let separator = separator(platform);
258    Ok(templates
259        .iter()
260        .filter_map(|template| expand_template(template, &variables, separator))
261        .map(PathBuf::from)
262        .collect())
263}
264
265#[cfg(test)]
266mod tests {
267    // feature-parity: sources.catalogue@native-typed
268    use super::*;
269
270    fn env(pairs: &[(&str, &str)]) -> Environment {
271        pairs
272            .iter()
273            .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
274            .collect()
275    }
276
277    fn roots(name: &str, platform: &str, home: &str, environment: &[(&str, &str)]) -> Vec<String> {
278        resolve_browser_roots(name, platform, home, &env(environment))
279            .unwrap()
280            .into_iter()
281            .map(|path| path.to_string_lossy().into_owned())
282            .collect()
283    }
284
285    #[test]
286    fn keeps_the_original_five_browsers_resolvable_by_id_and_alias() {
287        assert_eq!(normalize_browser_id("chrome").unwrap(), "chrome");
288        assert_eq!(normalize_browser_id("msedge").unwrap(), "edge");
289        assert_eq!(normalize_browser_id("microsoft-edge").unwrap(), "edge");
290        assert_eq!(normalize_browser_id("google-chrome").unwrap(), "chrome");
291        assert_eq!(normalize_browser_id("CHROME").unwrap(), "chrome");
292    }
293
294    #[test]
295    fn adds_the_new_source_browsers_from_114() {
296        for id in [
297            "opera",
298            "opera-gx",
299            "vivaldi",
300            "arc",
301            "yandex",
302            "chrome-beta",
303            "chrome-dev",
304            "chrome-canary",
305            "edge-beta",
306            "edge-dev",
307            "librewolf",
308            "waterfox",
309            "zen",
310            "floorp",
311            "firefox-developer",
312            "firefox-nightly",
313        ] {
314            assert!(browser_ids().contains(&id), "missing {id}");
315            assert!(find_browser_source(id).is_some(), "unresolvable {id}");
316        }
317    }
318
319    #[test]
320    fn rejects_an_unknown_browser_with_the_catalogue_listed() {
321        let error = normalize_browser_id("netscape").unwrap_err().to_string();
322        assert!(
323            error.starts_with("Unsupported browser: netscape. Expected one of"),
324            "{error}"
325        );
326        assert!(error.contains("chrome"), "{error}");
327    }
328
329    #[test]
330    fn classifies_browser_families() {
331        assert_eq!(browser_family("chrome").unwrap(), "chromium");
332        assert_eq!(browser_family("opera").unwrap(), "chromium");
333        assert_eq!(browser_family("firefox").unwrap(), "firefox");
334        assert_eq!(browser_family("librewolf").unwrap(), "firefox");
335    }
336
337    #[test]
338    fn expands_per_platform_roots_with_the_home_directory() {
339        assert_eq!(
340            roots("chrome", "linux", "/home/me", &[]),
341            ["/home/me/.config/google-chrome"]
342        );
343        assert_eq!(
344            roots("firefox", "darwin", "/Users/me", &[]),
345            ["/Users/me/Library/Application Support/Firefox"]
346        );
347    }
348
349    #[test]
350    fn honours_xdg_config_home_on_linux() {
351        assert_eq!(
352            roots(
353                "chromium",
354                "linux",
355                "/home/me",
356                &[("XDG_CONFIG_HOME", "/cfg")]
357            ),
358            ["/cfg/chromium"]
359        );
360    }
361
362    #[test]
363    fn builds_windows_roots_with_backslashes() {
364        assert_eq!(
365            roots(
366                "opera",
367                "win32",
368                "C:\\Users\\me",
369                &[("APPDATA", "C:\\Users\\me\\AppData\\Roaming")]
370            ),
371            ["C:\\Users\\me\\AppData\\Roaming\\Opera Software\\Opera Stable"]
372        );
373        assert_eq!(
374            roots(
375                "chrome",
376                "win32",
377                "C:\\Users\\me",
378                &[("LOCALAPPDATA", "C:\\Users\\me\\AppData\\Local")]
379            ),
380            ["C:\\Users\\me\\AppData\\Local\\Google\\Chrome\\User Data"]
381        );
382    }
383
384    #[test]
385    fn returns_no_root_where_a_browser_does_not_run() {
386        assert!(roots("chrome-canary", "linux", "/home/me", &[]).is_empty());
387        assert!(roots("arc", "linux", "/home/me", &[]).is_empty());
388    }
389
390    #[test]
391    fn marks_opera_style_browsers_as_single_profile() {
392        assert!(is_single_profile_browser("opera").unwrap());
393        assert!(is_single_profile_browser("opera-gx").unwrap());
394        assert!(!is_single_profile_browser("chrome").unwrap());
395        assert!(!is_single_profile_browser("vivaldi").unwrap());
396    }
397
398    #[test]
399    fn exposes_a_safe_storage_identity_for_chromium_and_none_for_firefox() {
400        assert_eq!(
401            safe_storage_identity("brave").unwrap(),
402            Some(&SafeStorageIdentity {
403                service: "Brave Safe Storage".into(),
404                application: "brave".into(),
405                folder: "Brave Keys".into(),
406            })
407        );
408        assert_eq!(safe_storage_identity("firefox").unwrap(), None);
409    }
410
411    #[test]
412    fn exposes_default_browser_identifiers_per_platform() {
413        assert_eq!(
414            default_browser_identifiers("chrome", "darwin").unwrap(),
415            ["com.google.chrome"]
416        );
417        assert_eq!(
418            default_browser_identifiers("firefox", "win32").unwrap(),
419            ["FirefoxHTML", "FirefoxURL"]
420        );
421        assert!(default_browser_identifiers("chrome", "nope")
422            .unwrap()
423            .is_empty());
424    }
425}