503 lines
16 KiB
Rust
503 lines
16 KiB
Rust
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<ScanResponse, String> {
|
|
let browsers = config_store::resolve_browser_configs(app)?
|
|
.into_iter()
|
|
.filter_map(scan_browser)
|
|
.collect();
|
|
|
|
Ok(ScanResponse { browsers })
|
|
}
|
|
|
|
fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
|
|
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::<String, TempExtension>::new();
|
|
let mut bookmarks = BTreeMap::<String, TempBookmark>::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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
|
|
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<String>) {
|
|
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::<String>())
|
|
.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<String> {
|
|
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<String, TempExtension>,
|
|
) {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String, Value>) -> Vec<(u32, String)> {
|
|
map.iter()
|
|
.filter_map(|(size, value)| {
|
|
value.as_str().map(|path| {
|
|
let parsed_size = size.parse::<u32>().unwrap_or(0);
|
|
(parsed_size, path.to_string())
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn scan_bookmarks_for_profile(
|
|
profile_path: &Path,
|
|
profile: &ProfileSummary,
|
|
bookmarks: &mut BTreeMap<String, TempBookmark>,
|
|
) {
|
|
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<String, TempBookmark>,
|
|
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<ProfileSummary>) -> Vec<ProfileSummary> {
|
|
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::<u32>().ok())
|
|
{
|
|
return (1, number, profile_id.to_string());
|
|
}
|
|
|
|
(2, u32::MAX, profile_id.to_string())
|
|
}
|
|
|
|
fn sort_extensions(mut extensions: Vec<ExtensionSummary>) -> Vec<ExtensionSummary> {
|
|
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<BookmarkSummary>) -> Vec<BookmarkSummary> {
|
|
bookmarks.sort_by(|left, right| {
|
|
left.title
|
|
.to_lowercase()
|
|
.cmp(&right.title.to_lowercase())
|
|
.then_with(|| left.url.cmp(&right.url))
|
|
});
|
|
bookmarks
|
|
}
|