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