use std::{ collections::{BTreeMap, BTreeSet}, fs, path::{Path, PathBuf}, }; use serde_json::Value; use tauri::AppHandle; use crate::{ config_store, models::{ AssociatedProfileSummary, BookmarkAssociatedProfileSummary, BookmarkSummary, BrowserConfigEntry, BrowserStats, BrowserView, ExtensionSummary, ProfileSummary, ScanResponse, TempBookmark, TempExtension, }, utils::{first_non_empty, load_image_as_data_url, pick_latest_subdirectory, read_json_file}, }; pub fn scan_browsers(app: &AppHandle) -> Result { let browsers = config_store::resolve_browser_configs(app)? .into_iter() .filter_map(scan_browser) .collect(); Ok(ScanResponse { browsers }) } fn scan_browser(config: BrowserConfigEntry) -> Option { let root = PathBuf::from(&config.user_data_path); 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)); let profile_summary = build_profile_summary( &root, &profile_path, &profile_id, profile_info, ); scan_extensions_for_profile(&profile_path, &profile_summary, &mut extensions); scan_bookmarks_for_profile(&profile_path, &profile_summary, &mut bookmarks); profiles.push(profile_summary); } 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(), profiles: entry.profiles.into_values().collect(), }) .collect::>(); let bookmarks = bookmarks .into_values() .map(|entry| BookmarkSummary { url: entry.url, title: entry.title, profile_ids: entry.profile_ids.into_iter().collect(), profiles: entry.profiles.into_values().collect(), }) .collect::>(); Some(BrowserView { browser_id: config.id, browser_family_id: config.browser_family_id, browser_name: config.name, icon_key: config.icon_key, 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_icon = profile_info .and_then(|value| value.get("avatar_icon")) .and_then(Value::as_str) .filter(|value| !value.is_empty()) .map(str::to_string); let default_avatar_fill_color = profile_info .and_then(|value| value.get("default_avatar_fill_color")) .and_then(Value::as_i64); let default_avatar_stroke_color = profile_info .and_then(|value| value.get("default_avatar_stroke_color")) .and_then(Value::as_i64); 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_icon, default_avatar_fill_color, default_avatar_stroke_color, 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: &ProfileSummary, 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(), profiles: BTreeMap::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.clone()); entry .profiles .entry(profile.id.clone()) .or_insert_with(|| AssociatedProfileSummary { id: profile.id.clone(), name: profile.name.clone(), avatar_data_url: profile.avatar_data_url.clone(), avatar_icon: profile.avatar_icon.clone(), default_avatar_fill_color: profile.default_avatar_fill_color, default_avatar_stroke_color: profile.default_avatar_stroke_color, avatar_label: profile.avatar_label.clone(), }); } } 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: &ProfileSummary, 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, bookmarks, &[]); } } fn collect_bookmarks( node: &Value, profile: &ProfileSummary, bookmarks: &mut BTreeMap, ancestors: &[String], ) { 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(), profiles: BTreeMap::new(), }); if entry.title == entry.url && title != url { entry.title = title; } entry.profile_ids.insert(profile.id.clone()); let bookmark_path = if ancestors.is_empty() { "Root".to_string() } else { ancestors.join(" > ") }; entry .profiles .entry(profile.id.clone()) .or_insert_with(|| BookmarkAssociatedProfileSummary { id: profile.id.clone(), name: profile.name.clone(), avatar_data_url: profile.avatar_data_url.clone(), avatar_icon: profile.avatar_icon.clone(), default_avatar_fill_color: profile.default_avatar_fill_color, default_avatar_stroke_color: profile.default_avatar_stroke_color, avatar_label: profile.avatar_label.clone(), bookmark_path, }); } Some("folder") => { if let Some(children) = node.get("children").and_then(Value::as_array) { let folder_name = node .get("name") .and_then(Value::as_str) .filter(|value| !value.is_empty()); let next_ancestors = if let Some(name) = folder_name { let mut path = ancestors.to_vec(); path.push(name.to_string()); path } else { ancestors.to_vec() }; for child in children { collect_bookmarks(child, profile, bookmarks, &next_ancestors); } } } _ => {} } } 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 }