use std::{ collections::{BTreeMap, BTreeSet}, env, fs, path::{Path, PathBuf}, }; use base64::{engine::general_purpose::STANDARD, Engine as _}; use serde::Serialize; use serde_json::Value; #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ScanResponse { browsers: Vec, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct BrowserView { browser_id: String, browser_name: String, data_root: String, profiles: Vec, extensions: Vec, bookmarks: Vec, stats: BrowserStats, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct BrowserStats { profile_count: usize, extension_count: usize, bookmark_count: usize, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ProfileSummary { id: String, name: String, email: Option, avatar_data_url: Option, avatar_label: String, path: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ExtensionSummary { id: String, name: String, version: Option, icon_data_url: Option, profile_ids: Vec, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct BookmarkSummary { url: String, title: String, profile_ids: Vec, } struct BrowserDefinition { id: &'static str, name: &'static str, local_app_data_segments: &'static [&'static str], } struct TempExtension { id: String, name: String, version: Option, icon_data_url: Option, profile_ids: BTreeSet, } struct TempBookmark { url: String, title: String, profile_ids: BTreeSet, } #[tauri::command] fn scan_browsers() -> Result { let local_app_data = local_app_data_dir().ok_or_else(|| { "Unable to resolve the LOCALAPPDATA directory for the current user.".to_string() })?; let browsers = browser_definitions() .into_iter() .filter_map(|definition| scan_browser(&local_app_data, definition)) .collect(); Ok(ScanResponse { browsers }) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![scan_browsers]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } fn browser_definitions() -> Vec { vec![ BrowserDefinition { id: "chrome", name: "Google Chrome", local_app_data_segments: &["Google", "Chrome", "User Data"], }, BrowserDefinition { id: "edge", name: "Microsoft Edge", local_app_data_segments: &["Microsoft", "Edge", "User Data"], }, BrowserDefinition { id: "brave", name: "Brave", local_app_data_segments: &["BraveSoftware", "Brave-Browser", "User Data"], }, ] } fn local_app_data_dir() -> Option { env::var_os("LOCALAPPDATA").map(PathBuf::from).or_else(|| { env::var_os("USERPROFILE") .map(PathBuf::from) .map(|path| path.join("AppData").join("Local")) }) } fn scan_browser(local_app_data: &Path, definition: BrowserDefinition) -> Option { let root = definition .local_app_data_segments .iter() .fold(local_app_data.to_path_buf(), |path, segment| { path.join(segment) }); if !root.is_dir() { return None; } let local_state = read_json_file(&root.join("Local State")).unwrap_or(Value::Null); let profile_cache = local_state .get("profile") .and_then(|value| value.get("info_cache")) .and_then(Value::as_object); let mut profile_ids = BTreeSet::new(); collect_profile_ids_from_fs(&root, &mut profile_ids); if let Some(cache) = profile_cache { for profile_id in cache.keys() { profile_ids.insert(profile_id.to_string()); } } let mut profiles = Vec::new(); let mut extensions = BTreeMap::::new(); let mut bookmarks = BTreeMap::::new(); for profile_id in profile_ids { let profile_path = root.join(&profile_id); if !profile_path.is_dir() { continue; } let profile_info = profile_cache.and_then(|cache| cache.get(&profile_id)); profiles.push(build_profile_summary( &root, &profile_path, &profile_id, profile_info, )); scan_extensions_for_profile(&profile_path, &profile_id, &mut extensions); scan_bookmarks_for_profile(&profile_path, &profile_id, &mut bookmarks); } let profiles = sort_profiles(profiles); let extensions = extensions .into_values() .map(|entry| ExtensionSummary { id: entry.id, name: entry.name, version: entry.version, icon_data_url: entry.icon_data_url, profile_ids: entry.profile_ids.into_iter().collect(), }) .collect::>(); let bookmarks = bookmarks .into_values() .map(|entry| BookmarkSummary { url: entry.url, title: entry.title, profile_ids: entry.profile_ids.into_iter().collect(), }) .collect::>(); Some(BrowserView { browser_id: definition.id.to_string(), browser_name: definition.name.to_string(), data_root: root.display().to_string(), stats: BrowserStats { profile_count: profiles.len(), extension_count: extensions.len(), bookmark_count: bookmarks.len(), }, profiles, extensions: sort_extensions(extensions), bookmarks: sort_bookmarks(bookmarks), }) } fn collect_profile_ids_from_fs(root: &Path, profile_ids: &mut BTreeSet) { let Ok(entries) = fs::read_dir(root) else { return; }; for entry in entries.flatten() { let Ok(file_type) = entry.file_type() else { continue; }; if !file_type.is_dir() { continue; } let name = entry.file_name().to_string_lossy().to_string(); if name == "Default" || name.starts_with("Profile ") { profile_ids.insert(name); } } } fn build_profile_summary( root: &Path, profile_path: &Path, profile_id: &str, profile_info: Option<&Value>, ) -> ProfileSummary { let name = first_non_empty([ profile_info .and_then(|value| value.get("name")) .and_then(Value::as_str), profile_info .and_then(|value| value.get("gaia_name")) .and_then(Value::as_str), Some(profile_id), ]) .unwrap_or(profile_id) .to_string(); let email = first_non_empty([ profile_info .and_then(|value| value.get("user_name")) .and_then(Value::as_str), None, ]) .map(str::to_string); let avatar_data_url = resolve_profile_avatar(root, profile_path, profile_info); let avatar_label = name .chars() .find(|character| !character.is_whitespace()) .map(|character| character.to_uppercase().collect::()) .unwrap_or_else(|| "?".to_string()); ProfileSummary { id: profile_id.to_string(), name, email, avatar_data_url, avatar_label, path: profile_path.display().to_string(), } } fn resolve_profile_avatar( _root: &Path, profile_path: &Path, profile_info: Option<&Value>, ) -> Option { let picture_file = profile_info .and_then(|value| value.get("gaia_picture_file_name")) .and_then(Value::as_str) .filter(|value| !value.is_empty()); if let Some(file_name) = picture_file { let candidate = profile_path.join(file_name); if let Some(data_url) = load_image_as_data_url(&candidate) { return Some(data_url); } } None } fn scan_extensions_for_profile( profile_path: &Path, profile_id: &str, extensions: &mut BTreeMap, ) { let extensions_root = profile_path.join("Extensions"); let Ok(entries) = fs::read_dir(&extensions_root) else { return; }; for entry in entries.flatten() { let Ok(file_type) = entry.file_type() else { continue; }; if !file_type.is_dir() { continue; } let extension_id = entry.file_name().to_string_lossy().to_string(); let Some(version_path) = pick_latest_subdirectory(&entry.path()) else { continue; }; let manifest_path = version_path.join("manifest.json"); let Some(manifest) = read_json_file(&manifest_path) else { continue; }; let name = resolve_extension_name(&manifest, &version_path) .filter(|value| !value.is_empty()) .unwrap_or_else(|| extension_id.clone()); let version = manifest .get("version") .and_then(Value::as_str) .map(str::to_string); let icon_data_url = resolve_extension_icon(&manifest, &version_path); let entry = extensions .entry(extension_id.clone()) .or_insert_with(|| TempExtension { id: extension_id.clone(), name: name.clone(), version: version.clone(), icon_data_url: icon_data_url.clone(), profile_ids: BTreeSet::new(), }); if entry.name == entry.id && name != extension_id { entry.name = name.clone(); } if entry.version.is_none() { entry.version = version.clone(); } if entry.icon_data_url.is_none() { entry.icon_data_url = icon_data_url.clone(); } entry.profile_ids.insert(profile_id.to_string()); } } fn resolve_extension_name(manifest: &Value, version_path: &Path) -> Option { let raw_name = manifest.get("name").and_then(Value::as_str)?; if let Some(localized_name) = resolve_localized_manifest_value(raw_name, manifest, version_path) { return Some(localized_name); } manifest .get("short_name") .and_then(Value::as_str) .map(str::to_string) .or_else(|| Some(raw_name.to_string())) } fn resolve_localized_manifest_value( raw_value: &str, manifest: &Value, version_path: &Path, ) -> Option { if !(raw_value.starts_with("__MSG_") && raw_value.ends_with("__")) { return Some(raw_value.to_string()); } let message_key = raw_value .trim_start_matches("__MSG_") .trim_end_matches("__"); let default_locale = manifest .get("default_locale") .and_then(Value::as_str) .unwrap_or("en"); for locale in [default_locale, "en"] { let messages_path = version_path .join("_locales") .join(locale) .join("messages.json"); let Some(messages) = read_json_file(&messages_path) else { continue; }; if let Some(message) = messages .get(message_key) .and_then(|value| value.get("message")) .and_then(Value::as_str) .filter(|value| !value.is_empty()) { return Some(message.to_string()); } } Some(raw_value.to_string()) } fn resolve_extension_icon(manifest: &Value, version_path: &Path) -> Option { let mut candidates = Vec::new(); if let Some(icons) = manifest.get("icons").and_then(Value::as_object) { candidates.extend(icon_candidates_from_object(icons)); } for key in ["action", "browser_action", "page_action"] { if let Some(default_icon) = manifest .get(key) .and_then(|value| value.get("default_icon")) { if let Some(icon_path) = default_icon.as_str() { candidates.push((0, icon_path.to_string())); } else if let Some(icon_map) = default_icon.as_object() { candidates.extend(icon_candidates_from_object(icon_map)); } } } candidates.sort_by(|left, right| right.0.cmp(&left.0)); candidates .into_iter() .find_map(|(_, relative_path)| load_image_as_data_url(&version_path.join(relative_path))) } fn icon_candidates_from_object(map: &serde_json::Map) -> Vec<(u32, String)> { map.iter() .filter_map(|(size, value)| { value.as_str().map(|path| { let parsed_size = size.parse::().unwrap_or(0); (parsed_size, path.to_string()) }) }) .collect() } fn scan_bookmarks_for_profile( profile_path: &Path, profile_id: &str, bookmarks: &mut BTreeMap, ) { let bookmarks_path = profile_path.join("Bookmarks"); let Some(document) = read_json_file(&bookmarks_path) else { return; }; let Some(roots) = document.get("roots").and_then(Value::as_object) else { return; }; for root in roots.values() { collect_bookmarks(root, profile_id, bookmarks); } } fn collect_bookmarks( node: &Value, profile_id: &str, bookmarks: &mut BTreeMap, ) { match node.get("type").and_then(Value::as_str) { Some("url") => { let Some(url) = node.get("url").and_then(Value::as_str) else { return; }; if url.is_empty() { return; } let title = node .get("name") .and_then(Value::as_str) .filter(|value| !value.is_empty()) .unwrap_or(url) .to_string(); let entry = bookmarks .entry(url.to_string()) .or_insert_with(|| TempBookmark { url: url.to_string(), title: title.clone(), profile_ids: BTreeSet::new(), }); if entry.title == entry.url && title != url { entry.title = title; } entry.profile_ids.insert(profile_id.to_string()); } Some("folder") => { if let Some(children) = node.get("children").and_then(Value::as_array) { for child in children { collect_bookmarks(child, profile_id, bookmarks); } } } _ => {} } } fn pick_latest_subdirectory(root: &Path) -> Option { let entries = fs::read_dir(root).ok()?; let mut candidates = entries .flatten() .filter_map(|entry| { let file_type = entry.file_type().ok()?; if !file_type.is_dir() { return None; } let metadata = entry.metadata().ok()?; let modified = metadata.modified().ok(); Some(( modified, entry.file_name().to_string_lossy().to_string(), entry.path(), )) }) .collect::>(); candidates.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1))); candidates.into_iter().next().map(|(_, _, path)| path) } fn load_image_as_data_url(path: &Path) -> Option { let bytes = fs::read(path).ok()?; let extension = path .extension() .and_then(|value| value.to_str()) .map(|value| value.to_ascii_lowercase())?; let mime_type = match extension.as_str() { "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "webp" => "image/webp", "gif" => "image/gif", "svg" => "image/svg+xml", _ => return None, }; Some(format!( "data:{mime_type};base64,{}", STANDARD.encode(bytes) )) } fn read_json_file(path: &Path) -> Option { let content = fs::read_to_string(path).ok()?; serde_json::from_str(&content).ok() } fn first_non_empty<'a>(values: impl IntoIterator>) -> Option<&'a str> { values .into_iter() .flatten() .find(|value| !value.trim().is_empty()) } fn sort_profiles(mut profiles: Vec) -> Vec { profiles.sort_by(|left, right| profile_sort_key(&left.id).cmp(&profile_sort_key(&right.id))); profiles } fn profile_sort_key(profile_id: &str) -> (u8, u32, String) { if profile_id == "Default" { return (0, 0, profile_id.to_string()); } if let Some(number) = profile_id .strip_prefix("Profile ") .and_then(|value| value.parse::().ok()) { return (1, number, profile_id.to_string()); } (2, u32::MAX, profile_id.to_string()) } fn sort_extensions(mut extensions: Vec) -> Vec { extensions.sort_by(|left, right| { left.name .to_lowercase() .cmp(&right.name.to_lowercase()) .then_with(|| left.id.cmp(&right.id)) }); extensions } fn sort_bookmarks(mut bookmarks: Vec) -> Vec { bookmarks.sort_by(|left, right| { left.title .to_lowercase() .cmp(&right.title.to_lowercase()) .then_with(|| left.url.cmp(&right.url)) }); bookmarks }