browser_commander/browser/
browser_sources.rs1use 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}
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
100pub static SUPPORTED_COOKIE_BROWSERS: LazyLock<Vec<&'static str>> = LazyLock::new(browser_ids);
103
104pub fn browser_sources() -> &'static [BrowserSource] {
106 ®ISTRY.browsers
107}
108
109pub fn browser_ids() -> Vec<&'static str> {
111 REGISTRY.browsers.iter().map(|b| b.id.as_str()).collect()
112}
113
114pub 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| ®ISTRY.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
132pub fn normalize_browser_id(name: &str) -> Result<&'static str> {
134 Ok(normalize_browser_source(name)?.id.as_str())
135}
136
137pub fn browser_family(name: &str) -> Result<&'static str> {
139 Ok(normalize_browser_source(name)?.family.as_str())
140}
141
142pub fn is_single_profile_browser(name: &str) -> Result<bool> {
144 Ok(normalize_browser_source(name)?.single_profile)
145}
146
147pub fn safe_storage_identity(name: &str) -> Result<Option<&'static SafeStorageIdentity>> {
150 Ok(normalize_browser_source(name)?.safe_storage.as_ref())
151}
152
153pub 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
241pub 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 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}