1use std::collections::{BTreeMap, BTreeSet, HashSet};
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use anyhow::{anyhow, Result};
12use serde::Deserialize;
13
14use super::browser_sources::{
15 browser_family, browser_ids, current_environment, is_single_profile_browser,
16 normalize_browser_id, resolve_browser_roots, Environment,
17};
18use super::default_browser::{default_run_command, resolve_default_browser, RunCommand};
19
20pub use super::browser_sources::SUPPORTED_COOKIE_BROWSERS;
23
24const DEFAULT_BROWSER_KEYWORDS: [&str; 2] = ["default", "auto"];
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct BrowserProfile {
32 pub browser: String,
34 pub name: String,
36 pub display_name: String,
38 pub path: PathBuf,
40 pub is_default: bool,
42}
43
44#[derive(Clone)]
46pub struct BrowserProfileOptions {
47 pub browser: Option<String>,
49 pub home_dir: PathBuf,
51 pub platform: String,
53 pub environment: Environment,
55 pub run_command: Option<RunCommand>,
58}
59
60impl std::fmt::Debug for BrowserProfileOptions {
61 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 formatter
63 .debug_struct("BrowserProfileOptions")
64 .field("browser", &self.browser)
65 .field("home_dir", &self.home_dir)
66 .field("platform", &self.platform)
67 .field("environment", &self.environment)
68 .field("run_command", &self.run_command.as_ref().map(|_| "<fn>"))
69 .finish()
70 }
71}
72
73impl Default for BrowserProfileOptions {
74 fn default() -> Self {
75 Self {
76 browser: None,
77 home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")),
78 platform: current_platform().to_string(),
79 environment: current_environment(),
80 run_command: None,
81 }
82 }
83}
84
85impl BrowserProfileOptions {
86 pub fn browser(mut self, browser: impl Into<String>) -> Self {
88 self.browser = Some(browser.into());
89 self
90 }
91
92 pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
94 self.home_dir = home_dir.into();
95 self
96 }
97
98 pub fn platform(mut self, platform: impl AsRef<str>) -> Self {
100 self.platform = normalize_platform(platform.as_ref()).to_string();
101 self
102 }
103
104 pub fn environment(mut self, environment: Environment) -> Self {
106 self.environment = environment;
107 self
108 }
109
110 pub fn run_command(mut self, run_command: RunCommand) -> Self {
112 self.run_command = Some(run_command);
113 self
114 }
115}
116
117pub(crate) fn current_platform() -> &'static str {
118 normalize_platform(std::env::consts::OS)
119}
120
121pub(crate) fn normalize_platform(platform: &str) -> &str {
122 match platform {
123 "macos" => "darwin",
124 "windows" => "win32",
125 other => other,
126 }
127}
128
129pub(crate) fn normalize_cookie_browser(browser: &str) -> Result<&'static str> {
131 normalize_browser_id(browser)
132}
133
134pub fn is_default_browser_keyword(browser: &str) -> bool {
136 let needle = browser.trim().to_lowercase();
137 DEFAULT_BROWSER_KEYWORDS.contains(&needle.as_str())
138}
139
140pub fn resolve_source_browser(
144 browser: &str,
145 platform: &str,
146 environment: &Environment,
147 run_command: Option<&RunCommand>,
148) -> Result<&'static str> {
149 if !is_default_browser_keyword(browser) {
150 return normalize_cookie_browser(browser);
151 }
152 let platform = normalize_platform(platform);
153 let fallback;
154 let runner = match run_command {
155 Some(runner) => runner,
156 None => {
157 fallback = default_run_command();
158 &fallback
159 }
160 };
161 resolve_default_browser(platform, environment, runner)?.ok_or_else(|| {
162 anyhow!(
163 "Could not determine the system default browser; pass an explicit browser instead of \"default\"."
164 )
165 })
166}
167
168pub(crate) fn browser_profile_root(
170 browser: &str,
171 platform: &str,
172 home_dir: &Path,
173) -> Result<PathBuf> {
174 browser_profile_root_in(browser, platform, home_dir, ¤t_environment())
175}
176
177pub(crate) fn browser_profile_root_in(
179 browser: &str,
180 platform: &str,
181 home_dir: &Path,
182 environment: &Environment,
183) -> Result<PathBuf> {
184 let browser = normalize_browser_id(browser)?;
185 resolve_browser_roots(browser, platform, &home_dir.to_string_lossy(), environment)?
186 .into_iter()
187 .next()
188 .ok_or_else(|| anyhow!("{browser} has no profile root on {platform}"))
189}
190
191pub(crate) fn find_cookie_database(browser: &str, profile_path: &Path) -> Option<PathBuf> {
192 if browser_family(browser).ok() == Some("safari") {
193 return super::safari_cookies::find_safari_cookie_file(profile_path);
194 }
195 if browser_family(browser)
196 .map(|family| family == "firefox")
197 .unwrap_or(false)
198 {
199 let candidate = profile_path.join("cookies.sqlite");
200 return candidate.is_file().then_some(candidate);
201 }
202 [
203 profile_path.join("Network/Cookies"),
204 profile_path.join("Cookies"),
205 ]
206 .into_iter()
207 .find(|candidate| candidate.is_file())
208}
209
210#[derive(Debug, Default, Deserialize)]
211struct LocalState {
212 #[serde(default)]
213 profile: LocalStateProfile,
214}
215
216#[derive(Debug, Default, Deserialize)]
217struct LocalStateProfile {
218 last_used: Option<String>,
219 #[serde(default)]
220 info_cache: BTreeMap<String, LocalStateProfileInfo>,
221}
222
223#[derive(Debug, Default, Deserialize)]
224struct LocalStateProfileInfo {
225 name: Option<String>,
226}
227
228fn list_chromium_profiles(browser: &str, root: &Path) -> Vec<BrowserProfile> {
229 if !root.is_dir() {
230 return Vec::new();
231 }
232 if is_single_profile_browser(browser).unwrap_or(false) {
235 return match find_cookie_database(browser, root) {
236 Some(_) => vec![BrowserProfile {
237 browser: browser.to_string(),
238 name: "Default".to_string(),
239 display_name: "Default".to_string(),
240 path: root.to_path_buf(),
241 is_default: true,
242 }],
243 None => Vec::new(),
244 };
245 }
246 let state = fs::read_to_string(root.join("Local State"))
247 .ok()
248 .and_then(|contents| serde_json::from_str::<LocalState>(&contents).ok())
249 .unwrap_or_default();
250 let mut names = state
251 .profile
252 .info_cache
253 .keys()
254 .cloned()
255 .collect::<BTreeSet<_>>();
256 if let Ok(entries) = fs::read_dir(root) {
257 for name in entries
258 .flatten()
259 .filter_map(|entry| entry.file_name().into_string().ok())
260 .filter(|name| name == "Default" || name.starts_with("Profile "))
261 {
262 names.insert(name);
263 }
264 }
265 let default_name = state.profile.last_used.as_deref().unwrap_or("Default");
266 let only_default = names.len() == 1 && names.contains("Default");
267 let mut profiles = names
268 .into_iter()
269 .filter_map(|name| {
270 let path = root.join(&name);
271 find_cookie_database(browser, &path)?;
272 let display_name = state
273 .profile
274 .info_cache
275 .get(&name)
276 .and_then(|info| info.name.clone())
277 .unwrap_or_else(|| name.clone());
278 Some(BrowserProfile {
279 browser: browser.to_string(),
280 is_default: name == default_name || (only_default && name == "Default"),
281 name,
282 display_name,
283 path,
284 })
285 })
286 .collect::<Vec<_>>();
287 profiles.sort_by_key(|profile| (!profile.is_default, profile.name.clone()));
288 profiles
289}
290
291fn parse_ini(contents: &str) -> Vec<BTreeMap<String, String>> {
292 let mut sections = Vec::new();
293 let mut current: Option<BTreeMap<String, String>> = None;
294 for raw_line in contents.lines() {
295 let line = raw_line.trim();
296 if line.starts_with('[') && line.ends_with(']') {
297 if let Some(section) = current.take() {
298 sections.push(section);
299 }
300 let mut section = BTreeMap::new();
301 section.insert("section".into(), line[1..line.len() - 1].into());
302 current = Some(section);
303 } else if let (Some(section), Some((key, value))) = (current.as_mut(), line.split_once('='))
304 {
305 if !line.starts_with(';') {
306 section.insert(key.trim().into(), value.trim().into());
307 }
308 }
309 }
310 if let Some(section) = current {
311 sections.push(section);
312 }
313 sections
314}
315
316fn firefox_sections(root: &Path) -> Vec<BTreeMap<String, String>> {
317 if let Ok(contents) = fs::read_to_string(root.join("profiles.ini")) {
318 return parse_ini(&contents);
319 }
320 let profiles_root = root.join("Profiles");
322 let Ok(entries) = fs::read_dir(&profiles_root) else {
323 return Vec::new();
324 };
325 entries
326 .flatten()
327 .filter(|entry| entry.path().is_dir())
328 .filter_map(|entry| {
329 let name = entry.file_name().into_string().ok()?;
330 let mut section = BTreeMap::new();
331 section.insert("section".into(), "Profile".into());
332 section.insert("Name".into(), name.clone());
333 section.insert("Path".into(), name);
334 section.insert(
335 "ProfilesRoot".into(),
336 profiles_root.to_string_lossy().into_owned(),
337 );
338 Some(section)
339 })
340 .collect()
341}
342
343fn list_firefox_profiles(browser: &str, root: &Path) -> Vec<BrowserProfile> {
344 if !root.is_dir() {
345 return Vec::new();
346 }
347 let mut profiles = firefox_sections(root)
348 .into_iter()
349 .filter(|section| {
350 section
351 .get("section")
352 .is_some_and(|name| name.starts_with("Profile"))
353 })
354 .filter_map(|section| {
355 let configured = section.get("Path")?;
356 let relative_root = section
357 .get("ProfilesRoot")
358 .map(PathBuf::from)
359 .unwrap_or_else(|| root.to_path_buf());
360 let path = if section.get("IsRelative").map(String::as_str) == Some("0") {
361 PathBuf::from(configured)
362 } else {
363 relative_root.join(configured)
364 };
365 find_cookie_database(browser, &path)?;
366 let display_name = section
367 .get("Name")
368 .cloned()
369 .or_else(|| path.file_name()?.to_str().map(String::from))?;
370 Some(BrowserProfile {
371 browser: browser.to_string(),
372 name: display_name.clone(),
373 display_name,
374 path,
375 is_default: section.get("Default").map(String::as_str) == Some("1"),
376 })
377 })
378 .collect::<Vec<_>>();
379 profiles.sort_by_key(|profile| (!profile.is_default, profile.name.clone()));
380 profiles
381}
382
383fn list_profiles_for_browser(
384 browser: &str,
385 platform: &str,
386 home_dir: &Path,
387 environment: &Environment,
388) -> Result<Vec<BrowserProfile>> {
389 let roots = resolve_browser_roots(browser, platform, &home_dir.to_string_lossy(), environment)?;
390 let family = browser_family(browser)?;
391 let mut profiles = Vec::new();
392 for root in roots {
393 if family == "safari" {
394 if super::safari_cookies::find_safari_cookie_file(&root).is_some() {
395 return Ok(vec![BrowserProfile {
396 browser: browser.into(),
397 name: "Default".into(),
398 display_name: "Default".into(),
399 path: root,
400 is_default: true,
401 }]);
402 }
403 continue;
404 }
405 if family == "firefox" {
406 profiles.extend(list_firefox_profiles(browser, &root));
407 } else {
408 profiles.extend(list_chromium_profiles(browser, &root));
409 }
410 }
411 Ok(profiles)
412}
413
414pub fn list_browser_profiles(options: BrowserProfileOptions) -> Result<Vec<BrowserProfile>> {
417 let browsers: Vec<&'static str> = match options.browser.as_deref() {
418 Some(browser) => vec![resolve_source_browser(
419 browser,
420 &options.platform,
421 &options.environment,
422 options.run_command.as_ref(),
423 )?],
424 None => browser_ids(),
425 };
426 let mut profiles = Vec::new();
427 let mut seen: HashSet<PathBuf> = HashSet::new();
431 for browser in browsers {
432 for profile in list_profiles_for_browser(
433 browser,
434 &options.platform,
435 &options.home_dir,
436 &options.environment,
437 )? {
438 if seen.insert(profile.path.clone()) {
439 profiles.push(profile);
440 }
441 }
442 }
443 Ok(profiles)
444}
445
446pub(crate) fn resolve_browser_profile(
447 browser: &str,
448 requested_profile: Option<&str>,
449 options: &BrowserProfileOptions,
450) -> Result<BrowserProfile> {
451 let browser = resolve_source_browser(
452 browser,
453 &options.platform,
454 &options.environment,
455 options.run_command.as_ref(),
456 )?;
457 let profiles = list_browser_profiles(options.clone().browser(browser))?;
458 let selected = requested_profile
459 .and_then(|requested| {
460 profiles.iter().find(|profile| {
461 profile.name == requested
462 || profile.display_name == requested
463 || profile.path.file_name().and_then(|name| name.to_str()) == Some(requested)
464 })
465 })
466 .or_else(|| profiles.iter().find(|profile| profile.is_default))
467 .or_else(|| profiles.first());
468 selected.cloned().ok_or_else(|| {
469 let detail = requested_profile
470 .map(|profile| format!(" profile \"{profile}\""))
471 .unwrap_or_else(|| " profile".into());
472 anyhow!("Could not find a cookie database for {browser}{detail}")
473 })
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use std::sync::Arc;
480
481 struct TempDir(PathBuf);
482
483 impl TempDir {
484 fn new(prefix: &str) -> Self {
485 use std::sync::atomic::{AtomicU64, Ordering};
486 static COUNTER: AtomicU64 = AtomicU64::new(0);
487 let nanos = std::time::SystemTime::now()
488 .duration_since(std::time::UNIX_EPOCH)
489 .map(|elapsed| elapsed.as_nanos())
490 .unwrap_or_default();
491 let count = COUNTER.fetch_add(1, Ordering::Relaxed);
492 let path = std::env::temp_dir()
493 .join(format!("{prefix}{}-{nanos:x}-{count}", std::process::id()));
494 fs::create_dir_all(&path).expect("temporary directory");
495 Self(path)
496 }
497
498 fn path(&self) -> &Path {
499 &self.0
500 }
501 }
502
503 impl Drop for TempDir {
504 fn drop(&mut self) {
505 let _ = fs::remove_dir_all(&self.0);
506 }
507 }
508
509 fn write(path: &Path, contents: &str) {
510 fs::create_dir_all(path.parent().unwrap()).expect("parent");
511 fs::write(path, contents).expect("write");
512 }
513
514 fn write_firefox_profile(home: &Path, root_name: &[&str], profile_name: &str) -> PathBuf {
515 let mut root = home.to_path_buf();
516 for part in root_name {
517 root = root.join(part);
518 }
519 let profile_path = root.join(profile_name);
520 fs::create_dir_all(&profile_path).expect("profile dir");
521 write(
522 &root.join("profiles.ini"),
523 &format!(
524 "[Profile0]\nName={}\nIsRelative=1\nPath={profile_name}\nDefault=1\n",
525 profile_name
526 .split_once('.')
527 .map(|(_, name)| name)
528 .unwrap_or(profile_name)
529 ),
530 );
531 fs::write(profile_path.join("cookies.sqlite"), b"SQLite format 3\0").expect("cookies");
532 profile_path
533 }
534
535 fn options(home: &Path) -> BrowserProfileOptions {
536 BrowserProfileOptions {
537 browser: None,
538 home_dir: home.to_path_buf(),
539 platform: "linux".to_string(),
540 environment: Environment::new(),
541 run_command: None,
542 }
543 }
544
545 #[test]
546 fn reads_an_opera_single_profile_layout_from_the_root_itself() {
547 let temp = TempDir::new("bc-opera-");
548 let root = temp.path().join(".config").join("opera");
549 fs::create_dir_all(root.join("Network")).expect("network");
550 fs::write(root.join("Network").join("Cookies"), b"SQLite format 3\0").expect("cookies");
551
552 let profiles = list_browser_profiles(options(temp.path()).browser("opera")).unwrap();
553 assert_eq!(
554 profiles,
555 vec![BrowserProfile {
556 browser: "opera".into(),
557 name: "Default".into(),
558 display_name: "Default".into(),
559 path: root,
560 is_default: true,
561 }]
562 );
563 }
564
565 #[test]
566 fn discovers_a_firefox_fork_librewolf_by_its_own_root() {
567 let temp = TempDir::new("bc-librewolf-");
568 let profile_path = write_firefox_profile(temp.path(), &[".librewolf"], "abcd.default");
569
570 let profiles = list_browser_profiles(options(temp.path()).browser("librewolf")).unwrap();
571 assert_eq!(
572 profiles,
573 vec![BrowserProfile {
574 browser: "librewolf".into(),
575 name: "default".into(),
576 display_name: "default".into(),
577 path: profile_path,
578 is_default: true,
579 }]
580 );
581 }
582
583 #[test]
584 fn lists_firefox_once_when_its_channels_share_a_root() {
585 let temp = TempDir::new("bc-ffshare-");
586 write_firefox_profile(temp.path(), &[".mozilla", "firefox"], "xyz.default-release");
587
588 let profiles = list_browser_profiles(options(temp.path())).unwrap();
589 let browsers: Vec<&str> = profiles
590 .iter()
591 .filter(|profile| profile.browser.starts_with("firefox"))
592 .map(|profile| profile.browser.as_str())
593 .collect();
594 assert_eq!(browsers, vec!["firefox"]);
595 }
596
597 fn firefox_runner() -> RunCommand {
598 Arc::new(|command: &str, args: &[&str], _env: &Environment| {
599 if command == "xdg-settings" && args == ["get", "default-web-browser"] {
600 Ok("firefox.desktop\n".to_string())
601 } else {
602 Err(anyhow!("unexpected command"))
603 }
604 })
605 }
606
607 #[test]
608 fn resolves_browser_default_to_the_system_default_browser() {
609 let runner = firefox_runner();
610 assert_eq!(
611 resolve_source_browser("default", "linux", &Environment::new(), Some(&runner)).unwrap(),
612 "firefox"
613 );
614 assert_eq!(
615 resolve_source_browser("AUTO", "linux", &Environment::new(), Some(&runner)).unwrap(),
616 "firefox"
617 );
618 }
619
620 #[test]
621 fn lists_the_default_browser_profile_when_browser_is_default() {
622 let temp = TempDir::new("bc-default-");
623 let profile_path =
624 write_firefox_profile(temp.path(), &[".mozilla", "firefox"], "xyz.default-release");
625 let runner: RunCommand = Arc::new(|_command: &str, _args: &[&str], _env: &Environment| {
626 Ok("firefox.desktop\n".to_string())
627 });
628
629 let resolved = resolve_browser_profile(
630 "default",
631 None,
632 &options(temp.path()).run_command(runner.clone()),
633 )
634 .unwrap();
635 assert_eq!(resolved.browser, "firefox");
636 assert_eq!(resolved.path, profile_path);
637
638 let profiles =
639 list_browser_profiles(options(temp.path()).browser("auto").run_command(runner))
640 .unwrap();
641 assert_eq!(
642 profiles
643 .iter()
644 .map(|profile| profile.browser.as_str())
645 .collect::<Vec<_>>(),
646 vec!["firefox"]
647 );
648 }
649
650 #[test]
651 fn reports_a_clear_error_when_the_default_browser_is_unknown() {
652 let runner: RunCommand =
653 Arc::new(|_command: &str, _args: &[&str], _env: &Environment| Err(anyhow!("no xdg")));
654 let error = resolve_source_browser("default", "linux", &Environment::new(), Some(&runner))
655 .unwrap_err()
656 .to_string();
657 assert!(
658 error.contains("Could not determine the system default browser"),
659 "{error}"
660 );
661 }
662}