diff --git a/README.md b/README.md index 2e052ad2f9..653e7f21ad 100755 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Win-CodexBar is a Windows system-tray app for keeping AI coding-tool usage visib - **Tray-first workflow** with a compact provider grid, usage cards, refresh action, settings shortcut, and quit control. - **Provider settings** for source selection, credentials, cookie import, token accounts, API keys, regions, and tray-display preferences. - **Windows credential protection** for app-managed API keys, manual cookies, and token accounts, using user-scoped DPAPI where available. -- **Browser cookie import** for Chrome, Edge, Brave, and Firefox, kept opt-in per provider. +- **Browser cookie import** for Chrome Stable, Beta, Dev, Canary, Chrome for Testing, Chromium, Edge, Brave, and Firefox, kept opt-in per provider. - **Installed local CLI** for scripting usage, cost, config, diagnostics, and loopback integrations. - **Installer + portable builds** with WebView2 runtime bootstrap, VC++ runtime bootstrap, and SHA-256 checksum files. diff --git a/apps/desktop-tauri/src-tauri/src/commands/browser_import.rs b/apps/desktop-tauri/src-tauri/src/commands/browser_import.rs index 40d8ff79a8..8ea322b7c7 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/browser_import.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/browser_import.rs @@ -23,7 +23,7 @@ pub fn list_detected_browsers() -> Vec { BrowserDetector::detect_all() .into_iter() .map(|b| DetectedBrowserBridge { - browser_type: browser_type_key(b.browser_type).to_string(), + browser_type: b.browser_type.key().to_string(), display_name: b.browser_type.display_name().to_string(), profile_count: b.profiles.len(), }) @@ -62,7 +62,7 @@ pub fn import_browser_cookies( let browsers = BrowserDetector::detect_all(); let browser = browsers .into_iter() - .find(|b| browser_type_key(b.browser_type) == browser_type.as_str()) + .find(|b| b.browser_type.key() == browser_type.as_str()) .ok_or_else(|| format!("Browser '{browser_type}' not found or not installed"))?; // Extract the cookie header. @@ -86,16 +86,3 @@ pub fn import_browser_cookies( Ok(get_manual_cookies()) } - -/// Map `BrowserType` to a stable lowercase string key used in the IPC bridge. -fn browser_type_key(bt: codexbar::browser::detection::BrowserType) -> &'static str { - use codexbar::browser::detection::BrowserType; - match bt { - BrowserType::Chrome => "chrome", - BrowserType::Edge => "edge", - BrowserType::Brave => "brave", - BrowserType::Arc => "arc", - BrowserType::Firefox => "firefox", - BrowserType::Chromium => "chromium", - } -} diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 237f7dab3d..3f5758d5e2 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -40,7 +40,7 @@ Auth resolution helpers in `rust/src/providers/` commonly try: explicit settings ## Cookie-backed providers -Windows browser import: Chrome, Edge, Brave (DPAPI + AES-GCM), Firefox (SQLite). +Windows browser import: Chrome Stable, Beta, Dev, Canary, Chrome for Testing, Chromium, Edge, Brave (DPAPI + AES-GCM), and Firefox (SQLite). Chrome channels are detected separately and keep distinct import choices. Settings → **Providers** → provider detail → choose browser → Import. Manual cookie header paste is the fallback (required under WSL for Chromium DPAPI). Details: [COOKIES.md](./COOKIES.md). diff --git a/rust/src/browser/detection.rs b/rust/src/browser/detection.rs index 0dc39f27d7..2a4167e8e0 100755 --- a/rust/src/browser/detection.rs +++ b/rust/src/browser/detection.rs @@ -16,6 +16,10 @@ use crate::wsl; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum BrowserType { Chrome, + ChromeBeta, + ChromeDev, + ChromeCanary, + ChromeForTesting, Edge, Brave, Arc, @@ -28,6 +32,10 @@ impl BrowserType { pub fn all() -> &'static [BrowserType] { &[ BrowserType::Chrome, + BrowserType::ChromeBeta, + BrowserType::ChromeDev, + BrowserType::ChromeCanary, + BrowserType::ChromeForTesting, BrowserType::Edge, BrowserType::Brave, BrowserType::Arc, @@ -45,6 +53,10 @@ impl BrowserType { pub fn display_name(&self) -> &'static str { match self { BrowserType::Chrome => "Google Chrome", + BrowserType::ChromeBeta => "Google Chrome Beta", + BrowserType::ChromeDev => "Google Chrome Dev", + BrowserType::ChromeCanary => "Google Chrome Canary", + BrowserType::ChromeForTesting => "Chrome for Testing", BrowserType::Edge => "Microsoft Edge", BrowserType::Brave => "Brave", BrowserType::Arc => "Arc", @@ -52,6 +64,41 @@ impl BrowserType { BrowserType::Chromium => "Chromium", } } + + /// Stable browser identifier shared by the core and desktop IPC bridge. + pub fn key(&self) -> &'static str { + match self { + BrowserType::Chrome => "chrome", + BrowserType::ChromeBeta => "chrome-beta", + BrowserType::ChromeDev => "chrome-dev", + BrowserType::ChromeCanary => "chrome-canary", + BrowserType::ChromeForTesting => "chrome-for-testing", + BrowserType::Edge => "edge", + BrowserType::Brave => "brave", + BrowserType::Arc => "arc", + BrowserType::Firefox => "firefox", + BrowserType::Chromium => "chromium", + } + } + + /// Resolve a profile root under Windows AppData. Firefox requires Roaming; + /// local-root browsers remain resolvable when Roaming is unavailable. + pub fn user_data_dir(&self, local: &Path, roaming: Option<&Path>) -> Option { + match self { + BrowserType::Chrome => Some(local.join("Google/Chrome/User Data")), + BrowserType::ChromeBeta => Some(local.join("Google/Chrome Beta/User Data")), + BrowserType::ChromeDev => Some(local.join("Google/Chrome Dev/User Data")), + BrowserType::ChromeCanary => Some(local.join("Google/Chrome SxS/User Data")), + BrowserType::ChromeForTesting => { + Some(local.join("Google/Chrome for Testing/User Data")) + } + BrowserType::Edge => Some(local.join("Microsoft/Edge/User Data")), + BrowserType::Brave => Some(local.join("BraveSoftware/Brave-Browser/User Data")), + BrowserType::Arc => Some(local.join("Arc/User Data")), + BrowserType::Firefox => roaming.map(|root| root.join("Mozilla/Firefox/Profiles")), + BrowserType::Chromium => Some(local.join("Chromium/User Data")), + } + } } /// A detected browser installation @@ -117,7 +164,23 @@ impl BrowserDetector { /// Detect a specific browser pub fn detect(browser_type: BrowserType) -> Option { let user_data_dir = Self::get_user_data_dir(browser_type)?; + Self::detect_at_path(browser_type, user_data_dir) + } + /// Detect from explicit AppData roots, including WSL-mounted Windows roots. + pub(super) fn detect_in_roots( + browser_type: BrowserType, + local: &Path, + roaming: Option<&Path>, + ) -> Option { + let user_data_dir = browser_type.user_data_dir(local, roaming)?; + Self::detect_at_path(browser_type, user_data_dir) + } + + fn detect_at_path( + browser_type: BrowserType, + user_data_dir: PathBuf, + ) -> Option { if !user_data_dir.exists() { return None; } @@ -141,59 +204,17 @@ impl BrowserDetector { if wsl::is_wsl() && let Some(appdata_local) = wsl::windows_appdata_local() { - let path = match browser_type { - BrowserType::Chrome => Some( - appdata_local - .join("Google") - .join("Chrome") - .join("User Data"), - ), - BrowserType::Edge => Some( - appdata_local - .join("Microsoft") - .join("Edge") - .join("User Data"), - ), - BrowserType::Brave => Some( - appdata_local - .join("BraveSoftware") - .join("Brave-Browser") - .join("User Data"), - ), - BrowserType::Arc => Some(appdata_local.join("Arc").join("User Data")), - BrowserType::Chromium => Some(appdata_local.join("Chromium").join("User Data")), - BrowserType::Firefox => wsl::windows_appdata_roaming() - .map(|roaming| roaming.join("Mozilla").join("Firefox").join("Profiles")), - }; - if let Some(ref p) = path - && p.exists() + let roaming = wsl::windows_appdata_roaming(); + if let Some(path) = browser_type.user_data_dir(&appdata_local, roaming.as_deref()) + && path.exists() { - return path; + return Some(path); } } let local_app_data = dirs::data_local_dir()?; - let app_data = dirs::data_dir()?; - - let path = match browser_type { - BrowserType::Chrome => local_app_data - .join("Google") - .join("Chrome") - .join("User Data"), - BrowserType::Edge => local_app_data - .join("Microsoft") - .join("Edge") - .join("User Data"), - BrowserType::Brave => local_app_data - .join("BraveSoftware") - .join("Brave-Browser") - .join("User Data"), - BrowserType::Arc => local_app_data.join("Arc").join("User Data"), - BrowserType::Chromium => local_app_data.join("Chromium").join("User Data"), - BrowserType::Firefox => app_data.join("Mozilla").join("Firefox").join("Profiles"), - }; - - Some(path) + let app_data = dirs::data_dir(); + browser_type.user_data_dir(&local_app_data, app_data.as_deref()) } /// Detect profiles within a browser's user data directory @@ -268,6 +289,92 @@ impl BrowserDetector { mod tests { use super::*; + #[test] + fn browser_paths_and_keys_cover_every_type() { + let local = Path::new("C:/Users/test/AppData/Local"); + let roaming = Path::new("C:/Users/test/AppData/Roaming"); + let expected = [ + ( + BrowserType::Chrome, + "Google/Chrome/User Data", + "Google Chrome", + "chrome", + ), + ( + BrowserType::ChromeBeta, + "Google/Chrome Beta/User Data", + "Google Chrome Beta", + "chrome-beta", + ), + ( + BrowserType::ChromeDev, + "Google/Chrome Dev/User Data", + "Google Chrome Dev", + "chrome-dev", + ), + ( + BrowserType::ChromeCanary, + "Google/Chrome SxS/User Data", + "Google Chrome Canary", + "chrome-canary", + ), + ( + BrowserType::ChromeForTesting, + "Google/Chrome for Testing/User Data", + "Chrome for Testing", + "chrome-for-testing", + ), + ( + BrowserType::Edge, + "Microsoft/Edge/User Data", + "Microsoft Edge", + "edge", + ), + ( + BrowserType::Brave, + "BraveSoftware/Brave-Browser/User Data", + "Brave", + "brave", + ), + (BrowserType::Arc, "Arc/User Data", "Arc", "arc"), + ( + BrowserType::Firefox, + "Mozilla/Firefox/Profiles", + "Firefox", + "firefox", + ), + ( + BrowserType::Chromium, + "Chromium/User Data", + "Chromium", + "chromium", + ), + ]; + + assert_eq!(BrowserType::all().len(), expected.len()); + let mut keys = std::collections::HashSet::new(); + for (browser, relative_path, display_name, key) in expected { + let root = if browser == BrowserType::Firefox { + roaming + } else { + local + }; + assert_eq!( + browser.user_data_dir(local, Some(roaming)), + Some(root.join(relative_path)), + "wrong profile root for {display_name}" + ); + assert_eq!(browser.display_name(), display_name); + assert_eq!(browser.key(), key); + assert!(keys.insert(browser.key()), "duplicate IPC key: {key}"); + assert!(BrowserType::all().contains(&browser)); + assert_eq!( + browser.user_data_dir(local, None), + (browser != BrowserType::Firefox).then(|| local.join(relative_path)) + ); + } + } + #[test] fn test_browser_detection() { let browsers = BrowserDetector::detect_all(); diff --git a/rust/src/browser/wsl_paths.rs b/rust/src/browser/wsl_paths.rs index 42d93f17d4..df59249d48 100644 --- a/rust/src/browser/wsl_paths.rs +++ b/rust/src/browser/wsl_paths.rs @@ -4,11 +4,9 @@ //! This module provides path resolvers that detect WSL and map //! browser profile paths to their Windows host equivalents. -use std::path::PathBuf; - use crate::wsl; -use super::detection::{BrowserProfile, BrowserType, DetectedBrowser}; +use super::detection::{BrowserDetector, BrowserType, DetectedBrowser}; /// WSL-aware browser detector. /// @@ -17,6 +15,7 @@ use super::detection::{BrowserProfile, BrowserType, DetectedBrowser}; pub struct WslBrowserDetector; impl WslBrowserDetector { + /// Detect Windows browsers visible from WSL via /mnt/c/ paths. pub fn detect_all() -> Vec { if !wsl::is_wsl() { return Vec::new(); @@ -27,130 +26,82 @@ impl WslBrowserDetector { None => return Vec::new(), }; - let mut browsers = Vec::new(); - - let candidates: &[(BrowserType, PathBuf)] = &[ - ( - BrowserType::Chrome, - appdata_local - .join("Google") - .join("Chrome") - .join("User Data"), - ), - ( - BrowserType::Edge, - appdata_local - .join("Microsoft") - .join("Edge") - .join("User Data"), - ), - ( - BrowserType::Brave, - appdata_local - .join("BraveSoftware") - .join("Brave-Browser") - .join("User Data"), - ), - ( - BrowserType::Arc, - appdata_local.join("Arc").join("User Data"), - ), - ( - BrowserType::Chromium, - appdata_local.join("Chromium").join("User Data"), - ), - ]; - - for (browser_type, user_data_dir) in candidates { - if user_data_dir.exists() { - let profiles = detect_chromium_profiles(user_data_dir); - if !profiles.is_empty() { - browsers.push(DetectedBrowser { - browser_type: *browser_type, - user_data_dir: user_data_dir.clone(), - profiles, - }); - } - } - } - - if let Some(appdata_roaming) = wsl::windows_appdata_roaming() { - let ff_dir = appdata_roaming - .join("Mozilla") - .join("Firefox") - .join("Profiles"); - if ff_dir.exists() { - let profiles = detect_firefox_profiles(&ff_dir); - if !profiles.is_empty() { - browsers.push(DetectedBrowser { - browser_type: BrowserType::Firefox, - user_data_dir: ff_dir, - profiles, - }); - } - } - } - - browsers + let appdata_roaming = wsl::windows_appdata_roaming(); + BrowserType::all() + .iter() + .copied() + .filter_map(|browser_type| { + BrowserDetector::detect_in_roots( + browser_type, + &appdata_local, + appdata_roaming.as_deref(), + ) + }) + .collect() } } -fn detect_chromium_profiles(user_data_dir: &PathBuf) -> Vec { - let mut profiles = Vec::new(); - - let default_path = user_data_dir.join("Default"); - if default_path.exists() { - profiles.push(BrowserProfile { - name: "Default".to_string(), - path: default_path, - is_default: true, - }); - } +#[cfg(test)] +mod tests { + use super::*; - if let Ok(entries) = std::fs::read_dir(user_data_dir) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with("Profile ") { - let path = entry.path(); - if path.is_dir() { - profiles.push(BrowserProfile { - name, - path, - is_default: false, - }); - } - } - } + #[test] + fn shared_detector_reads_wsl_shaped_roots_without_roaming() { + let temp = tempfile::tempdir().unwrap(); + let local = temp.path().join("mnt/c/Users/test/AppData/Local"); + let chrome = local.join("Google/Chrome Beta/User Data"); + std::fs::create_dir_all(chrome.join("Default")).unwrap(); + std::fs::create_dir_all(chrome.join("Profile 2")).unwrap(); + std::fs::create_dir_all(chrome.join("Profile 1")).unwrap(); + std::fs::create_dir_all(chrome.join("Other")).unwrap(); + + let detected = + BrowserDetector::detect_in_roots(BrowserType::ChromeBeta, &local, None).unwrap(); + assert_eq!(detected.user_data_dir, chrome); + assert_eq!(detected.profiles[0].name, "Default"); + assert!(detected.profiles[0].is_default); + let mut other_names: Vec<_> = detected.profiles[1..] + .iter() + .map(|profile| profile.name.as_str()) + .collect(); + other_names.sort_unstable(); + assert_eq!(other_names, ["Profile 1", "Profile 2"]); + assert!( + detected.profiles[1..] + .iter() + .all(|profile| !profile.is_default) + ); + assert!(BrowserDetector::detect_in_roots(BrowserType::Firefox, &local, None).is_none()); } - profiles -} - -fn detect_firefox_profiles(profiles_dir: &PathBuf) -> Vec { - let mut profiles = Vec::new(); - - if let Ok(entries) = std::fs::read_dir(profiles_dir) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - let path = entry.path(); - if path.is_dir() && name.contains('.') { - let is_default = name.contains("default"); - profiles.push(BrowserProfile { - name, - path, - is_default, - }); - } - } + #[test] + fn shared_detector_reads_firefox_from_wsl_roaming_root() { + let temp = tempfile::tempdir().unwrap(); + let local = temp.path().join("mnt/c/Users/test/AppData/Local"); + let roaming = temp.path().join("mnt/c/Users/test/AppData/Roaming"); + let firefox = roaming.join("Mozilla/Firefox/Profiles"); + std::fs::create_dir_all(firefox.join("abc.default-release")).unwrap(); + std::fs::create_dir_all(firefox.join("def.other")).unwrap(); + std::fs::create_dir_all(local.join("Mozilla/Firefox/Profiles/fake.default")).unwrap(); + + let detected = + BrowserDetector::detect_in_roots(BrowserType::Firefox, &local, Some(&roaming)).unwrap(); + assert_eq!(detected.user_data_dir, firefox); + assert_eq!(detected.profiles.len(), 2); + assert!( + detected + .profiles + .iter() + .any(|profile| profile.name == "abc.default-release" && profile.is_default) + ); + assert!( + detected + .profiles + .iter() + .any(|profile| profile.name == "def.other" && !profile.is_default) + ); } - profiles -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] fn test_wsl_browser_detection() { let browsers = WslBrowserDetector::detect_all();