browser_commander/browser/
browser_profiles.rs1use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{anyhow, Result};
8use serde::Deserialize;
9
10pub const SUPPORTED_COOKIE_BROWSERS: [&str; 5] = ["chrome", "edge", "brave", "chromium", "firefox"];
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct BrowserProfile {
16 pub browser: String,
18 pub name: String,
20 pub display_name: String,
22 pub path: PathBuf,
24 pub is_default: bool,
26}
27
28#[derive(Debug, Clone)]
30pub struct BrowserProfileOptions {
31 pub browser: Option<String>,
33 pub home_dir: PathBuf,
35 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 pub fn browser(mut self, browser: impl Into<String>) -> Self {
52 self.browser = Some(browser.into());
53 self
54 }
55
56 pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
58 self.home_dir = home_dir.into();
59 self
60 }
61
62 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
267pub 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}