init
This commit is contained in:
608
src-tauri/src/lib.rs
Normal file
608
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,608 @@
|
||||
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
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
chrom_tool_lib::run()
|
||||
}
|
||||
Reference in New Issue
Block a user