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)
193        .map(|family| family == "firefox")
194        .unwrap_or(false)
195    {
196        let candidate = profile_path.join("cookies.sqlite");
197        return candidate.is_file().then_some(candidate);
198    }
199    [
200        profile_path.join("Network/Cookies"),
201        profile_path.join("Cookies"),
202    ]
203    .into_iter()
204    .find(|candidate| candidate.is_file())
205}
206
207#[derive(Debug, Default, Deserialize)]
208struct LocalState {
209    #[serde(default)]
210    profile: LocalStateProfile,
211}
212
213#[derive(Debug, Default, Deserialize)]
214struct LocalStateProfile {
215    last_used: Option<String>,
216    #[serde(default)]
217    info_cache: BTreeMap<String, LocalStateProfileInfo>,
218}
219
220#[derive(Debug, Default, Deserialize)]
221struct LocalStateProfileInfo {
222    name: Option<String>,
223}
224
225fn list_chromium_profiles(browser: &str, root: &Path) -> Vec<BrowserProfile> {
226    if !root.is_dir() {
227        return Vec::new();
228    }
229    // Opera-style browsers keep one profile in the root itself rather than in
230    // Default/Profile N subdirectories.
231    if is_single_profile_browser(browser).unwrap_or(false) {
232        return match find_cookie_database(browser, root) {
233            Some(_) => vec![BrowserProfile {
234                browser: browser.to_string(),
235                name: "Default".to_string(),
236                display_name: "Default".to_string(),
237                path: root.to_path_buf(),
238                is_default: true,
239            }],
240            None => Vec::new(),
241        };
242    }
243    let state = fs::read_to_string(root.join("Local State"))
244        .ok()
245        .and_then(|contents| serde_json::from_str::<LocalState>(&contents).ok())
246        .unwrap_or_default();
247    let mut names = state
248        .profile
249        .info_cache
250        .keys()
251        .cloned()
252        .collect::<BTreeSet<_>>();
253    if let Ok(entries) = fs::read_dir(root) {
254        for name in entries
255            .flatten()
256            .filter_map(|entry| entry.file_name().into_string().ok())
257            .filter(|name| name == "Default" || name.starts_with("Profile "))
258        {
259            names.insert(name);
260        }
261    }
262    let default_name = state.profile.last_used.as_deref().unwrap_or("Default");
263    let only_default = names.len() == 1 && names.contains("Default");
264    let mut profiles = names
265        .into_iter()
266        .filter_map(|name| {
267            let path = root.join(&name);
268            find_cookie_database(browser, &path)?;
269            let display_name = state
270                .profile
271                .info_cache
272                .get(&name)
273                .and_then(|info| info.name.clone())
274                .unwrap_or_else(|| name.clone());
275            Some(BrowserProfile {
276                browser: browser.to_string(),
277                is_default: name == default_name || (only_default && name == "Default"),
278                name,
279                display_name,
280                path,
281            })
282        })
283        .collect::<Vec<_>>();
284    profiles.sort_by_key(|profile| (!profile.is_default, profile.name.clone()));
285    profiles
286}
287
288fn parse_ini(contents: &str) -> Vec<BTreeMap<String, String>> {
289    let mut sections = Vec::new();
290    let mut current: Option<BTreeMap<String, String>> = None;
291    for raw_line in contents.lines() {
292        let line = raw_line.trim();
293        if line.starts_with('[') && line.ends_with(']') {
294            if let Some(section) = current.take() {
295                sections.push(section);
296            }
297            let mut section = BTreeMap::new();
298            section.insert("section".into(), line[1..line.len() - 1].into());
299            current = Some(section);
300        } else if let (Some(section), Some((key, value))) = (current.as_mut(), line.split_once('='))
301        {
302            if !line.starts_with(';') {
303                section.insert(key.trim().into(), value.trim().into());
304            }
305        }
306    }
307    if let Some(section) = current {
308        sections.push(section);
309    }
310    sections
311}
312
313fn firefox_sections(root: &Path) -> Vec<BTreeMap<String, String>> {
314    if let Ok(contents) = fs::read_to_string(root.join("profiles.ini")) {
315        return parse_ini(&contents);
316    }
317    // No profiles.ini (some forks): fall back to the Profiles directory.
318    let profiles_root = root.join("Profiles");
319    let Ok(entries) = fs::read_dir(&profiles_root) else {
320        return Vec::new();
321    };
322    entries
323        .flatten()
324        .filter(|entry| entry.path().is_dir())
325        .filter_map(|entry| {
326            let name = entry.file_name().into_string().ok()?;
327            let mut section = BTreeMap::new();
328            section.insert("section".into(), "Profile".into());
329            section.insert("Name".into(), name.clone());
330            section.insert("Path".into(), name);
331            section.insert(
332                "ProfilesRoot".into(),
333                profiles_root.to_string_lossy().into_owned(),
334            );
335            Some(section)
336        })
337        .collect()
338}
339
340fn list_firefox_profiles(browser: &str, root: &Path) -> Vec<BrowserProfile> {
341    if !root.is_dir() {
342        return Vec::new();
343    }
344    let mut profiles = firefox_sections(root)
345        .into_iter()
346        .filter(|section| {
347            section
348                .get("section")
349                .is_some_and(|name| name.starts_with("Profile"))
350        })
351        .filter_map(|section| {
352            let configured = section.get("Path")?;
353            let relative_root = section
354                .get("ProfilesRoot")
355                .map(PathBuf::from)
356                .unwrap_or_else(|| root.to_path_buf());
357            let path = if section.get("IsRelative").map(String::as_str) == Some("0") {
358                PathBuf::from(configured)
359            } else {
360                relative_root.join(configured)
361            };
362            find_cookie_database(browser, &path)?;
363            let display_name = section
364                .get("Name")
365                .cloned()
366                .or_else(|| path.file_name()?.to_str().map(String::from))?;
367            Some(BrowserProfile {
368                browser: browser.to_string(),
369                name: display_name.clone(),
370                display_name,
371                path,
372                is_default: section.get("Default").map(String::as_str) == Some("1"),
373            })
374        })
375        .collect::<Vec<_>>();
376    profiles.sort_by_key(|profile| (!profile.is_default, profile.name.clone()));
377    profiles
378}
379
380fn list_profiles_for_browser(
381    browser: &str,
382    platform: &str,
383    home_dir: &Path,
384    environment: &Environment,
385) -> Result<Vec<BrowserProfile>> {
386    let roots = resolve_browser_roots(browser, platform, &home_dir.to_string_lossy(), environment)?;
387    let family = browser_family(browser)?;
388    let mut profiles = Vec::new();
389    for root in roots {
390        if family == "firefox" {
391            profiles.extend(list_firefox_profiles(browser, &root));
392        } else {
393            profiles.extend(list_chromium_profiles(browser, &root));
394        }
395    }
396    Ok(profiles)
397}
398
399/// Discover cookie-bearing profiles from installed browsers. When `browser` is
400/// unset, every browser in the shared catalogue is scanned.
401pub fn list_browser_profiles(options: BrowserProfileOptions) -> Result<Vec<BrowserProfile>> {
402    let browsers: Vec<&'static str> = match options.browser.as_deref() {
403        Some(browser) => vec![resolve_source_browser(
404            browser,
405            &options.platform,
406            &options.environment,
407            options.run_command.as_ref(),
408        )?],
409        None => browser_ids(),
410    };
411    let mut profiles = Vec::new();
412    // Several Firefox channels (firefox, firefox-developer, firefox-nightly)
413    // share one profile root, so a catalogue-wide scan would otherwise report
414    // the same profile under each id. Keep the first (canonical) browser.
415    let mut seen: HashSet<PathBuf> = HashSet::new();
416    for browser in browsers {
417        for profile in list_profiles_for_browser(
418            browser,
419            &options.platform,
420            &options.home_dir,
421            &options.environment,
422        )? {
423            if seen.insert(profile.path.clone()) {
424                profiles.push(profile);
425            }
426        }
427    }
428    Ok(profiles)
429}
430
431pub(crate) fn resolve_browser_profile(
432    browser: &str,
433    requested_profile: Option<&str>,
434    options: &BrowserProfileOptions,
435) -> Result<BrowserProfile> {
436    let browser = resolve_source_browser(
437        browser,
438        &options.platform,
439        &options.environment,
440        options.run_command.as_ref(),
441    )?;
442    let profiles = list_browser_profiles(options.clone().browser(browser))?;
443    let selected = requested_profile
444        .and_then(|requested| {
445            profiles.iter().find(|profile| {
446                profile.name == requested
447                    || profile.display_name == requested
448                    || profile.path.file_name().and_then(|name| name.to_str()) == Some(requested)
449            })
450        })
451        .or_else(|| profiles.iter().find(|profile| profile.is_default))
452        .or_else(|| profiles.first());
453    selected.cloned().ok_or_else(|| {
454        let detail = requested_profile
455            .map(|profile| format!(" profile \"{profile}\""))
456            .unwrap_or_else(|| " profile".into());
457        anyhow!("Could not find a cookie database for {browser}{detail}")
458    })
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use std::sync::Arc;
465
466    struct TempDir(PathBuf);
467
468    impl TempDir {
469        fn new(prefix: &str) -> Self {
470            use std::sync::atomic::{AtomicU64, Ordering};
471            static COUNTER: AtomicU64 = AtomicU64::new(0);
472            let nanos = std::time::SystemTime::now()
473                .duration_since(std::time::UNIX_EPOCH)
474                .map(|elapsed| elapsed.as_nanos())
475                .unwrap_or_default();
476            let count = COUNTER.fetch_add(1, Ordering::Relaxed);
477            let path = std::env::temp_dir()
478                .join(format!("{prefix}{}-{nanos:x}-{count}", std::process::id()));
479            fs::create_dir_all(&path).expect("temporary directory");
480            Self(path)
481        }
482
483        fn path(&self) -> &Path {
484            &self.0
485        }
486    }
487
488    impl Drop for TempDir {
489        fn drop(&mut self) {
490            let _ = fs::remove_dir_all(&self.0);
491        }
492    }
493
494    fn write(path: &Path, contents: &str) {
495        fs::create_dir_all(path.parent().unwrap()).expect("parent");
496        fs::write(path, contents).expect("write");
497    }
498
499    fn write_firefox_profile(home: &Path, root_name: &[&str], profile_name: &str) -> PathBuf {
500        let mut root = home.to_path_buf();
501        for part in root_name {
502            root = root.join(part);
503        }
504        let profile_path = root.join(profile_name);
505        fs::create_dir_all(&profile_path).expect("profile dir");
506        write(
507            &root.join("profiles.ini"),
508            &format!(
509                "[Profile0]\nName={}\nIsRelative=1\nPath={profile_name}\nDefault=1\n",
510                profile_name
511                    .split_once('.')
512                    .map(|(_, name)| name)
513                    .unwrap_or(profile_name)
514            ),
515        );
516        fs::write(profile_path.join("cookies.sqlite"), b"SQLite format 3\0").expect("cookies");
517        profile_path
518    }
519
520    fn options(home: &Path) -> BrowserProfileOptions {
521        BrowserProfileOptions {
522            browser: None,
523            home_dir: home.to_path_buf(),
524            platform: "linux".to_string(),
525            environment: Environment::new(),
526            run_command: None,
527        }
528    }
529
530    #[test]
531    fn reads_an_opera_single_profile_layout_from_the_root_itself() {
532        let temp = TempDir::new("bc-opera-");
533        let root = temp.path().join(".config").join("opera");
534        fs::create_dir_all(root.join("Network")).expect("network");
535        fs::write(root.join("Network").join("Cookies"), b"SQLite format 3\0").expect("cookies");
536
537        let profiles = list_browser_profiles(options(temp.path()).browser("opera")).unwrap();
538        assert_eq!(
539            profiles,
540            vec![BrowserProfile {
541                browser: "opera".into(),
542                name: "Default".into(),
543                display_name: "Default".into(),
544                path: root,
545                is_default: true,
546            }]
547        );
548    }
549
550    #[test]
551    fn discovers_a_firefox_fork_librewolf_by_its_own_root() {
552        let temp = TempDir::new("bc-librewolf-");
553        let profile_path = write_firefox_profile(temp.path(), &[".librewolf"], "abcd.default");
554
555        let profiles = list_browser_profiles(options(temp.path()).browser("librewolf")).unwrap();
556        assert_eq!(
557            profiles,
558            vec![BrowserProfile {
559                browser: "librewolf".into(),
560                name: "default".into(),
561                display_name: "default".into(),
562                path: profile_path,
563                is_default: true,
564            }]
565        );
566    }
567
568    #[test]
569    fn lists_firefox_once_when_its_channels_share_a_root() {
570        let temp = TempDir::new("bc-ffshare-");
571        write_firefox_profile(temp.path(), &[".mozilla", "firefox"], "xyz.default-release");
572
573        let profiles = list_browser_profiles(options(temp.path())).unwrap();
574        let browsers: Vec<&str> = profiles
575            .iter()
576            .filter(|profile| profile.browser.starts_with("firefox"))
577            .map(|profile| profile.browser.as_str())
578            .collect();
579        assert_eq!(browsers, vec!["firefox"]);
580    }
581
582    fn firefox_runner() -> RunCommand {
583        Arc::new(|command: &str, args: &[&str], _env: &Environment| {
584            if command == "xdg-settings" && args == ["get", "default-web-browser"] {
585                Ok("firefox.desktop\n".to_string())
586            } else {
587                Err(anyhow!("unexpected command"))
588            }
589        })
590    }
591
592    #[test]
593    fn resolves_browser_default_to_the_system_default_browser() {
594        let runner = firefox_runner();
595        assert_eq!(
596            resolve_source_browser("default", "linux", &Environment::new(), Some(&runner)).unwrap(),
597            "firefox"
598        );
599        assert_eq!(
600            resolve_source_browser("AUTO", "linux", &Environment::new(), Some(&runner)).unwrap(),
601            "firefox"
602        );
603    }
604
605    #[test]
606    fn lists_the_default_browser_profile_when_browser_is_default() {
607        let temp = TempDir::new("bc-default-");
608        let profile_path =
609            write_firefox_profile(temp.path(), &[".mozilla", "firefox"], "xyz.default-release");
610        let runner: RunCommand = Arc::new(|_command: &str, _args: &[&str], _env: &Environment| {
611            Ok("firefox.desktop\n".to_string())
612        });
613
614        let resolved = resolve_browser_profile(
615            "default",
616            None,
617            &options(temp.path()).run_command(runner.clone()),
618        )
619        .unwrap();
620        assert_eq!(resolved.browser, "firefox");
621        assert_eq!(resolved.path, profile_path);
622
623        let profiles =
624            list_browser_profiles(options(temp.path()).browser("auto").run_command(runner))
625                .unwrap();
626        assert_eq!(
627            profiles
628                .iter()
629                .map(|profile| profile.browser.as_str())
630                .collect::<Vec<_>>(),
631            vec!["firefox"]
632        );
633    }
634
635    #[test]
636    fn reports_a_clear_error_when_the_default_browser_is_unknown() {
637        let runner: RunCommand =
638            Arc::new(|_command: &str, _args: &[&str], _env: &Environment| Err(anyhow!("no xdg")));
639        let error = resolve_source_browser("default", "linux", &Environment::new(), Some(&runner))
640            .unwrap_err()
641            .to_string();
642        assert!(
643            error.contains("Could not determine the system default browser"),
644            "{error}"
645        );
646    }
647}