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