Skip to main content

browser_commander/browser/
browser_profiles.rs

1//! Discovery of installed browser profiles that contain cookie databases.
2//!
3//! Driven by the shared `browser-sources.json` catalogue (#114), so adding a
4//! browser there adds it to discovery, including Opera-style single-profile
5//! layouts and Firefox forks. Mirrors `js/src/browser/browser-profiles.js`.
6
7use 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
20/// Browsers whose on-disk cookie stores can be imported, driven by the shared
21/// catalogue. Re-exported here so the name callers already use keeps working.
22pub use super::browser_sources::SUPPORTED_COOKIE_BROWSERS;
23
24/// Keywords that select the operating-system default browser instead of a named
25/// one, so `browser: "default"` (or `"auto"`) imports from whatever a person
26/// actually uses.
27const DEFAULT_BROWSER_KEYWORDS: [&str; 2] = ["default", "auto"];
28
29/// Metadata for a cookie-bearing installed browser profile.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct BrowserProfile {
32    /// Normalized browser name.
33    pub browser: String,
34    /// On-disk profile name.
35    pub name: String,
36    /// Human-readable profile name, when the browser supplies one.
37    pub display_name: String,
38    /// Absolute or home-relative profile directory.
39    pub path: PathBuf,
40    /// Whether the browser marks this profile as the default/last used one.
41    pub is_default: bool,
42}
43
44/// Options for [`list_browser_profiles`].
45#[derive(Clone)]
46pub struct BrowserProfileOptions {
47    /// Restrict discovery to one browser. `None` scans all supported browsers.
48    pub browser: Option<String>,
49    /// Home directory used to resolve conventional profile locations.
50    pub home_dir: PathBuf,
51    /// Platform path convention (`linux`, `darwin`, or `win32`).
52    pub platform: String,
53    /// Environment used to expand profile-root templates (`%APPDATA%`, ...).
54    pub environment: Environment,
55    /// Command runner used to resolve the system default browser, injectable
56    /// for deterministic tests. `None` uses a real subprocess.
57    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    /// Restrict discovery to one installed browser.
87    pub fn browser(mut self, browser: impl Into<String>) -> Self {
88        self.browser = Some(browser.into());
89        self
90    }
91
92    /// Override the home directory used for discovery.
93    pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
94        self.home_dir = home_dir.into();
95        self
96    }
97
98    /// Override the platform convention, primarily for portable tooling/tests.
99    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    /// Override the environment used when expanding profile-root templates.
105    pub fn environment(mut self, environment: Environment) -> Self {
106        self.environment = environment;
107        self
108    }
109
110    /// Inject the command runner used to resolve the system default browser.
111    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
129/// Resolve a browser name (id or alias) to its canonical catalogue id.
130pub(crate) fn normalize_cookie_browser(browser: &str) -> Result<&'static str> {
131    normalize_browser_id(browser)
132}
133
134/// Whether `browser` asks for the system default rather than a named browser.
135pub 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
140/// Resolve a requested browser to a canonical catalogue id, expanding the
141/// `default`/`auto` keywords to the operating-system default browser. Named
142/// browsers are normalized through the catalogue as before.
143pub 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
168/// The primary profile root a browser uses on a platform.
169pub(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, &current_environment())
175}
176
177/// [`browser_profile_root`] with an explicit environment for the root templates.
178pub(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    // Opera-style browsers keep one profile in the root itself rather than in
233    // Default/Profile N subdirectories.
234    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    // No profiles.ini (some forks): fall back to the Profiles directory.
321    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
414/// Discover cookie-bearing profiles from installed browsers. When `browser` is
415/// unset, every browser in the shared catalogue is scanned.
416pub 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    // Several Firefox channels (firefox, firefox-developer, firefox-nightly)
428    // share one profile root, so a catalogue-wide scan would otherwise report
429    // the same profile under each id. Keep the first (canonical) browser.
430    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}