609 lines
17 KiB
Rust
609 lines
17 KiB
Rust
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<BrowserView>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct BrowserView {
|
|
browser_id: String,
|
|
browser_name: String,
|
|
data_root: String,
|
|
profiles: Vec<ProfileSummary>,
|
|
extensions: Vec<ExtensionSummary>,
|
|
bookmarks: Vec<BookmarkSummary>,
|
|
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<String>,
|
|
avatar_data_url: Option<String>,
|
|
avatar_label: String,
|
|
path: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ExtensionSummary {
|
|
id: String,
|
|
name: String,
|
|
version: Option<String>,
|
|
icon_data_url: Option<String>,
|
|
profile_ids: Vec<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct BookmarkSummary {
|
|
url: String,
|
|
title: String,
|
|
profile_ids: Vec<String>,
|
|
}
|
|
|
|
struct BrowserDefinition {
|
|
id: &'static str,
|
|
name: &'static str,
|
|
local_app_data_segments: &'static [&'static str],
|
|
}
|
|
|
|
struct TempExtension {
|
|
id: String,
|
|
name: String,
|
|
version: Option<String>,
|
|
icon_data_url: Option<String>,
|
|
profile_ids: BTreeSet<String>,
|
|
}
|
|
|
|
struct TempBookmark {
|
|
url: String,
|
|
title: String,
|
|
profile_ids: BTreeSet<String>,
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn scan_browsers() -> Result<ScanResponse, String> {
|
|
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<BrowserDefinition> {
|
|
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<PathBuf> {
|
|
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<BrowserView> {
|
|
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::<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));
|
|
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::<Vec<_>>();
|
|
let bookmarks = bookmarks
|
|
.into_values()
|
|
.map(|entry| BookmarkSummary {
|
|
url: entry.url,
|
|
title: entry.title,
|
|
profile_ids: entry.profile_ids.into_iter().collect(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
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<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_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_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_id: &str,
|
|
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(),
|
|
});
|
|
|
|
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<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_id: &str,
|
|
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_id, bookmarks);
|
|
}
|
|
}
|
|
|
|
fn collect_bookmarks(
|
|
node: &Value,
|
|
profile_id: &str,
|
|
bookmarks: &mut BTreeMap<String, TempBookmark>,
|
|
) {
|
|
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<PathBuf> {
|
|
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::<Vec<_>>();
|
|
|
|
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<String> {
|
|
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<Value> {
|
|
let content = fs::read_to_string(path).ok()?;
|
|
serde_json::from_str(&content).ok()
|
|
}
|
|
|
|
fn first_non_empty<'a>(values: impl IntoIterator<Item = Option<&'a str>>) -> Option<&'a str> {
|
|
values
|
|
.into_iter()
|
|
.flatten()
|
|
.find(|value| !value.trim().is_empty())
|
|
}
|
|
|
|
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
|
|
}
|