This commit is contained in:
Julian Freeman
2026-04-16 10:39:53 -04:00
commit 6eb0b9bdf6
40 changed files with 8258 additions and 0 deletions

8
src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/target*/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

5464
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

26
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,26 @@
[package]
name = "chrom-tool"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "chrom_tool_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

608
src-tauri/src/lib.rs Normal file
View 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
View 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()
}

37
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,37 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "chrom-tool",
"version": "0.1.0",
"identifier": "top.volan.chrom-tool",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Browser Assistant",
"width": 1400,
"height": 900,
"minWidth": 1100,
"minHeight": 720
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}