diff --git a/public/tauri.svg b/public/tauri.svg deleted file mode 100644 index 31b62c9..0000000 --- a/public/tauri.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..52d1703 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,6 @@ +use crate::{models::ScanResponse, scanner}; + +#[tauri::command] +pub fn scan_browsers() -> Result { + scanner::scan_browsers() +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 685f04d..d5c610d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,608 +1,13 @@ -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 }) -} +mod commands; +mod models; +mod scanner; +mod utils; #[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]) + .invoke_handler(tauri::generate_handler![commands::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 -} diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs new file mode 100644 index 0000000..c36732c --- /dev/null +++ b/src-tauri/src/models.rs @@ -0,0 +1,78 @@ +use std::collections::BTreeSet; + +use serde::Serialize; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScanResponse { + pub browsers: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserView { + pub browser_id: String, + pub browser_name: String, + pub data_root: String, + pub profiles: Vec, + pub extensions: Vec, + pub bookmarks: Vec, + pub stats: BrowserStats, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserStats { + pub profile_count: usize, + pub extension_count: usize, + pub bookmark_count: usize, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileSummary { + pub id: String, + pub name: String, + pub email: Option, + pub avatar_data_url: Option, + pub avatar_label: String, + pub path: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionSummary { + pub id: String, + pub name: String, + pub version: Option, + pub icon_data_url: Option, + pub profile_ids: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BookmarkSummary { + pub url: String, + pub title: String, + pub profile_ids: Vec, +} + +pub struct BrowserDefinition { + pub id: &'static str, + pub name: &'static str, + pub local_app_data_segments: &'static [&'static str], +} + +pub struct TempExtension { + pub id: String, + pub name: String, + pub version: Option, + pub icon_data_url: Option, + pub profile_ids: BTreeSet, +} + +pub struct TempBookmark { + pub url: String, + pub title: String, + pub profile_ids: BTreeSet, +} diff --git a/src-tauri/src/scanner.rs b/src-tauri/src/scanner.rs new file mode 100644 index 0000000..2e66065 --- /dev/null +++ b/src-tauri/src/scanner.rs @@ -0,0 +1,468 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, +}; + +use serde_json::Value; + +use crate::{ + models::{ + BookmarkSummary, BrowserDefinition, BrowserStats, BrowserView, ExtensionSummary, + ProfileSummary, ScanResponse, TempBookmark, TempExtension, + }, + utils::{ + first_non_empty, load_image_as_data_url, local_app_data_dir, pick_latest_subdirectory, + read_json_file, + }, +}; + +pub 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 }) +} + +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 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 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 +} diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs new file mode 100644 index 0000000..903572a --- /dev/null +++ b/src-tauri/src/utils.rs @@ -0,0 +1,71 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde_json::Value; + +pub 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")) + }) +} + +pub 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) +} + +pub 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) + )) +} + +pub fn read_json_file(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +pub fn first_non_empty<'a>(values: impl IntoIterator>) -> Option<&'a str> { + values + .into_iter() + .flatten() + .find(|value| !value.trim().is_empty()) +} diff --git a/src/assets/vue.svg b/src/assets/vue.svg deleted file mode 100644 index 770e9d3..0000000 --- a/src/assets/vue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file