Skip to main content

browser_commander/browser/
browser_profiles.rs

1//! Discovery of installed browser profiles that contain cookie databases.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{anyhow, Result};
8use serde::Deserialize;
9
10/// Browsers whose on-disk cookie stores can be imported.
11pub const SUPPORTED_COOKIE_BROWSERS: [&str; 5] = ["chrome", "edge", "brave", "chromium", "firefox"];
12
13/// Metadata for a cookie-bearing installed browser profile.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct BrowserProfile {
16    /// Normalized browser name.
17    pub browser: String,
18    /// On-disk profile name.
19    pub name: String,
20    /// Human-readable profile name, when the browser supplies one.
21    pub display_name: String,
22    /// Absolute or home-relative profile directory.
23    pub path: PathBuf,
24    /// Whether the browser marks this profile as the default/last used one.
25    pub is_default: bool,
26}
27
28/// Options for [`list_browser_profiles`].
29#[derive(Debug, Clone)]
30pub struct BrowserProfileOptions {
31    /// Restrict discovery to one browser. `None` scans all supported browsers.
32    pub browser: Option<String>,
33    /// Home directory used to resolve conventional profile locations.
34    pub home_dir: PathBuf,
35    /// Platform path convention (`linux`, `darwin`, or `win32`).
36    pub platform: String,
37}
38
39impl Default for BrowserProfileOptions {
40    fn default() -> Self {
41        Self {
42            browser: None,
43            home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")),
44            platform: current_platform().to_string(),
45        }
46    }
47}
48
49impl BrowserProfileOptions {
50    /// Restrict discovery to one installed browser.
51    pub fn browser(mut self, browser: impl Into<String>) -> Self {
52        self.browser = Some(browser.into());
53        self
54    }
55
56    /// Override the home directory used for discovery.
57    pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
58        self.home_dir = home_dir.into();
59        self
60    }
61
62    /// Override the platform convention, primarily for portable tooling/tests.
63    pub fn platform(mut self, platform: impl AsRef<str>) -> Self {
64        self.platform = normalize_platform(platform.as_ref()).to_string();
65        self
66    }
67}
68
69pub(crate) fn current_platform() -> &'static str {
70    normalize_platform(std::env::consts::OS)
71}
72
73pub(crate) fn normalize_platform(platform: &str) -> &str {
74    match platform {
75        "macos" => "darwin",
76        "windows" => "win32",
77        other => other,
78    }
79}
80
81pub(crate) fn normalize_cookie_browser(browser: &str) -> Result<&str> {
82    let normalized = if browser == "msedge" { "edge" } else { browser };
83    if SUPPORTED_COOKIE_BROWSERS.contains(&normalized) {
84        Ok(normalized)
85    } else {
86        Err(anyhow!(
87            "Unsupported browser: {browser}. Expected one of {}",
88            SUPPORTED_COOKIE_BROWSERS.join(", ")
89        ))
90    }
91}
92
93pub(crate) fn browser_profile_root(
94    browser: &str,
95    platform: &str,
96    home_dir: &Path,
97) -> Result<PathBuf> {
98    let browser = normalize_cookie_browser(browser)?;
99    let local = std::env::var_os("LOCALAPPDATA")
100        .map(PathBuf::from)
101        .unwrap_or_else(|| home_dir.join("AppData/Local"));
102    let roaming = std::env::var_os("APPDATA")
103        .map(PathBuf::from)
104        .unwrap_or_else(|| home_dir.join("AppData/Roaming"));
105    let support = home_dir.join("Library/Application Support");
106    let root = match (normalize_platform(platform), browser) {
107        ("darwin", "chrome") => support.join("Google/Chrome"),
108        ("darwin", "edge") => support.join("Microsoft Edge"),
109        ("darwin", "brave") => support.join("BraveSoftware/Brave-Browser"),
110        ("darwin", "chromium") => support.join("Chromium"),
111        ("darwin", "firefox") => support.join("Firefox"),
112        ("win32", "chrome") => local.join("Google/Chrome/User Data"),
113        ("win32", "edge") => local.join("Microsoft/Edge/User Data"),
114        ("win32", "brave") => local.join("BraveSoftware/Brave-Browser/User Data"),
115        ("win32", "chromium") => local.join("Chromium/User Data"),
116        ("win32", "firefox") => roaming.join("Mozilla/Firefox"),
117        (_, "chrome") => home_dir.join(".config/google-chrome"),
118        (_, "edge") => home_dir.join(".config/microsoft-edge"),
119        (_, "brave") => home_dir.join(".config/BraveSoftware/Brave-Browser"),
120        (_, "chromium") => home_dir.join(".config/chromium"),
121        (_, "firefox") => home_dir.join(".mozilla/firefox"),
122        _ => unreachable!(),
123    };
124    Ok(root)
125}
126
127pub(crate) fn find_cookie_database(browser: &str, profile_path: &Path) -> Option<PathBuf> {
128    if browser == "firefox" {
129        let candidate = profile_path.join("cookies.sqlite");
130        return candidate.is_file().then_some(candidate);
131    }
132    [
133        profile_path.join("Network/Cookies"),
134        profile_path.join("Cookies"),
135    ]
136    .into_iter()
137    .find(|candidate| candidate.is_file())
138}
139
140#[derive(Debug, Default, Deserialize)]
141struct LocalState {
142    #[serde(default)]
143    profile: LocalStateProfile,
144}
145
146#[derive(Debug, Default, Deserialize)]
147struct LocalStateProfile {
148    last_used: Option<String>,
149    #[serde(default)]
150    info_cache: BTreeMap<String, LocalStateProfileInfo>,
151}
152
153#[derive(Debug, Default, Deserialize)]
154struct LocalStateProfileInfo {
155    name: Option<String>,
156}
157
158fn list_chromium_profiles(browser: &str, root: &Path) -> Vec<BrowserProfile> {
159    if !root.is_dir() {
160        return Vec::new();
161    }
162    let state = fs::read_to_string(root.join("Local State"))
163        .ok()
164        .and_then(|contents| serde_json::from_str::<LocalState>(&contents).ok())
165        .unwrap_or_default();
166    let mut names = state
167        .profile
168        .info_cache
169        .keys()
170        .cloned()
171        .collect::<BTreeSet<_>>();
172    if let Ok(entries) = fs::read_dir(root) {
173        for name in entries
174            .flatten()
175            .filter_map(|entry| entry.file_name().into_string().ok())
176            .filter(|name| name == "Default" || name.starts_with("Profile "))
177        {
178            names.insert(name);
179        }
180    }
181    let default_name = state.profile.last_used.as_deref().unwrap_or("Default");
182    let only_default = names.len() == 1 && names.contains("Default");
183    let mut profiles = names
184        .into_iter()
185        .filter_map(|name| {
186            let path = root.join(&name);
187            find_cookie_database(browser, &path)?;
188            let display_name = state
189                .profile
190                .info_cache
191                .get(&name)
192                .and_then(|info| info.name.clone())
193                .unwrap_or_else(|| name.clone());
194            Some(BrowserProfile {
195                browser: browser.to_string(),
196                is_default: name == default_name || (only_default && name == "Default"),
197                name,
198                display_name,
199                path,
200            })
201        })
202        .collect::<Vec<_>>();
203    profiles.sort_by_key(|profile| (!profile.is_default, profile.name.clone()));
204    profiles
205}
206
207fn parse_ini(contents: &str) -> Vec<BTreeMap<String, String>> {
208    let mut sections = Vec::new();
209    let mut current: Option<BTreeMap<String, String>> = None;
210    for raw_line in contents.lines() {
211        let line = raw_line.trim();
212        if line.starts_with('[') && line.ends_with(']') {
213            if let Some(section) = current.take() {
214                sections.push(section);
215            }
216            let mut section = BTreeMap::new();
217            section.insert("section".into(), line[1..line.len() - 1].into());
218            current = Some(section);
219        } else if let (Some(section), Some((key, value))) = (current.as_mut(), line.split_once('='))
220        {
221            section.insert(key.trim().into(), value.trim().into());
222        }
223    }
224    if let Some(section) = current {
225        sections.push(section);
226    }
227    sections
228}
229
230fn list_firefox_profiles(root: &Path) -> Vec<BrowserProfile> {
231    let contents = match fs::read_to_string(root.join("profiles.ini")) {
232        Ok(contents) => contents,
233        Err(_) => return Vec::new(),
234    };
235    let mut profiles = parse_ini(&contents)
236        .into_iter()
237        .filter(|section| {
238            section
239                .get("section")
240                .is_some_and(|name| name.starts_with("Profile"))
241        })
242        .filter_map(|section| {
243            let configured = PathBuf::from(section.get("Path")?);
244            let path = if section.get("IsRelative").map(String::as_str) == Some("0") {
245                configured
246            } else {
247                root.join(configured)
248            };
249            find_cookie_database("firefox", &path)?;
250            let name = section
251                .get("Name")
252                .cloned()
253                .or_else(|| path.file_name()?.to_str().map(String::from))?;
254            Some(BrowserProfile {
255                browser: "firefox".into(),
256                name: name.clone(),
257                display_name: name,
258                path,
259                is_default: section.get("Default").map(String::as_str) == Some("1"),
260            })
261        })
262        .collect::<Vec<_>>();
263    profiles.sort_by_key(|profile| (!profile.is_default, profile.name.clone()));
264    profiles
265}
266
267/// Discover cookie-bearing profiles for Chrome, Edge, Brave, Chromium, and Firefox.
268pub fn list_browser_profiles(options: BrowserProfileOptions) -> Result<Vec<BrowserProfile>> {
269    let browsers = match options.browser.as_deref() {
270        Some(browser) => vec![normalize_cookie_browser(browser)?],
271        None => SUPPORTED_COOKIE_BROWSERS.to_vec(),
272    };
273    let mut profiles = Vec::new();
274    for browser in browsers {
275        let root = browser_profile_root(browser, &options.platform, &options.home_dir)?;
276        if browser == "firefox" {
277            profiles.extend(list_firefox_profiles(&root));
278        } else {
279            profiles.extend(list_chromium_profiles(browser, &root));
280        }
281    }
282    Ok(profiles)
283}
284
285pub(crate) fn resolve_browser_profile(
286    browser: &str,
287    requested_profile: Option<&str>,
288    options: &BrowserProfileOptions,
289) -> Result<BrowserProfile> {
290    let profiles = list_browser_profiles(options.clone().browser(browser))?;
291    let selected = requested_profile
292        .and_then(|requested| {
293            profiles.iter().find(|profile| {
294                profile.name == requested
295                    || profile.display_name == requested
296                    || profile.path.file_name().and_then(|name| name.to_str()) == Some(requested)
297            })
298        })
299        .or_else(|| profiles.iter().find(|profile| profile.is_default))
300        .or_else(|| profiles.first());
301    selected.cloned().ok_or_else(|| {
302        let detail = requested_profile
303            .map(|profile| format!(" profile \"{profile}\""))
304            .unwrap_or_else(|| " profile".into());
305        anyhow!("Could not find a cookie database for {browser}{detail}")
306    })
307}