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    /// Discovery failure for this source; readable profiles remain in the listing.
43    pub error: Option<String>,
44}
45
46/// Options for [`list_browser_profiles`].
47#[derive(Clone)]
48pub struct BrowserProfileOptions {
49    /// Restrict discovery to one browser. `None` scans all supported browsers.
50    pub browser: Option<String>,
51    /// Home directory used to resolve conventional profile locations.
52    pub home_dir: PathBuf,
53    /// Platform path convention (`linux`, `darwin`, or `win32`).
54    pub platform: String,
55    /// Environment used to expand profile-root templates (`%APPDATA%`, ...).
56    pub environment: Environment,
57    /// Command runner used to resolve the system default browser, injectable
58    /// for deterministic tests. `None` uses a real subprocess.
59    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    /// Restrict discovery to one installed browser.
89    pub fn browser(mut self, browser: impl Into<String>) -> Self {
90        self.browser = Some(browser.into());
91        self
92    }
93
94    /// Override the home directory used for discovery.
95    pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
96        self.home_dir = home_dir.into();
97        self
98    }
99
100    /// Override the platform convention, primarily for portable tooling/tests.
101    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    /// Override the environment used when expanding profile-root templates.
107    pub fn environment(mut self, environment: Environment) -> Self {
108        self.environment = environment;
109        self
110    }
111
112    /// Inject the command runner used to resolve the system default browser.
113    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
131/// Resolve a browser name (id or alias) to its canonical catalogue id.
132pub(crate) fn normalize_cookie_browser(browser: &str) -> Result<&'static str> {
133    normalize_browser_id(browser)
134}
135
136/// Whether `browser` asks for the system default rather than a named browser.
137pub 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
142/// Resolve a requested browser to a canonical catalogue id, expanding the
143/// `default`/`auto` keywords to the operating-system default browser. Named
144/// browsers are normalized through the catalogue as before.
145pub 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
170/// The primary profile root a browser uses on a platform.
171pub(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, &current_environment())
177}
178
179/// [`browser_profile_root`] with an explicit environment for the root templates.
180pub(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    // Opera-style browsers keep one profile in the root itself rather than in
235    // Default/Profile N subdirectories.
236    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    // No profiles.ini (some forks): fall back to the Profiles directory.
325    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
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, 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}