use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::LazyLock;
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use super::browser_profiles::normalize_platform;
pub const BROWSER_SOURCES_SOURCE: &str = include_str!("browser-sources.json");
pub type Environment = HashMap<String, String>;
pub(crate) fn current_environment() -> Environment {
std::env::vars().collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SafeStorageIdentity {
pub service: String,
pub application: String,
pub folder: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserSource {
pub id: String,
pub family: String,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default)]
pub single_profile: bool,
#[serde(default)]
pub roots: HashMap<String, Vec<String>>,
#[serde(default)]
pub safe_storage: Option<SafeStorageIdentity>,
#[serde(default)]
pub default: HashMap<String, Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct Catalogue {
browsers: Vec<BrowserSource>,
}
struct Registry {
browsers: Vec<BrowserSource>,
by_name: HashMap<String, usize>,
}
static REGISTRY: LazyLock<Registry> = LazyLock::new(|| {
let catalogue: Catalogue = serde_json::from_str(BROWSER_SOURCES_SOURCE)
.expect("browser-sources.json is embedded at compile time and has to parse");
let mut by_name = HashMap::new();
for (index, browser) in catalogue.browsers.iter().enumerate() {
by_name.insert(browser.id.clone(), index);
for alias in &browser.aliases {
by_name.insert(alias.clone(), index);
}
}
Registry {
browsers: catalogue.browsers,
by_name,
}
});
pub static SUPPORTED_COOKIE_BROWSERS: LazyLock<Vec<&'static str>> = LazyLock::new(browser_ids);
pub fn browser_sources() -> &'static [BrowserSource] {
®ISTRY.browsers
}
pub fn browser_ids() -> Vec<&'static str> {
REGISTRY.browsers.iter().map(|b| b.id.as_str()).collect()
}
pub fn find_browser_source(name: &str) -> Option<&'static BrowserSource> {
REGISTRY
.by_name
.get(name)
.or_else(|| REGISTRY.by_name.get(&name.to_lowercase()))
.map(|&index| ®ISTRY.browsers[index])
}
fn normalize_browser_source(name: &str) -> Result<&'static BrowserSource> {
find_browser_source(name).ok_or_else(|| {
anyhow!(
"Unsupported browser: {name}. Expected one of {}",
browser_ids().join(", ")
)
})
}
pub fn normalize_browser_id(name: &str) -> Result<&'static str> {
Ok(normalize_browser_source(name)?.id.as_str())
}
pub fn browser_family(name: &str) -> Result<&'static str> {
Ok(normalize_browser_source(name)?.family.as_str())
}
pub fn is_single_profile_browser(name: &str) -> Result<bool> {
Ok(normalize_browser_source(name)?.single_profile)
}
pub fn safe_storage_identity(name: &str) -> Result<Option<&'static SafeStorageIdentity>> {
Ok(normalize_browser_source(name)?.safe_storage.as_ref())
}
pub fn default_browser_identifiers(name: &str, platform: &str) -> Result<Vec<&'static str>> {
Ok(normalize_browser_source(name)?
.default
.get(platform)
.map(|ids| ids.iter().map(String::as_str).collect())
.unwrap_or_default())
}
fn separator(platform: &str) -> char {
if platform == "win32" {
'\\'
} else {
'/'
}
}
fn join_with(base: &str, parts: &[&str], separator: char) -> String {
let mut result = base.to_string();
for part in parts {
result.push(separator);
result.push_str(part);
}
result
}
fn template_variables(
platform: &str,
home_dir: &str,
environment: &Environment,
) -> HashMap<&'static str, String> {
let mut variables = HashMap::new();
variables.insert("home", home_dir.to_string());
match platform {
"darwin" => {
variables.insert(
"appSupport",
join_with(home_dir, &["Library", "Application Support"], '/'),
);
}
"win32" => {
variables.insert(
"localAppData",
environment
.get("LOCALAPPDATA")
.cloned()
.unwrap_or_else(|| join_with(home_dir, &["AppData", "Local"], '\\')),
);
variables.insert(
"appData",
environment
.get("APPDATA")
.cloned()
.unwrap_or_else(|| join_with(home_dir, &["AppData", "Roaming"], '\\')),
);
}
_ => {
variables.insert(
"config",
environment
.get("XDG_CONFIG_HOME")
.cloned()
.unwrap_or_else(|| join_with(home_dir, &[".config"], '/')),
);
}
}
variables
}
fn expand_template(
template: &str,
variables: &HashMap<&'static str, String>,
separator: char,
) -> Option<String> {
if !template.starts_with('{') {
return Some(template.to_string());
}
let end = template.find('}')?;
let key = &template[1..end];
let base = variables.get(key)?;
let parts: Vec<&str> = template[end + 1..]
.split('/')
.filter(|segment| !segment.is_empty())
.collect();
Some(join_with(base, &parts, separator))
}
pub fn resolve_browser_roots(
name: &str,
platform: &str,
home_dir: &str,
environment: &Environment,
) -> Result<Vec<PathBuf>> {
let platform = normalize_platform(platform);
let source = normalize_browser_source(name)?;
let Some(templates) = source.roots.get(platform) else {
return Ok(Vec::new());
};
let variables = template_variables(platform, home_dir, environment);
let separator = separator(platform);
Ok(templates
.iter()
.filter_map(|template| expand_template(template, &variables, separator))
.map(PathBuf::from)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn env(pairs: &[(&str, &str)]) -> Environment {
pairs
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect()
}
fn roots(name: &str, platform: &str, home: &str, environment: &[(&str, &str)]) -> Vec<String> {
resolve_browser_roots(name, platform, home, &env(environment))
.unwrap()
.into_iter()
.map(|path| path.to_string_lossy().into_owned())
.collect()
}
#[test]
fn keeps_the_original_five_browsers_resolvable_by_id_and_alias() {
assert_eq!(normalize_browser_id("chrome").unwrap(), "chrome");
assert_eq!(normalize_browser_id("msedge").unwrap(), "edge");
assert_eq!(normalize_browser_id("microsoft-edge").unwrap(), "edge");
assert_eq!(normalize_browser_id("google-chrome").unwrap(), "chrome");
assert_eq!(normalize_browser_id("CHROME").unwrap(), "chrome");
}
#[test]
fn adds_the_new_source_browsers_from_114() {
for id in [
"opera",
"opera-gx",
"vivaldi",
"arc",
"yandex",
"chrome-beta",
"chrome-dev",
"chrome-canary",
"edge-beta",
"edge-dev",
"librewolf",
"waterfox",
"zen",
"floorp",
"firefox-developer",
"firefox-nightly",
] {
assert!(browser_ids().contains(&id), "missing {id}");
assert!(find_browser_source(id).is_some(), "unresolvable {id}");
}
}
#[test]
fn rejects_an_unknown_browser_with_the_catalogue_listed() {
let error = normalize_browser_id("netscape").unwrap_err().to_string();
assert!(
error.starts_with("Unsupported browser: netscape. Expected one of"),
"{error}"
);
assert!(error.contains("chrome"), "{error}");
}
#[test]
fn classifies_browser_families() {
assert_eq!(browser_family("chrome").unwrap(), "chromium");
assert_eq!(browser_family("opera").unwrap(), "chromium");
assert_eq!(browser_family("firefox").unwrap(), "firefox");
assert_eq!(browser_family("librewolf").unwrap(), "firefox");
}
#[test]
fn expands_per_platform_roots_with_the_home_directory() {
assert_eq!(
roots("chrome", "linux", "/home/me", &[]),
["/home/me/.config/google-chrome"]
);
assert_eq!(
roots("firefox", "darwin", "/Users/me", &[]),
["/Users/me/Library/Application Support/Firefox"]
);
}
#[test]
fn honours_xdg_config_home_on_linux() {
assert_eq!(
roots(
"chromium",
"linux",
"/home/me",
&[("XDG_CONFIG_HOME", "/cfg")]
),
["/cfg/chromium"]
);
}
#[test]
fn builds_windows_roots_with_backslashes() {
assert_eq!(
roots(
"opera",
"win32",
"C:\\Users\\me",
&[("APPDATA", "C:\\Users\\me\\AppData\\Roaming")]
),
["C:\\Users\\me\\AppData\\Roaming\\Opera Software\\Opera Stable"]
);
assert_eq!(
roots(
"chrome",
"win32",
"C:\\Users\\me",
&[("LOCALAPPDATA", "C:\\Users\\me\\AppData\\Local")]
),
["C:\\Users\\me\\AppData\\Local\\Google\\Chrome\\User Data"]
);
}
#[test]
fn returns_no_root_where_a_browser_does_not_run() {
assert!(roots("chrome-canary", "linux", "/home/me", &[]).is_empty());
assert!(roots("arc", "linux", "/home/me", &[]).is_empty());
}
#[test]
fn marks_opera_style_browsers_as_single_profile() {
assert!(is_single_profile_browser("opera").unwrap());
assert!(is_single_profile_browser("opera-gx").unwrap());
assert!(!is_single_profile_browser("chrome").unwrap());
assert!(!is_single_profile_browser("vivaldi").unwrap());
}
#[test]
fn exposes_a_safe_storage_identity_for_chromium_and_none_for_firefox() {
assert_eq!(
safe_storage_identity("brave").unwrap(),
Some(&SafeStorageIdentity {
service: "Brave Safe Storage".into(),
application: "brave".into(),
folder: "Brave Keys".into(),
})
);
assert_eq!(safe_storage_identity("firefox").unwrap(), None);
}
#[test]
fn exposes_default_browser_identifiers_per_platform() {
assert_eq!(
default_browser_identifiers("chrome", "darwin").unwrap(),
["com.google.chrome"]
);
assert_eq!(
default_browser_identifiers("firefox", "win32").unwrap(),
["FirefoxHTML", "FirefoxURL"]
);
assert!(default_browser_identifiers("chrome", "nope")
.unwrap()
.is_empty());
}
}