From 8a96d18e46de953458143ea7828b32b5589a37b6 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 2 Mar 2026 07:26:42 +0400 Subject: [PATCH] feat: extension management --- src-tauri/src/api_server.rs | 107 ++ src-tauri/src/app_dirs.rs | 5 + src-tauri/src/auto_updater.rs | 10 + src-tauri/src/bin/proxy_server.rs | 11 +- src-tauri/src/browser_runner.rs | 51 + src-tauri/src/ephemeral_dirs.rs | 2 + src-tauri/src/extension_manager.rs | 1185 +++++++++++++++++ src-tauri/src/lib.rs | 31 +- src-tauri/src/mcp_server.rs | 259 +++- src-tauri/src/profile/manager.rs | 74 +- src-tauri/src/profile/types.rs | 4 + src-tauri/src/profile_importer.rs | 2 + src-tauri/src/proxy_manager.rs | 8 + src-tauri/src/proxy_runner.rs | 8 +- src-tauri/src/proxy_server.rs | 79 +- src-tauri/src/proxy_storage.rs | 8 + src-tauri/src/sync/engine.rs | 503 +++++++ src-tauri/src/sync/mod.rs | 6 +- src-tauri/src/sync/scheduler.rs | 138 +- src-tauri/src/sync/subscription.rs | 22 + src-tauri/src/wayfern_manager.rs | 6 + src/app/page.tsx | 53 +- src/components/create-profile-dialog.tsx | 49 + .../extension-management-dialog.tsx | 716 ++++++++++ src/components/home-header.tsx | 19 +- src/components/profile-data-table.tsx | 63 +- src/components/profile-info-dialog.tsx | 121 +- src/hooks/use-extension-events.ts | 73 + src/i18n/locales/en.json | 52 +- src/i18n/locales/es.json | 52 +- src/i18n/locales/fr.json | 52 +- src/i18n/locales/ja.json | 52 +- src/i18n/locales/pt.json | 52 +- src/i18n/locales/ru.json | 52 +- src/i18n/locales/zh.json | 52 +- src/types.ts | 24 + 36 files changed, 3915 insertions(+), 86 deletions(-) create mode 100644 src-tauri/src/extension_manager.rs create mode 100644 src/components/extension-management-dialog.tsx create mode 100644 src/hooks/use-extension-events.ts diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 20ab6d5..a2ddb5b 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -78,6 +78,7 @@ pub struct UpdateProfileRequest { pub camoufox_config: Option, pub group_id: Option, pub tags: Option>, + pub extension_group_id: Option, } #[derive(Clone)] @@ -305,6 +306,10 @@ impl ApiServer { .routes(routes!(get_tags)) .routes(routes!(get_proxies, create_proxy)) .routes(routes!(get_proxy, update_proxy, delete_proxy)) + .routes(routes!(get_extensions)) + .routes(routes!(delete_extension_api)) + .routes(routes!(get_extension_groups)) + .routes(routes!(delete_extension_group_api)) .routes(routes!(download_browser_api)) .routes(routes!(get_browser_versions)) .routes(routes!(check_browser_downloaded)) @@ -737,6 +742,20 @@ async fn update_profile( } } + if let Some(extension_group_id) = request.extension_group_id { + let ext_group = if extension_group_id.is_empty() { + None + } else { + Some(extension_group_id) + }; + if profile_manager + .update_profile_extension_group(&id, ext_group) + .is_err() + { + return Err(StatusCode::BAD_REQUEST); + } + } + // Return updated profile get_profile(Path(id), State(state)).await } @@ -1142,6 +1161,94 @@ async fn delete_proxy( } } +// Extension API endpoints + +#[utoipa::path( + get, + path = "/v1/extensions", + responses( + (status = 200, description = "List of extensions"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])), + tag = "extensions" +)] +async fn get_extensions( + State(_state): State, +) -> Result>, StatusCode> { + let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + mgr + .list_extensions() + .map(Json) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +#[utoipa::path( + get, + path = "/v1/extension-groups", + responses( + (status = 200, description = "List of extension groups"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])), + tag = "extensions" +)] +async fn get_extension_groups( + State(_state): State, +) -> Result>, StatusCode> { + let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + mgr + .list_groups() + .map(Json) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +#[utoipa::path( + delete, + path = "/v1/extensions/{id}", + params(("id" = String, Path, description = "Extension ID")), + responses( + (status = 204, description = "Extension deleted"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Extension not found"), + ), + security(("bearer_auth" = [])), + tag = "extensions" +)] +async fn delete_extension_api( + Path(id): Path, + State(state): State, +) -> Result { + let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + mgr + .delete_extension(&state.app_handle, &id) + .map(|_| StatusCode::NO_CONTENT) + .map_err(|_| StatusCode::NOT_FOUND) +} + +#[utoipa::path( + delete, + path = "/v1/extension-groups/{id}", + params(("id" = String, Path, description = "Extension Group ID")), + responses( + (status = 204, description = "Extension group deleted"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Extension group not found"), + ), + security(("bearer_auth" = [])), + tag = "extensions" +)] +async fn delete_extension_group_api( + Path(id): Path, + State(state): State, +) -> Result { + let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + mgr + .delete_group(&state.app_handle, &id) + .map(|_| StatusCode::NO_CONTENT) + .map_err(|_| StatusCode::NOT_FOUND) +} + // API Handler - Run Profile with Remote Debugging #[utoipa::path( post, diff --git a/src-tauri/src/app_dirs.rs b/src-tauri/src/app_dirs.rs index 3cdb70d..2d41295 100644 --- a/src-tauri/src/app_dirs.rs +++ b/src-tauri/src/app_dirs.rs @@ -70,6 +70,10 @@ pub fn vpn_dir() -> PathBuf { data_dir().join("vpn") } +pub fn extensions_dir() -> PathBuf { + data_dir().join("extensions") +} + #[cfg(test)] thread_local! { static TEST_DATA_DIR: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; @@ -152,6 +156,7 @@ mod tests { assert!(settings_dir().ends_with("settings")); assert!(proxies_dir().ends_with("proxies")); assert!(vpn_dir().ends_with("vpn")); + assert!(extensions_dir().ends_with("extensions")); } #[test] diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 1b735e5..2a85e06 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -61,6 +61,10 @@ impl AutoUpdater { let mut browser_profiles: HashMap> = HashMap::new(); for profile in profiles { + if profile.is_cross_os() { + continue; + } + // Only check supported browsers if !self .browser_version_manager @@ -313,6 +317,10 @@ impl AutoUpdater { // Find all profiles for this browser that should be updated for profile in profiles { if profile.browser == browser { + if profile.is_cross_os() { + continue; + } + // Check if profile is currently running if profile.process_id.is_some() { continue; // Skip running profiles @@ -515,6 +523,8 @@ mod tests { last_sync: None, host_os: None, ephemeral: false, + extension_group_id: None, + proxy_bypass_rules: Vec::new(), } } diff --git a/src-tauri/src/bin/proxy_server.rs b/src-tauri/src/bin/proxy_server.rs index 5c6aaf5..cb2f363 100644 --- a/src-tauri/src/bin/proxy_server.rs +++ b/src-tauri/src/bin/proxy_server.rs @@ -147,6 +147,11 @@ async fn main() { Arg::new("profile-id") .long("profile-id") .help("ID of the profile this proxy is associated with"), + ) + .arg( + Arg::new("bypass-rules") + .long("bypass-rules") + .help("JSON array of bypass rules (hostnames, IPs, or regex patterns)"), ), ) .subcommand( @@ -217,8 +222,12 @@ async fn main() { let port = start_matches.get_one::("port").copied(); let profile_id = start_matches.get_one::("profile-id").cloned(); + let bypass_rules: Vec = start_matches + .get_one::("bypass-rules") + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); - match start_proxy_process_with_profile(upstream_url, port, profile_id).await { + match start_proxy_process_with_profile(upstream_url, port, profile_id, bypass_rules).await { Ok(config) => { // Output the configuration as JSON for the Rust side to parse // Use println! here because this needs to go to stdout for parsing diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index a424945..c6168c0 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -150,6 +150,7 @@ impl BrowserRunner { upstream_proxy.as_ref(), 0, // Use 0 as temporary PID, will be updated later Some(&profile_id_str), + profile.proxy_bypass_rules.clone(), ) .await .map_err(|e| { @@ -239,6 +240,31 @@ impl BrowserRunner { None }; + // Install extensions if an extension group is assigned + if updated_profile.extension_group_id.is_some() { + let profiles_dir = self.profile_manager.get_profiles_dir(); + let ext_profile_path = if let Some(ref override_path) = override_profile_path { + override_path.clone() + } else { + updated_profile.get_profile_data_path(&profiles_dir) + }; + let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + match mgr.install_extensions_for_profile(&updated_profile, &ext_profile_path) { + Ok(paths) => { + if !paths.is_empty() { + log::info!( + "Installed {} Firefox extensions for profile: {}", + paths.len(), + updated_profile.name + ); + } + } + Err(e) => { + log::warn!("Failed to install extensions for Camoufox profile: {e}"); + } + } + } + // Launch Camoufox browser log::info!("Launching Camoufox for profile: {}", profile.name); let camoufox_result = self @@ -390,6 +416,7 @@ impl BrowserRunner { upstream_proxy.as_ref(), 0, // Use 0 as temporary PID, will be updated later Some(&profile_id_str), + profile.proxy_bypass_rules.clone(), ) .await .map_err(|e| { @@ -467,6 +494,27 @@ impl BrowserRunner { crate::ephemeral_dirs::get_effective_profile_path(&updated_profile, &profiles_dir); let profile_path_str = profile_data_path.to_string_lossy().to_string(); + // Install extensions if an extension group is assigned + let mut extension_paths = Vec::new(); + if updated_profile.extension_group_id.is_some() { + let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + match mgr.install_extensions_for_profile(&updated_profile, &profile_data_path) { + Ok(paths) => { + if !paths.is_empty() { + log::info!( + "Prepared {} Chromium extensions for profile: {}", + paths.len(), + updated_profile.name + ); + } + extension_paths = paths; + } + Err(e) => { + log::warn!("Failed to install extensions for Wayfern profile: {e}"); + } + } + } + // Get proxy URL from config let proxy_url = wayfern_config.proxy.as_deref(); @@ -480,6 +528,7 @@ impl BrowserRunner { url.as_deref(), proxy_url, profile.ephemeral, + &extension_paths, ) .await .map_err(|e| -> Box { @@ -1052,6 +1101,7 @@ impl BrowserRunner { upstream_proxy.as_ref(), temp_pid, Some(&profile_id_str), + profile.proxy_bypass_rules.clone(), ) .await .map_err(|e| { @@ -2543,6 +2593,7 @@ pub async fn launch_browser_profile( upstream_proxy.as_ref(), temp_pid, Some(&profile_id_str), + profile_for_launch.proxy_bypass_rules.clone(), ) .await { diff --git a/src-tauri/src/ephemeral_dirs.rs b/src-tauri/src/ephemeral_dirs.rs index 094a248..ff65db3 100644 --- a/src-tauri/src/ephemeral_dirs.rs +++ b/src-tauri/src/ephemeral_dirs.rs @@ -273,6 +273,8 @@ mod tests { last_sync: None, host_os: None, ephemeral, + extension_group_id: None, + proxy_bypass_rules: Vec::new(), } } diff --git a/src-tauri/src/extension_manager.rs b/src-tauri/src/extension_manager.rs new file mode 100644 index 0000000..de68542 --- /dev/null +++ b/src-tauri/src/extension_manager.rs @@ -0,0 +1,1185 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::events; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Extension { + pub id: String, + pub name: String, + pub file_name: String, + pub file_type: String, + pub browser_compatibility: Vec, + pub created_at: u64, + pub updated_at: u64, + #[serde(default)] + pub sync_enabled: bool, + #[serde(default)] + pub last_sync: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtensionGroup { + pub id: String, + pub name: String, + pub extension_ids: Vec, + pub created_at: u64, + pub updated_at: u64, + #[serde(default)] + pub sync_enabled: bool, + #[serde(default)] + pub last_sync: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ExtensionGroupsData { + groups: Vec, +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn extensions_base_dir() -> PathBuf { + crate::app_dirs::extensions_dir() +} + +fn extension_groups_file() -> PathBuf { + crate::app_dirs::data_subdir().join("extension_groups.json") +} + +fn determine_browser_compatibility(file_type: &str) -> Vec { + match file_type { + "xpi" => vec!["firefox".to_string()], + "crx" => vec!["chromium".to_string()], + "zip" => vec!["chromium".to_string(), "firefox".to_string()], + _ => vec![], + } +} + +fn get_file_type(file_name: &str) -> Option { + let ext = file_name.rsplit('.').next()?.to_lowercase(); + match ext.as_str() { + "xpi" | "crx" | "zip" => Some(ext), + _ => None, + } +} + +pub struct ExtensionManager; + +impl ExtensionManager { + pub fn new() -> Self { + Self + } + + fn get_extension_dir(&self, ext_id: &str) -> PathBuf { + extensions_base_dir().join(ext_id) + } + + fn get_metadata_path(&self, ext_id: &str) -> PathBuf { + self.get_extension_dir(ext_id).join("metadata.json") + } + + fn get_file_dir(&self, ext_id: &str) -> PathBuf { + self.get_extension_dir(ext_id).join("file") + } + + pub fn get_file_dir_public(&self, ext_id: &str) -> PathBuf { + self.get_file_dir(ext_id) + } + + // Extension CRUD + + pub fn add_extension( + &self, + name: String, + file_name: String, + file_data: Vec, + ) -> Result> { + let file_type = + get_file_type(&file_name).ok_or_else(|| format!("Unsupported file type: {file_name}"))?; + + let browser_compatibility = determine_browser_compatibility(&file_type); + let now = now_secs(); + + let ext = Extension { + id: uuid::Uuid::new_v4().to_string(), + name, + file_name: file_name.clone(), + file_type, + browser_compatibility, + created_at: now, + updated_at: now, + sync_enabled: crate::sync::is_sync_configured(), + last_sync: None, + }; + + let file_dir = self.get_file_dir(&ext.id); + fs::create_dir_all(&file_dir)?; + fs::write(file_dir.join(&file_name), &file_data)?; + + let metadata_path = self.get_metadata_path(&ext.id); + let json = serde_json::to_string_pretty(&ext)?; + fs::write(metadata_path, json)?; + + if let Err(e) = events::emit_empty("extensions-changed") { + log::error!("Failed to emit extensions-changed event: {e}"); + } + + if ext.sync_enabled { + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let id = ext.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_extension_sync(id).await; + }); + } + } + + Ok(ext) + } + + pub fn get_extension(&self, id: &str) -> Result> { + let metadata_path = self.get_metadata_path(id); + if !metadata_path.exists() { + return Err(format!("Extension with id '{id}' not found").into()); + } + let content = fs::read_to_string(metadata_path)?; + let ext: Extension = serde_json::from_str(&content)?; + Ok(ext) + } + + pub fn list_extensions(&self) -> Result, Box> { + let base = extensions_base_dir(); + if !base.exists() { + return Ok(Vec::new()); + } + + let mut extensions = Vec::new(); + for entry in fs::read_dir(base)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + let metadata_path = entry.path().join("metadata.json"); + if metadata_path.exists() { + let content = fs::read_to_string(&metadata_path)?; + if let Ok(ext) = serde_json::from_str::(&content) { + extensions.push(ext); + } + } + } + } + + extensions.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + Ok(extensions) + } + + pub fn update_extension( + &self, + id: &str, + name: Option, + file_name: Option, + file_data: Option>, + ) -> Result> { + let mut ext = self.get_extension(id)?; + + if let Some(new_name) = name { + ext.name = new_name; + } + + if let (Some(new_file_name), Some(data)) = (file_name, file_data) { + let new_file_type = get_file_type(&new_file_name) + .ok_or_else(|| format!("Unsupported file type: {new_file_name}"))?; + + // Remove old file + let file_dir = self.get_file_dir(id); + if file_dir.exists() { + fs::remove_dir_all(&file_dir)?; + } + fs::create_dir_all(&file_dir)?; + fs::write(file_dir.join(&new_file_name), &data)?; + + ext.file_name = new_file_name; + ext.file_type = new_file_type.clone(); + ext.browser_compatibility = determine_browser_compatibility(&new_file_type); + } + + ext.updated_at = now_secs(); + + let metadata_path = self.get_metadata_path(id); + let json = serde_json::to_string_pretty(&ext)?; + fs::write(metadata_path, json)?; + + if let Err(e) = events::emit_empty("extensions-changed") { + log::error!("Failed to emit extensions-changed event: {e}"); + } + + if ext.sync_enabled { + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let eid = ext.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_extension_sync(eid).await; + }); + } + } + + Ok(ext) + } + + pub fn delete_extension( + &self, + app_handle: &tauri::AppHandle, + id: &str, + ) -> Result<(), Box> { + let ext = self.get_extension(id)?; + let ext_dir = self.get_extension_dir(id); + if ext_dir.exists() { + fs::remove_dir_all(&ext_dir)?; + } + + // Remove from all groups + let mut groups_data = self.load_groups_data()?; + for group in &mut groups_data.groups { + group.extension_ids.retain(|eid| eid != id); + } + self.save_groups_data(&groups_data)?; + + if let Err(e) = events::emit_empty("extensions-changed") { + log::error!("Failed to emit extensions-changed event: {e}"); + } + + if ext.sync_enabled { + let ext_id = id.to_string(); + let app_handle_clone = app_handle.clone(); + tauri::async_runtime::spawn(async move { + match crate::sync::SyncEngine::create_from_settings(&app_handle_clone).await { + Ok(engine) => { + if let Err(e) = engine.delete_extension(&ext_id).await { + log::warn!("Failed to delete extension {} from sync: {}", ext_id, e); + } + } + Err(e) => { + log::debug!("Sync not configured, skipping remote deletion: {}", e); + } + } + }); + } + + Ok(()) + } + + // Extension Group CRUD + + fn load_groups_data(&self) -> Result> { + let path = extension_groups_file(); + if !path.exists() { + return Ok(ExtensionGroupsData { groups: Vec::new() }); + } + let content = fs::read_to_string(path)?; + let data: ExtensionGroupsData = serde_json::from_str(&content)?; + Ok(data) + } + + fn save_groups_data(&self, data: &ExtensionGroupsData) -> Result<(), Box> { + let path = extension_groups_file(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(data)?; + fs::write(path, json)?; + Ok(()) + } + + pub fn create_group(&self, name: String) -> Result> { + let mut data = self.load_groups_data()?; + + if data.groups.iter().any(|g| g.name == name) { + return Err(format!("Extension group with name '{name}' already exists").into()); + } + + let now = now_secs(); + let group = ExtensionGroup { + id: uuid::Uuid::new_v4().to_string(), + name, + extension_ids: Vec::new(), + created_at: now, + updated_at: now, + sync_enabled: crate::sync::is_sync_configured(), + last_sync: None, + }; + + data.groups.push(group.clone()); + self.save_groups_data(&data)?; + + if let Err(e) = events::emit_empty("extensions-changed") { + log::error!("Failed to emit extensions-changed event: {e}"); + } + + if group.sync_enabled { + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let id = group.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_extension_group_sync(id).await; + }); + } + } + + Ok(group) + } + + pub fn get_group(&self, id: &str) -> Result> { + let data = self.load_groups_data()?; + data + .groups + .into_iter() + .find(|g| g.id == id) + .ok_or_else(|| format!("Extension group with id '{id}' not found").into()) + } + + pub fn list_groups(&self) -> Result, Box> { + let data = self.load_groups_data()?; + Ok(data.groups) + } + + pub fn update_group( + &self, + id: &str, + name: Option, + extension_ids: Option>, + ) -> Result> { + let mut data = self.load_groups_data()?; + + if let Some(ref new_name) = name { + if data + .groups + .iter() + .any(|g| g.name == *new_name && g.id != id) + { + return Err(format!("Extension group with name '{new_name}' already exists").into()); + } + } + + let group = data + .groups + .iter_mut() + .find(|g| g.id == id) + .ok_or_else(|| format!("Extension group with id '{id}' not found"))?; + + if let Some(new_name) = name { + group.name = new_name; + } + if let Some(new_ids) = extension_ids { + group.extension_ids = new_ids; + } + group.updated_at = now_secs(); + + let updated = group.clone(); + self.save_groups_data(&data)?; + + if let Err(e) = events::emit_empty("extensions-changed") { + log::error!("Failed to emit extensions-changed event: {e}"); + } + + if updated.sync_enabled { + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let gid = updated.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_extension_group_sync(gid).await; + }); + } + } + + Ok(updated) + } + + pub fn delete_group( + &self, + app_handle: &tauri::AppHandle, + id: &str, + ) -> Result<(), Box> { + let mut data = self.load_groups_data()?; + + let was_sync_enabled = data + .groups + .iter() + .find(|g| g.id == id) + .map(|g| g.sync_enabled) + .unwrap_or(false); + + let initial_len = data.groups.len(); + data.groups.retain(|g| g.id != id); + if data.groups.len() == initial_len { + return Err(format!("Extension group with id '{id}' not found").into()); + } + self.save_groups_data(&data)?; + + // Clear extension_group_id from profiles that used this group + let profile_manager = crate::profile::ProfileManager::instance(); + if let Ok(profiles) = profile_manager.list_profiles() { + for mut p in profiles { + if p.extension_group_id.as_deref() == Some(id) { + p.extension_group_id = None; + let _ = profile_manager.save_profile(&p); + } + } + } + + if was_sync_enabled { + let group_id_owned = id.to_string(); + let app_handle_clone = app_handle.clone(); + tauri::async_runtime::spawn(async move { + match crate::sync::SyncEngine::create_from_settings(&app_handle_clone).await { + Ok(engine) => { + if let Err(e) = engine.delete_extension_group(&group_id_owned).await { + log::warn!( + "Failed to delete extension group {} from sync: {}", + group_id_owned, + e + ); + } + } + Err(e) => { + log::debug!("Sync not configured, skipping remote deletion: {}", e); + } + } + }); + } + + if let Err(e) = events::emit_empty("extensions-changed") { + log::error!("Failed to emit extensions-changed event: {e}"); + } + + Ok(()) + } + + pub fn add_extension_to_group( + &self, + group_id: &str, + extension_id: &str, + ) -> Result> { + // Verify extension exists + let _ = self.get_extension(extension_id)?; + + let mut data = self.load_groups_data()?; + let group = data + .groups + .iter_mut() + .find(|g| g.id == group_id) + .ok_or_else(|| format!("Extension group with id '{group_id}' not found"))?; + + if !group.extension_ids.contains(&extension_id.to_string()) { + group.extension_ids.push(extension_id.to_string()); + group.updated_at = now_secs(); + } + + let updated = group.clone(); + self.save_groups_data(&data)?; + + if let Err(e) = events::emit_empty("extensions-changed") { + log::error!("Failed to emit extensions-changed event: {e}"); + } + + if updated.sync_enabled { + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let gid = updated.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_extension_group_sync(gid).await; + }); + } + } + + Ok(updated) + } + + pub fn remove_extension_from_group( + &self, + group_id: &str, + extension_id: &str, + ) -> Result> { + let mut data = self.load_groups_data()?; + let group = data + .groups + .iter_mut() + .find(|g| g.id == group_id) + .ok_or_else(|| format!("Extension group with id '{group_id}' not found"))?; + + group.extension_ids.retain(|eid| eid != extension_id); + group.updated_at = now_secs(); + + let updated = group.clone(); + self.save_groups_data(&data)?; + + if let Err(e) = events::emit_empty("extensions-changed") { + log::error!("Failed to emit extensions-changed event: {e}"); + } + + if updated.sync_enabled { + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let gid = updated.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_extension_group_sync(gid).await; + }); + } + } + + Ok(updated) + } + + // Sync helpers + + pub fn update_extension_internal( + &self, + ext: &Extension, + ) -> Result<(), Box> { + let metadata_path = self.get_metadata_path(&ext.id); + if let Some(parent) = metadata_path.parent() { + fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(ext)?; + fs::write(metadata_path, json)?; + Ok(()) + } + + pub fn upsert_extension_internal( + &self, + ext: &Extension, + ) -> Result<(), Box> { + self.update_extension_internal(ext) + } + + pub fn delete_extension_internal(&self, id: &str) -> Result<(), Box> { + let ext_dir = self.get_extension_dir(id); + if ext_dir.exists() { + fs::remove_dir_all(&ext_dir)?; + } + // Remove from all groups + let mut groups_data = self.load_groups_data()?; + for group in &mut groups_data.groups { + group.extension_ids.retain(|eid| eid != id); + } + self.save_groups_data(&groups_data)?; + Ok(()) + } + + pub fn update_group_internal( + &self, + group: &ExtensionGroup, + ) -> Result<(), Box> { + let mut data = self.load_groups_data()?; + if let Some(existing) = data.groups.iter_mut().find(|g| g.id == group.id) { + existing.name = group.name.clone(); + existing.extension_ids = group.extension_ids.clone(); + existing.sync_enabled = group.sync_enabled; + existing.last_sync = group.last_sync; + existing.updated_at = group.updated_at; + self.save_groups_data(&data)?; + } + Ok(()) + } + + pub fn upsert_group_internal( + &self, + group: &ExtensionGroup, + ) -> Result<(), Box> { + let mut data = self.load_groups_data()?; + if let Some(existing) = data.groups.iter_mut().find(|g| g.id == group.id) { + existing.name = group.name.clone(); + existing.extension_ids = group.extension_ids.clone(); + existing.sync_enabled = group.sync_enabled; + existing.last_sync = group.last_sync; + existing.updated_at = group.updated_at; + } else { + data.groups.push(group.clone()); + } + self.save_groups_data(&data)?; + Ok(()) + } + + pub fn delete_group_internal(&self, id: &str) -> Result<(), Box> { + let mut data = self.load_groups_data()?; + data.groups.retain(|g| g.id != id); + self.save_groups_data(&data)?; + Ok(()) + } + + // Compatibility validation + + pub fn validate_group_compatibility( + &self, + group_id: &str, + browser: &str, + ) -> Result<(), Box> { + let group = self.get_group(group_id)?; + let browser_type = match browser { + "camoufox" | "firefox" | "firefox-developer" | "zen" => "firefox", + "wayfern" | "chromium" | "brave" => "chromium", + _ => return Err(format!("Extensions are not supported for browser '{browser}'").into()), + }; + + for ext_id in &group.extension_ids { + let ext = self.get_extension(ext_id)?; + if !ext + .browser_compatibility + .contains(&browser_type.to_string()) + { + return Err( + format!( + "Extension '{}' ({}) is not compatible with {} browsers", + ext.name, ext.file_type, browser_type + ) + .into(), + ); + } + } + + Ok(()) + } + + // Launch-time installation + + pub fn install_extensions_for_profile( + &self, + profile: &crate::profile::BrowserProfile, + profile_data_path: &std::path::Path, + ) -> Result, Box> { + let group_id = match &profile.extension_group_id { + Some(id) => id, + None => return Ok(Vec::new()), + }; + + let group = self.get_group(group_id)?; + if group.extension_ids.is_empty() { + return Ok(Vec::new()); + } + + let browser_type = match profile.browser.as_str() { + "camoufox" | "firefox" | "firefox-developer" | "zen" => "firefox", + "wayfern" | "chromium" | "brave" => "chromium", + _ => return Ok(Vec::new()), + }; + + let mut extension_paths = Vec::new(); + + match browser_type { + "firefox" => { + let extensions_dir = profile_data_path.join("extensions"); + // Clear existing extensions + if extensions_dir.exists() { + fs::remove_dir_all(&extensions_dir)?; + } + fs::create_dir_all(&extensions_dir)?; + + for ext_id in &group.extension_ids { + if let Ok(ext) = self.get_extension(ext_id) { + if !ext.browser_compatibility.contains(&"firefox".to_string()) { + continue; + } + let src_file = self.get_file_dir(ext_id).join(&ext.file_name); + if src_file.exists() { + // Firefox expects .xpi files in extensions dir + let dest_name = if ext.file_type == "zip" { + format!( + "{}.xpi", + ext + .file_name + .rsplit('.') + .next_back() + .unwrap_or(&ext.file_name) + ) + } else { + ext.file_name.clone() + }; + let dest = extensions_dir.join(&dest_name); + fs::copy(&src_file, &dest)?; + extension_paths.push(dest.to_string_lossy().to_string()); + } + } + } + } + "chromium" => { + // For Chromium, unpack extensions and return paths for --load-extension + let unpacked_base = extensions_base_dir().join("unpacked"); + if unpacked_base.exists() { + fs::remove_dir_all(&unpacked_base)?; + } + fs::create_dir_all(&unpacked_base)?; + + for ext_id in &group.extension_ids { + if let Ok(ext) = self.get_extension(ext_id) { + if !ext.browser_compatibility.contains(&"chromium".to_string()) { + continue; + } + let src_file = self.get_file_dir(ext_id).join(&ext.file_name); + if src_file.exists() { + let unpack_dir = unpacked_base.join(ext_id); + fs::create_dir_all(&unpack_dir)?; + + // Extract .crx or .zip + match Self::unpack_extension(&src_file, &unpack_dir) { + Ok(()) => { + extension_paths.push(unpack_dir.to_string_lossy().to_string()); + } + Err(e) => { + log::warn!("Failed to unpack extension '{}': {}", ext.name, e); + } + } + } + } + } + } + _ => {} + } + + Ok(extension_paths) + } + + fn unpack_extension( + src: &std::path::Path, + dest: &std::path::Path, + ) -> Result<(), Box> { + let data = fs::read(src)?; + let mut archive = match zip::ZipArchive::new(std::io::Cursor::new(data.as_slice())) { + Ok(a) => a, + Err(e) => { + // CRX files have a header before the ZIP data — try skipping the CRX header + if let Some(zip_start) = Self::find_zip_start(&data) { + zip::ZipArchive::new(std::io::Cursor::new(&data[zip_start..])) + .map_err(|e2| format!("Failed to open CRX as zip after header skip: {e2}"))? + } else { + return Err(format!("Failed to open as zip: {e}").into()); + } + } + }; + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + let out_path = dest.join(file.mangled_name()); + + if file.is_dir() { + fs::create_dir_all(&out_path)?; + } else { + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent)?; + } + let mut out_file = fs::File::create(&out_path)?; + std::io::copy(&mut file, &mut out_file)?; + } + } + + Ok(()) + } + + fn find_zip_start(data: &[u8]) -> Option { + // ZIP local file header magic: PK\x03\x04 + let magic = [0x50, 0x4B, 0x03, 0x04]; + data.windows(4).position(|window| window == magic) + } +} + +// Global instance +lazy_static::lazy_static! { + pub static ref EXTENSION_MANAGER: Mutex = Mutex::new(ExtensionManager::new()); +} + +// Tauri commands + +#[tauri::command] +pub async fn list_extensions() -> Result, String> { + if !crate::cloud_auth::CLOUD_AUTH + .has_active_paid_subscription() + .await + { + return Err("Extension management requires an active Pro subscription".to_string()); + } + let mgr = EXTENSION_MANAGER.lock().unwrap(); + mgr + .list_extensions() + .map_err(|e| format!("Failed to list extensions: {e}")) +} + +#[tauri::command] +pub async fn add_extension( + name: String, + file_name: String, + file_data: Vec, +) -> Result { + if !crate::cloud_auth::CLOUD_AUTH + .has_active_paid_subscription() + .await + { + return Err("Extension management requires an active Pro subscription".to_string()); + } + let mgr = EXTENSION_MANAGER.lock().unwrap(); + mgr + .add_extension(name, file_name, file_data) + .map_err(|e| format!("Failed to add extension: {e}")) +} + +#[tauri::command] +pub async fn update_extension( + extension_id: String, + name: Option, + file_name: Option, + file_data: Option>, +) -> Result { + if !crate::cloud_auth::CLOUD_AUTH + .has_active_paid_subscription() + .await + { + return Err("Extension management requires an active Pro subscription".to_string()); + } + let mgr = EXTENSION_MANAGER.lock().unwrap(); + mgr + .update_extension(&extension_id, name, file_name, file_data) + .map_err(|e| format!("Failed to update extension: {e}")) +} + +#[tauri::command] +pub async fn delete_extension( + app_handle: tauri::AppHandle, + extension_id: String, +) -> Result<(), String> { + if !crate::cloud_auth::CLOUD_AUTH + .has_active_paid_subscription() + .await + { + return Err("Extension management requires an active Pro subscription".to_string()); + } + let mgr = EXTENSION_MANAGER.lock().unwrap(); + mgr + .delete_extension(&app_handle, &extension_id) + .map_err(|e| format!("Failed to delete extension: {e}")) +} + +#[tauri::command] +pub async fn list_extension_groups() -> Result, String> { + if !crate::cloud_auth::CLOUD_AUTH + .has_active_paid_subscription() + .await + { + return Err("Extension management requires an active Pro subscription".to_string()); + } + let mgr = EXTENSION_MANAGER.lock().unwrap(); + mgr + .list_groups() + .map_err(|e| format!("Failed to list extension groups: {e}")) +} + +#[tauri::command] +pub async fn create_extension_group(name: String) -> Result { + if !crate::cloud_auth::CLOUD_AUTH + .has_active_paid_subscription() + .await + { + return Err("Extension management requires an active Pro subscription".to_string()); + } + let mgr = EXTENSION_MANAGER.lock().unwrap(); + mgr + .create_group(name) + .map_err(|e| format!("Failed to create extension group: {e}")) +} + +#[tauri::command] +pub async fn update_extension_group( + group_id: String, + name: Option, + extension_ids: Option>, +) -> Result { + if !crate::cloud_auth::CLOUD_AUTH + .has_active_paid_subscription() + .await + { + return Err("Extension management requires an active Pro subscription".to_string()); + } + let mgr = EXTENSION_MANAGER.lock().unwrap(); + mgr + .update_group(&group_id, name, extension_ids) + .map_err(|e| format!("Failed to update extension group: {e}")) +} + +#[tauri::command] +pub async fn delete_extension_group( + app_handle: tauri::AppHandle, + group_id: String, +) -> Result<(), String> { + if !crate::cloud_auth::CLOUD_AUTH + .has_active_paid_subscription() + .await + { + return Err("Extension management requires an active Pro subscription".to_string()); + } + let mgr = EXTENSION_MANAGER.lock().unwrap(); + mgr + .delete_group(&app_handle, &group_id) + .map_err(|e| format!("Failed to delete extension group: {e}")) +} + +#[tauri::command] +pub async fn add_extension_to_group( + group_id: String, + extension_id: String, +) -> Result { + if !crate::cloud_auth::CLOUD_AUTH + .has_active_paid_subscription() + .await + { + return Err("Extension management requires an active Pro subscription".to_string()); + } + let mgr = EXTENSION_MANAGER.lock().unwrap(); + mgr + .add_extension_to_group(&group_id, &extension_id) + .map_err(|e| format!("Failed to add extension to group: {e}")) +} + +#[tauri::command] +pub async fn remove_extension_from_group( + group_id: String, + extension_id: String, +) -> Result { + if !crate::cloud_auth::CLOUD_AUTH + .has_active_paid_subscription() + .await + { + return Err("Extension management requires an active Pro subscription".to_string()); + } + let mgr = EXTENSION_MANAGER.lock().unwrap(); + mgr + .remove_extension_from_group(&group_id, &extension_id) + .map_err(|e| format!("Failed to remove extension from group: {e}")) +} + +#[tauri::command] +pub async fn assign_extension_group_to_profile( + profile_id: String, + extension_group_id: Option, +) -> Result { + if !crate::cloud_auth::CLOUD_AUTH + .has_active_paid_subscription() + .await + { + return Err("Extension management requires an active Pro subscription".to_string()); + } + + // Validate compatibility if assigning a group + if let Some(ref group_id) = extension_group_id { + let profile_manager = crate::profile::ProfileManager::instance(); + let profiles = profile_manager + .list_profiles() + .map_err(|e| format!("Failed to list profiles: {e}"))?; + let profile = profiles + .iter() + .find(|p| p.id.to_string() == profile_id) + .ok_or_else(|| format!("Profile '{profile_id}' not found"))?; + + let mgr = EXTENSION_MANAGER.lock().unwrap(); + mgr + .validate_group_compatibility(group_id, &profile.browser) + .map_err(|e| format!("{e}"))?; + } + + let profile_manager = crate::profile::ProfileManager::instance(); + profile_manager + .update_profile_extension_group(&profile_id, extension_group_id) + .map_err(|e| format!("Failed to assign extension group: {e}")) +} + +#[tauri::command] +pub async fn get_extension_group_for_profile( + profile_id: String, +) -> Result, String> { + let profile_manager = crate::profile::ProfileManager::instance(); + let profiles = profile_manager + .list_profiles() + .map_err(|e| format!("Failed to list profiles: {e}"))?; + let profile = profiles + .iter() + .find(|p| p.id.to_string() == profile_id) + .ok_or_else(|| format!("Profile '{profile_id}' not found"))?; + + match &profile.extension_group_id { + Some(group_id) => { + let mgr = EXTENSION_MANAGER.lock().unwrap(); + match mgr.get_group(group_id) { + Ok(group) => Ok(Some(group)), + Err(_) => Ok(None), + } + } + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_file_type() { + assert_eq!(get_file_type("ublock.xpi"), Some("xpi".to_string())); + assert_eq!(get_file_type("ext.crx"), Some("crx".to_string())); + assert_eq!(get_file_type("ext.zip"), Some("zip".to_string())); + assert_eq!(get_file_type("readme.txt"), None); + assert_eq!(get_file_type("noext"), None); + } + + #[test] + fn test_determine_browser_compatibility() { + assert_eq!( + determine_browser_compatibility("xpi"), + vec!["firefox".to_string()] + ); + assert_eq!( + determine_browser_compatibility("crx"), + vec!["chromium".to_string()] + ); + assert_eq!( + determine_browser_compatibility("zip"), + vec!["chromium".to_string(), "firefox".to_string()] + ); + } + + #[test] + fn test_extension_manager_crud() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(tmp.path().to_path_buf()); + + let mgr = ExtensionManager::new(); + + // List empty + let exts = mgr.list_extensions().unwrap(); + assert!(exts.is_empty()); + + // Add + let ext = mgr + .add_extension( + "Test Ext".to_string(), + "test.xpi".to_string(), + vec![0, 1, 2, 3], + ) + .unwrap(); + assert_eq!(ext.name, "Test Ext"); + assert_eq!(ext.file_type, "xpi"); + assert_eq!(ext.browser_compatibility, vec!["firefox".to_string()]); + + // Get + let fetched = mgr.get_extension(&ext.id).unwrap(); + assert_eq!(fetched.name, "Test Ext"); + + // List + let exts = mgr.list_extensions().unwrap(); + assert_eq!(exts.len(), 1); + + // Update name + let updated = mgr + .update_extension(&ext.id, Some("Updated".to_string()), None, None) + .unwrap(); + assert_eq!(updated.name, "Updated"); + + // Delete + mgr.delete_extension_internal(&ext.id).unwrap(); + let exts = mgr.list_extensions().unwrap(); + assert!(exts.is_empty()); + } + + #[test] + fn test_extension_group_crud() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(tmp.path().to_path_buf()); + + let mgr = ExtensionManager::new(); + + // Create group + let group = mgr.create_group("My Group".to_string()).unwrap(); + assert_eq!(group.name, "My Group"); + assert!(group.extension_ids.is_empty()); + + // List groups + let groups = mgr.list_groups().unwrap(); + assert_eq!(groups.len(), 1); + + // Add extension + let ext = mgr + .add_extension( + "Test Ext".to_string(), + "test.xpi".to_string(), + vec![0, 1, 2, 3], + ) + .unwrap(); + + // Add to group + let updated = mgr.add_extension_to_group(&group.id, &ext.id).unwrap(); + assert_eq!(updated.extension_ids.len(), 1); + + // Remove from group + let updated = mgr.remove_extension_from_group(&group.id, &ext.id).unwrap(); + assert!(updated.extension_ids.is_empty()); + + // Duplicate name check + let err = mgr.create_group("My Group".to_string()); + assert!(err.is_err()); + } + + #[test] + fn test_validate_group_compatibility() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(tmp.path().to_path_buf()); + + let mgr = ExtensionManager::new(); + + let ext = mgr + .add_extension( + "Firefox Ext".to_string(), + "test.xpi".to_string(), + vec![0, 1, 2, 3], + ) + .unwrap(); + + let group = mgr.create_group("Firefox Group".to_string()).unwrap(); + mgr.add_extension_to_group(&group.id, &ext.id).unwrap(); + + // Compatible with camoufox (firefox-based) + assert!(mgr + .validate_group_compatibility(&group.id, "camoufox") + .is_ok()); + + // Incompatible with wayfern (chromium-based) + assert!(mgr + .validate_group_compatibility(&group.id, "wayfern") + .is_err()); + } + + #[test] + fn test_find_zip_start() { + let data = vec![0x00, 0x00, 0x50, 0x4B, 0x03, 0x04, 0xFF]; + assert_eq!(ExtensionManager::find_zip_start(&data), Some(2)); + + let data = vec![0x50, 0x4B, 0x03, 0x04, 0xFF]; + assert_eq!(ExtensionManager::find_zip_start(&data), Some(0)); + + let data = vec![0x00, 0x00, 0x00]; + assert_eq!(ExtensionManager::find_zip_start(&data), None); + } + + #[test] + fn test_delete_extension_removes_from_groups() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(tmp.path().to_path_buf()); + + let mgr = ExtensionManager::new(); + + let ext = mgr + .add_extension("Test".to_string(), "test.xpi".to_string(), vec![0, 1, 2, 3]) + .unwrap(); + + let group = mgr.create_group("G1".to_string()).unwrap(); + mgr.add_extension_to_group(&group.id, &ext.id).unwrap(); + + // Delete extension should remove from group + mgr.delete_extension_internal(&ext.id).unwrap(); + + let updated_group = mgr.get_group(&group.id).unwrap(); + assert!(updated_group.extension_ids.is_empty()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f78f63f..9715d9d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,6 +22,7 @@ mod default_browser; mod downloaded_browsers_registry; mod downloader; mod ephemeral_dirs; +mod extension_manager; mod extraction; mod geoip_downloader; mod group_manager; @@ -61,7 +62,8 @@ use browser_runner::{ use profile::manager::{ check_browser_status, clone_profile, create_browser_profile_new, delete_profile, list_browser_profiles, rename_profile, update_camoufox_config, update_profile_note, - update_profile_proxy, update_profile_tags, update_profile_vpn, update_wayfern_config, + update_profile_proxy, update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn, + update_wayfern_config, }; use browser_version_manager::{ @@ -87,7 +89,8 @@ use settings_manager::{ use sync::{ check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities, get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile, - is_vpn_in_use_by_synced_profile, request_profile_sync, set_e2e_password, set_group_sync_enabled, + is_vpn_in_use_by_synced_profile, request_profile_sync, set_e2e_password, + set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, }; @@ -111,6 +114,12 @@ use app_auto_updater::{ use profile_importer::{detect_existing_profiles, import_browser_profile}; +use extension_manager::{ + add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group, + delete_extension, delete_extension_group, get_extension_group_for_profile, list_extension_groups, + list_extensions, remove_extension_from_group, update_extension, update_extension_group, +}; + use group_manager::{ assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles, get_groups_with_profile_counts, get_profile_groups, update_profile_group, @@ -1331,6 +1340,7 @@ pub fn run() { update_profile_vpn, update_profile_tags, update_profile_note, + update_profile_proxy_bypass_rules, check_browser_status, kill_browser_profile, rename_profile, @@ -1382,6 +1392,18 @@ pub fn run() { delete_profile_group, assign_profiles_to_group, delete_selected_profiles, + list_extensions, + add_extension, + update_extension, + delete_extension, + list_extension_groups, + create_extension_group, + update_extension_group, + delete_extension_group, + add_extension_to_group, + remove_extension_from_group, + assign_extension_group_to_profile, + get_extension_group_for_profile, is_geoip_database_available, download_geoip_database, start_api_server, @@ -1400,6 +1422,8 @@ pub fn run() { is_group_in_use_by_synced_profile, set_vpn_sync_enabled, is_vpn_in_use_by_synced_profile, + set_extension_sync_enabled, + set_extension_group_sync_enabled, get_unsynced_entity_counts, enable_sync_for_all_entities, set_e2e_password, @@ -1482,6 +1506,9 @@ mod tests { "get_vpn_config", "list_active_vpn_connections", "export_profile_cookies", + "update_extension", + "set_extension_sync_enabled", + "set_extension_group_sync_enabled", ]; // Extract command names from the generate_handler! macro in this file diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 172b52f..2086d46 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -725,6 +725,69 @@ impl McpServer { "required": ["profile_id"] }), }, + McpTool { + name: "list_extensions".to_string(), + description: "List all managed browser extensions. Requires Pro subscription.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + McpTool { + name: "list_extension_groups".to_string(), + description: "List all extension groups. Requires Pro subscription.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + McpTool { + name: "create_extension_group".to_string(), + description: "Create a new extension group. Requires Pro subscription.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "name": { "type": "string", "description": "Name for the extension group" } + }, + "required": ["name"] + }), + }, + McpTool { + name: "delete_extension".to_string(), + description: "Delete a managed extension. Requires Pro subscription.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "extension_id": { "type": "string", "description": "The extension ID to delete" } + }, + "required": ["extension_id"] + }), + }, + McpTool { + name: "delete_extension_group".to_string(), + description: "Delete an extension group. Requires Pro subscription.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "group_id": { "type": "string", "description": "The extension group ID to delete" } + }, + "required": ["group_id"] + }), + }, + McpTool { + name: "assign_extension_group_to_profile".to_string(), + description: "Assign an extension group to a profile, or remove the assignment. Requires Pro subscription.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { "type": "string", "description": "The profile ID" }, + "extension_group_id": { "type": "string", "description": "The extension group ID, or empty string to remove" } + }, + "required": ["profile_id"] + }), + }, ] } @@ -826,6 +889,17 @@ impl McpServer { // Fingerprint management "get_profile_fingerprint" => self.handle_get_profile_fingerprint(&arguments).await, "update_profile_fingerprint" => self.handle_update_profile_fingerprint(&arguments).await, + // Extension management + "list_extensions" => self.handle_list_extensions().await, + "list_extension_groups" => self.handle_list_extension_groups().await, + "create_extension_group" => self.handle_create_extension_group(&arguments).await, + "delete_extension" => self.handle_delete_extension_mcp(&arguments).await, + "delete_extension_group" => self.handle_delete_extension_group_mcp(&arguments).await, + "assign_extension_group_to_profile" => { + self + .handle_assign_extension_group_to_profile(&arguments) + .await + } _ => Err(McpError { code: -32602, message: format!("Unknown tool: {tool_name}"), @@ -2066,6 +2140,180 @@ impl McpServer { }] })) } + + async fn handle_list_extensions(&self) -> Result { + if !CLOUD_AUTH.has_active_paid_subscription().await { + return Err(McpError { + code: -32000, + message: "Extension management requires an active Pro subscription".to_string(), + }); + } + let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + let extensions = mgr.list_extensions().map_err(|e| McpError { + code: -32000, + message: format!("Failed to list extensions: {e}"), + })?; + Ok(serde_json::to_value(extensions).unwrap()) + } + + async fn handle_list_extension_groups(&self) -> Result { + if !CLOUD_AUTH.has_active_paid_subscription().await { + return Err(McpError { + code: -32000, + message: "Extension management requires an active Pro subscription".to_string(), + }); + } + let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + let groups = mgr.list_groups().map_err(|e| McpError { + code: -32000, + message: format!("Failed to list extension groups: {e}"), + })?; + Ok(serde_json::to_value(groups).unwrap()) + } + + async fn handle_create_extension_group( + &self, + arguments: &serde_json::Value, + ) -> Result { + if !CLOUD_AUTH.has_active_paid_subscription().await { + return Err(McpError { + code: -32000, + message: "Extension management requires an active Pro subscription".to_string(), + }); + } + let name = arguments + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing required parameter: name".to_string(), + })?; + let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + let group = mgr.create_group(name.to_string()).map_err(|e| McpError { + code: -32000, + message: format!("Failed to create extension group: {e}"), + })?; + Ok(serde_json::to_value(group).unwrap()) + } + + async fn handle_delete_extension_mcp( + &self, + arguments: &serde_json::Value, + ) -> Result { + if !CLOUD_AUTH.has_active_paid_subscription().await { + return Err(McpError { + code: -32000, + message: "Extension management requires an active Pro subscription".to_string(), + }); + } + let extension_id = arguments + .get("extension_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing required parameter: extension_id".to_string(), + })?; + let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + mgr + .delete_extension_internal(extension_id) + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to delete extension: {e}"), + })?; + Ok(serde_json::json!({"success": true})) + } + + async fn handle_delete_extension_group_mcp( + &self, + arguments: &serde_json::Value, + ) -> Result { + if !CLOUD_AUTH.has_active_paid_subscription().await { + return Err(McpError { + code: -32000, + message: "Extension management requires an active Pro subscription".to_string(), + }); + } + let group_id = arguments + .get("group_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing required parameter: group_id".to_string(), + })?; + let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + // For MCP, we don't have an app_handle, but we need one for sync deletion. + // Use the delete_group_internal which skips sync remote deletion. + mgr.delete_group_internal(group_id).map_err(|e| McpError { + code: -32000, + message: format!("Failed to delete extension group: {e}"), + })?; + if let Err(e) = crate::events::emit_empty("extensions-changed") { + log::error!("Failed to emit extensions-changed event: {e}"); + } + Ok(serde_json::json!({"success": true})) + } + + async fn handle_assign_extension_group_to_profile( + &self, + arguments: &serde_json::Value, + ) -> Result { + if !CLOUD_AUTH.has_active_paid_subscription().await { + return Err(McpError { + code: -32000, + message: "Extension management requires an active Pro subscription".to_string(), + }); + } + let profile_id = arguments + .get("profile_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing required parameter: profile_id".to_string(), + })?; + let extension_group_id = arguments + .get("extension_group_id") + .and_then(|v| v.as_str()) + .map(|s| { + if s.is_empty() { + None + } else { + Some(s.to_string()) + } + }) + .unwrap_or(None); + + // Validate compatibility if assigning + if let Some(ref gid) = extension_group_id { + let profile_manager = ProfileManager::instance(); + let profiles = profile_manager.list_profiles().map_err(|e| McpError { + code: -32000, + message: format!("Failed to list profiles: {e}"), + })?; + let profile = profiles + .iter() + .find(|p| p.id.to_string() == profile_id) + .ok_or_else(|| McpError { + code: -32000, + message: format!("Profile '{profile_id}' not found"), + })?; + let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + mgr + .validate_group_compatibility(gid, &profile.browser) + .map_err(|e| McpError { + code: -32000, + message: format!("{e}"), + })?; + } + + let profile_manager = ProfileManager::instance(); + let profile = profile_manager + .update_profile_extension_group(profile_id, extension_group_id) + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to assign extension group: {e}"), + })?; + Ok(serde_json::to_value(profile).unwrap()) + } } lazy_static::lazy_static! { @@ -2081,8 +2329,8 @@ mod tests { let server = McpServer::new(); let tools = server.get_tools(); - // Should have at least 26 tools (24 + 2 fingerprint tools) - assert!(tools.len() >= 26); + // Should have at least 32 tools (26 + 6 extension tools) + assert!(tools.len() >= 32); // Check tool names let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); @@ -2118,6 +2366,13 @@ mod tests { // Fingerprint tools assert!(tool_names.contains(&"get_profile_fingerprint")); assert!(tool_names.contains(&"update_profile_fingerprint")); + // Extension tools + assert!(tool_names.contains(&"list_extensions")); + assert!(tool_names.contains(&"list_extension_groups")); + assert!(tool_names.contains(&"create_extension_group")); + assert!(tool_names.contains(&"delete_extension")); + assert!(tool_names.contains(&"delete_extension_group")); + assert!(tool_names.contains(&"assign_extension_group_to_profile")); } #[test] diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 7fc05c9..0e0a1ba 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -177,6 +177,8 @@ impl ProfileManager { last_sync: None, host_os: None, ephemeral: false, + extension_group_id: None, + proxy_bypass_rules: Vec::new(), }; match self @@ -294,6 +296,8 @@ impl ProfileManager { last_sync: None, host_os: None, ephemeral: false, + extension_group_id: None, + proxy_bypass_rules: Vec::new(), }; match self @@ -343,6 +347,8 @@ impl ProfileManager { last_sync: None, host_os: Some(get_host_os()), ephemeral, + extension_group_id: None, + proxy_bypass_rules: Vec::new(), }; // Save profile info @@ -732,6 +738,31 @@ impl ProfileManager { Ok(profile) } + pub fn update_profile_proxy_bypass_rules( + &self, + _app_handle: &tauri::AppHandle, + profile_id: &str, + rules: Vec, + ) -> Result> { + let profile_uuid = + uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?; + let profiles = self.list_profiles()?; + let mut profile = profiles + .into_iter() + .find(|p| p.id == profile_uuid) + .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; + + profile.proxy_bypass_rules = rules; + + self.save_profile(&profile)?; + + if let Err(e) = events::emit_empty("profiles-changed") { + log::warn!("Warning: Failed to emit profiles-changed event: {e}"); + } + + Ok(profile) + } + pub fn delete_multiple_profiles( &self, app_handle: &tauri::AppHandle, @@ -866,6 +897,8 @@ impl ProfileManager { last_sync: None, host_os: Some(get_host_os()), ephemeral: false, + extension_group_id: source.extension_group_id, + proxy_bypass_rules: source.proxy_bypass_rules, }; self.save_profile(&new_profile)?; @@ -1137,6 +1170,32 @@ impl ProfileManager { Ok(profile) } + pub fn update_profile_extension_group( + &self, + profile_id: &str, + extension_group_id: Option, + ) -> Result> { + let profile_uuid = + uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?; + let profiles = self.list_profiles()?; + let mut profile = profiles + .into_iter() + .find(|p| p.id == profile_uuid) + .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; + + profile.extension_group_id = extension_group_id; + self.save_profile(&profile)?; + + if let Err(e) = events::emit("profile-updated", &profile) { + log::warn!("Failed to emit profile update event: {e}"); + } + if let Err(e) = events::emit_empty("profiles-changed") { + log::warn!("Failed to emit profiles-changed event: {e}"); + } + + Ok(profile) + } + pub async fn check_browser_status( &self, app_handle: tauri::AppHandle, @@ -1531,9 +1590,10 @@ impl ProfileManager { "user_pref(\"startup.homepage_welcome_url\", \"\");".to_string(), "user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(), "user_pref(\"startup.homepage_override_url\", \"\");".to_string(), - // Keep extension updates enabled + // Keep extension updates enabled and allow sideloaded extensions "user_pref(\"extensions.update.enabled\", true);".to_string(), "user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(), + "user_pref(\"extensions.autoDisableScopes\", 0);".to_string(), // Completely disable browser update checking "user_pref(\"app.update.enabled\", false);".to_string(), "user_pref(\"app.update.auto\", false);".to_string(), @@ -1987,6 +2047,18 @@ pub fn update_profile_note( .map_err(|e| format!("Failed to update profile note: {e}")) } +#[tauri::command] +pub fn update_profile_proxy_bypass_rules( + app_handle: tauri::AppHandle, + profile_id: String, + rules: Vec, +) -> Result { + let profile_manager = ProfileManager::instance(); + profile_manager + .update_profile_proxy_bypass_rules(&app_handle, &profile_id, rules) + .map_err(|e| format!("Failed to update proxy bypass rules: {e}")) +} + #[tauri::command] pub async fn check_browser_status( app_handle: tauri::AppHandle, diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index 9f7fa22..323f2dc 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -57,6 +57,10 @@ pub struct BrowserProfile { pub host_os: Option, // OS where profile was created ("macos", "windows", "linux") #[serde(default)] pub ephemeral: bool, + #[serde(default)] + pub extension_group_id: Option, + #[serde(default)] + pub proxy_bypass_rules: Vec, } pub fn default_release_type() -> String { diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 6c3dea0..f2ae048 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -559,6 +559,8 @@ impl ProfileImporter { last_sync: None, host_os: Some(crate::profile::types::get_host_os()), ephemeral: false, + extension_group_id: None, + proxy_bypass_rules: Vec::new(), }; // Save the profile metadata diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 4b6ab73..49e5a60 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -1192,6 +1192,7 @@ impl ProxyManager { proxy_settings: Option<&ProxySettings>, browser_pid: u32, profile_id: Option<&str>, + bypass_rules: Vec, ) -> Result { if let Some(name) = profile_id { // Check if we have an active proxy recorded for this profile @@ -1312,6 +1313,13 @@ impl ProxyManager { proxy_cmd = proxy_cmd.arg("--profile-id").arg(id); } + // Add bypass rules if any + if !bypass_rules.is_empty() { + let rules_json = serde_json::to_string(&bypass_rules) + .map_err(|e| format!("Failed to serialize bypass rules: {e}"))?; + proxy_cmd = proxy_cmd.arg("--bypass-rules").arg(rules_json); + } + // Execute the command and wait for it to complete // The donut-proxy binary should start the worker and then exit let output = proxy_cmd diff --git a/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index c54aac7..8a6fa52 100644 --- a/src-tauri/src/proxy_runner.rs +++ b/src-tauri/src/proxy_runner.rs @@ -12,13 +12,14 @@ pub async fn start_proxy_process( upstream_url: Option, port: Option, ) -> Result> { - start_proxy_process_with_profile(upstream_url, port, None).await + start_proxy_process_with_profile(upstream_url, port, None, Vec::new()).await } pub async fn start_proxy_process_with_profile( upstream_url: Option, port: Option, profile_id: Option, + bypass_rules: Vec, ) -> Result> { let id = generate_proxy_id(); let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string()); @@ -30,8 +31,9 @@ pub async fn start_proxy_process_with_profile( listener.local_addr().unwrap().port() }); - let config = - ProxyConfig::new(id.clone(), upstream, Some(local_port)).with_profile_id(profile_id.clone()); + let config = ProxyConfig::new(id.clone(), upstream, Some(local_port)) + .with_profile_id(profile_id.clone()) + .with_bypass_rules(bypass_rules); save_proxy_config(&config)?; // Log profile_id for debugging diff --git a/src-tauri/src/proxy_server.rs b/src-tauri/src/proxy_server.rs index bfb85a8..aa89cfe 100644 --- a/src-tauri/src/proxy_server.rs +++ b/src-tauri/src/proxy_server.rs @@ -6,6 +6,7 @@ use hyper::server::conn::http1; use hyper::service::service_fn; use hyper::{Method, Request, Response, StatusCode}; use hyper_util::rt::TokioIo; +use regex_lite::Regex; use std::convert::Infallible; use std::io; use std::net::SocketAddr; @@ -18,6 +19,38 @@ use tokio::net::TcpListener; use tokio::net::TcpStream; use url::Url; +enum CompiledRule { + Regex(Regex), + Exact(String), +} + +#[derive(Clone)] +pub struct BypassMatcher { + rules: Arc>, +} + +impl BypassMatcher { + pub fn new(rules: &[String]) -> Self { + let compiled = rules + .iter() + .map(|rule| match Regex::new(rule) { + Ok(re) => CompiledRule::Regex(re), + Err(_) => CompiledRule::Exact(rule.clone()), + }) + .collect(); + Self { + rules: Arc::new(compiled), + } + } + + pub fn should_bypass(&self, host: &str) -> bool { + self.rules.iter().any(|rule| match rule { + CompiledRule::Regex(re) => re.is_match(host), + CompiledRule::Exact(exact) => host == exact, + }) + } +} + /// Wrapper stream that counts bytes read and written struct CountingStream { inner: S, @@ -133,19 +166,21 @@ impl AsyncWrite for PrependReader { async fn handle_request( req: Request, upstream_url: Option, + bypass_matcher: BypassMatcher, ) -> Result>, Infallible> { // Handle CONNECT method for HTTPS tunneling if req.method() == Method::CONNECT { - return handle_connect(req, upstream_url).await; + return handle_connect(req, upstream_url, bypass_matcher).await; } // Handle regular HTTP requests - handle_http(req, upstream_url).await + handle_http(req, upstream_url, bypass_matcher).await } async fn handle_connect( req: Request, upstream_url: Option, + bypass_matcher: BypassMatcher, ) -> Result>, Infallible> { let authority = req.uri().authority().cloned(); @@ -161,12 +196,13 @@ async fn handle_connect( (&target_addr[..], 443) }; - // If no upstream proxy, connect directly + // If no upstream proxy, or bypass rule matches, connect directly if upstream_url.is_none() || upstream_url .as_ref() .map(|s| s == "DIRECT") .unwrap_or(false) + || bypass_matcher.should_bypass(target_host) { match TcpStream::connect(&target_addr).await { Ok(_stream) => { @@ -674,6 +710,7 @@ async fn handle_http_via_socks4( async fn handle_http( req: Request, upstream_url: Option, + bypass_matcher: BypassMatcher, ) -> Result>, Infallible> { // Extract domain for traffic tracking let domain = req @@ -689,13 +726,17 @@ async fn handle_http( req.uri().host() ); + let should_bypass = bypass_matcher.should_bypass(&domain); + // Check if we need to handle SOCKS4 manually (reqwest doesn't support it) - if let Some(ref upstream) = upstream_url { - if upstream != "DIRECT" { - if let Ok(url) = Url::parse(upstream) { - if url.scheme() == "socks4" { - // Handle SOCKS4 manually for HTTP requests - return handle_http_via_socks4(req, upstream).await; + if !should_bypass { + if let Some(ref upstream) = upstream_url { + if upstream != "DIRECT" { + if let Ok(url) = Url::parse(upstream) { + if url.scheme() == "socks4" { + // Handle SOCKS4 manually for HTTP requests + return handle_http_via_socks4(req, upstream).await; + } } } } @@ -705,7 +746,9 @@ async fn handle_http( use reqwest::Client; let client_builder = Client::builder(); - let client = if let Some(ref upstream) = upstream_url { + let client = if should_bypass { + client_builder.build().unwrap_or_default() + } else if let Some(ref upstream) = upstream_url { if upstream == "DIRECT" { client_builder.build().unwrap_or_default() } else { @@ -1003,6 +1046,8 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box, upstream_url: Option, + bypass_matcher: BypassMatcher, ) -> Result<(), Box> { // Parse the CONNECT request from the buffer let request_str = String::from_utf8_lossy(&request_buffer); @@ -1193,6 +1243,7 @@ async fn handle_connect_from_buffer( } // Connect to target (directly or via upstream proxy) + let should_bypass = bypass_matcher.should_bypass(target_host); let target_stream = match upstream_url.as_ref() { None => { // Direct connection @@ -1202,6 +1253,10 @@ async fn handle_connect_from_buffer( // Direct connection TcpStream::connect((target_host, target_port)).await? } + _ if should_bypass => { + // Bypass rule matched - connect directly + TcpStream::connect((target_host, target_port)).await? + } Some(upstream_url_str) => { // Connect via upstream proxy let upstream = Url::parse(upstream_url_str)?; diff --git a/src-tauri/src/proxy_storage.rs b/src-tauri/src/proxy_storage.rs index 4cfb132..6ce47ac 100644 --- a/src-tauri/src/proxy_storage.rs +++ b/src-tauri/src/proxy_storage.rs @@ -12,6 +12,8 @@ pub struct ProxyConfig { pub pid: Option, #[serde(default)] pub profile_id: Option, + #[serde(default)] + pub bypass_rules: Vec, } impl ProxyConfig { @@ -24,6 +26,7 @@ impl ProxyConfig { local_url: None, pid: None, profile_id: None, + bypass_rules: Vec::new(), } } @@ -31,6 +34,11 @@ impl ProxyConfig { self.profile_id = profile_id; self } + + pub fn with_bypass_rules(mut self, bypass_rules: Vec) -> Self { + self.bypass_rules = bypass_rules; + self + } } pub fn get_storage_dir() -> PathBuf { diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 8e5990e..abee13b 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -1013,6 +1013,347 @@ impl SyncEngine { Ok(()) } + // Extension sync + + async fn sync_extension( + &self, + ext_id: &str, + app_handle: Option<&tauri::AppHandle>, + ) -> SyncResult<()> { + let local_ext = { + let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + manager.get_extension(ext_id).ok() + }; + + let remote_key = format!("extensions/{}.json", ext_id); + let stat = self.client.stat(&remote_key).await?; + + match (local_ext, stat.exists) { + (Some(ext), true) => { + let local_updated = ext.last_sync.unwrap_or(0); + let remote_updated: DateTime = stat + .last_modified + .as_ref() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(Utc::now); + let remote_ts = remote_updated.timestamp() as u64; + + if remote_ts > local_updated { + self.download_extension(ext_id, app_handle).await?; + } else if local_updated > remote_ts { + self.upload_extension(&ext).await?; + } + } + (Some(ext), false) => { + self.upload_extension(&ext).await?; + } + (None, true) => { + self.download_extension(ext_id, app_handle).await?; + } + (None, false) => { + log::debug!("Extension {} not found locally or remotely", ext_id); + } + } + + Ok(()) + } + + async fn upload_extension(&self, ext: &crate::extension_manager::Extension) -> SyncResult<()> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let mut updated_ext = ext.clone(); + updated_ext.last_sync = Some(now); + + let json = serde_json::to_string_pretty(&updated_ext) + .map_err(|e| SyncError::SerializationError(format!("Failed to serialize extension: {e}")))?; + + let remote_key = format!("extensions/{}.json", ext.id); + let presign = self + .client + .presign_upload(&remote_key, Some("application/json")) + .await?; + self + .client + .upload_bytes(&presign.url, json.as_bytes(), Some("application/json")) + .await?; + + // Also upload the extension file data + let file_path = { + let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + let file_dir = manager.get_file_dir_public(&ext.id); + file_dir.join(&ext.file_name) + }; + + if file_path.exists() { + let file_data = fs::read(&file_path).map_err(|e| { + SyncError::IoError(format!( + "Failed to read extension file {}: {e}", + file_path.display() + )) + })?; + + let file_remote_key = format!("extensions/{}/file/{}", ext.id, ext.file_name); + let file_presign = self + .client + .presign_upload(&file_remote_key, Some("application/octet-stream")) + .await?; + self + .client + .upload_bytes( + &file_presign.url, + &file_data, + Some("application/octet-stream"), + ) + .await?; + } + + // Update local extension with new last_sync + { + let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + if let Err(e) = manager.update_extension_internal(&updated_ext) { + log::warn!("Failed to update extension last_sync: {}", e); + } + } + + log::info!("Extension {} uploaded", ext.id); + Ok(()) + } + + async fn download_extension( + &self, + ext_id: &str, + app_handle: Option<&tauri::AppHandle>, + ) -> SyncResult<()> { + let remote_key = format!("extensions/{}.json", ext_id); + let presign = self.client.presign_download(&remote_key).await?; + let data = self.client.download_bytes(&presign.url).await?; + + let mut ext: crate::extension_manager::Extension = serde_json::from_slice(&data) + .map_err(|e| SyncError::SerializationError(format!("Failed to parse extension JSON: {e}")))?; + + ext.last_sync = Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); + ext.sync_enabled = true; + + // Download the extension file + let file_remote_key = format!("extensions/{}/file/{}", ext.id, ext.file_name); + let file_stat = self.client.stat(&file_remote_key).await?; + if file_stat.exists { + let file_presign = self.client.presign_download(&file_remote_key).await?; + let file_data = self.client.download_bytes(&file_presign.url).await?; + + let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + let file_dir = manager.get_file_dir_public(&ext.id); + drop(manager); + + fs::create_dir_all(&file_dir).map_err(|e| { + SyncError::IoError(format!( + "Failed to create extension file dir {}: {e}", + file_dir.display() + )) + })?; + let file_path = file_dir.join(&ext.file_name); + fs::write(&file_path, &file_data).map_err(|e| { + SyncError::IoError(format!( + "Failed to write extension file {}: {e}", + file_path.display() + )) + })?; + } + + // Save or update local extension + { + let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + if let Err(e) = manager.upsert_extension_internal(&ext) { + log::warn!("Failed to save downloaded extension: {}", e); + } + } + + if let Some(_handle) = app_handle { + let _ = events::emit("extensions-changed", ()); + } + + log::info!("Extension {} downloaded", ext_id); + Ok(()) + } + + pub async fn sync_extension_by_id_with_handle( + &self, + ext_id: &str, + app_handle: &tauri::AppHandle, + ) -> SyncResult<()> { + self.sync_extension(ext_id, Some(app_handle)).await + } + + pub async fn delete_extension(&self, ext_id: &str) -> SyncResult<()> { + let remote_key = format!("extensions/{}.json", ext_id); + let file_prefix = format!("extensions/{}/file/", ext_id); + let tombstone_key = format!("tombstones/extensions/{}.json", ext_id); + + // Delete metadata + self + .client + .delete(&remote_key, Some(&tombstone_key)) + .await?; + + // Delete file data + let _ = self.client.delete_prefix(&file_prefix, None).await; + + log::info!("Extension {} deleted from sync", ext_id); + Ok(()) + } + + // Extension group sync + + async fn sync_extension_group( + &self, + group_id: &str, + app_handle: Option<&tauri::AppHandle>, + ) -> SyncResult<()> { + let local_group = { + let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + manager.get_group(group_id).ok() + }; + + let remote_key = format!("extension_groups/{}.json", group_id); + let stat = self.client.stat(&remote_key).await?; + + match (local_group, stat.exists) { + (Some(group), true) => { + let local_updated = group.last_sync.unwrap_or(0); + let remote_updated: DateTime = stat + .last_modified + .as_ref() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(Utc::now); + let remote_ts = remote_updated.timestamp() as u64; + + if remote_ts > local_updated { + self.download_extension_group(group_id, app_handle).await?; + } else if local_updated > remote_ts { + self.upload_extension_group(&group).await?; + } + } + (Some(group), false) => { + self.upload_extension_group(&group).await?; + } + (None, true) => { + self.download_extension_group(group_id, app_handle).await?; + } + (None, false) => { + log::debug!("Extension group {} not found locally or remotely", group_id); + } + } + + Ok(()) + } + + async fn upload_extension_group( + &self, + group: &crate::extension_manager::ExtensionGroup, + ) -> SyncResult<()> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let mut updated_group = group.clone(); + updated_group.last_sync = Some(now); + + let json = serde_json::to_string_pretty(&updated_group).map_err(|e| { + SyncError::SerializationError(format!("Failed to serialize extension group: {e}")) + })?; + + let remote_key = format!("extension_groups/{}.json", group.id); + let presign = self + .client + .presign_upload(&remote_key, Some("application/json")) + .await?; + self + .client + .upload_bytes(&presign.url, json.as_bytes(), Some("application/json")) + .await?; + + // Update local group with new last_sync + { + let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + if let Err(e) = manager.update_group_internal(&updated_group) { + log::warn!("Failed to update extension group last_sync: {}", e); + } + } + + log::info!("Extension group {} uploaded", group.id); + Ok(()) + } + + async fn download_extension_group( + &self, + group_id: &str, + app_handle: Option<&tauri::AppHandle>, + ) -> SyncResult<()> { + let remote_key = format!("extension_groups/{}.json", group_id); + let presign = self.client.presign_download(&remote_key).await?; + let data = self.client.download_bytes(&presign.url).await?; + + let mut group: crate::extension_manager::ExtensionGroup = serde_json::from_slice(&data) + .map_err(|e| { + SyncError::SerializationError(format!("Failed to parse extension group JSON: {e}")) + })?; + + group.last_sync = Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); + group.sync_enabled = true; + + // Save or update local group + { + let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + if let Err(e) = manager.upsert_group_internal(&group) { + log::warn!("Failed to save downloaded extension group: {}", e); + } + } + + if let Some(_handle) = app_handle { + let _ = events::emit("extensions-changed", ()); + } + + log::info!("Extension group {} downloaded", group_id); + Ok(()) + } + + pub async fn sync_extension_group_by_id_with_handle( + &self, + group_id: &str, + app_handle: &tauri::AppHandle, + ) -> SyncResult<()> { + self.sync_extension_group(group_id, Some(app_handle)).await + } + + pub async fn delete_extension_group(&self, group_id: &str) -> SyncResult<()> { + let remote_key = format!("extension_groups/{}.json", group_id); + let tombstone_key = format!("tombstones/extension_groups/{}.json", group_id); + + self + .client + .delete(&remote_key, Some(&tombstone_key)) + .await?; + + log::info!("Extension group {} deleted from sync", group_id); + Ok(()) + } + /// Download a profile from S3 if it exists remotely but not locally pub async fn download_profile_if_missing( &self, @@ -2093,6 +2434,8 @@ pub struct UnsyncedEntityCounts { pub proxies: usize, pub groups: usize, pub vpns: usize, + pub extensions: usize, + pub extension_groups: usize, } #[tauri::command] @@ -2121,10 +2464,28 @@ pub fn get_unsynced_entity_counts() -> Result { configs.iter().filter(|c| !c.sync_enabled).count() }; + let extension_count = { + let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + let exts = em + .list_extensions() + .map_err(|e| format!("Failed to list extensions: {e}"))?; + exts.iter().filter(|e| !e.sync_enabled).count() + }; + + let extension_group_count = { + let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + let groups = em + .list_groups() + .map_err(|e| format!("Failed to list extension groups: {e}"))?; + groups.iter().filter(|g| !g.sync_enabled).count() + }; + Ok(UnsyncedEntityCounts { proxies: proxy_count, groups: group_count, vpns: vpn_count, + extensions: extension_count, + extension_groups: extension_group_count, }) } @@ -2169,5 +2530,147 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul } } + // Enable sync for all unsynced extensions + { + let exts = { + let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + em.list_extensions() + .map_err(|e| format!("Failed to list extensions: {e}"))? + }; + for ext in &exts { + if !ext.sync_enabled { + set_extension_sync_enabled(app_handle.clone(), ext.id.clone(), true).await?; + } + } + } + + // Enable sync for all unsynced extension groups + { + let groups = { + let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + em.list_groups() + .map_err(|e| format!("Failed to list extension groups: {e}"))? + }; + for group in &groups { + if !group.sync_enabled { + set_extension_group_sync_enabled(app_handle.clone(), group.id.clone(), true).await?; + } + } + } + + Ok(()) +} + +#[tauri::command] +pub async fn set_extension_sync_enabled( + app_handle: tauri::AppHandle, + extension_id: String, + enabled: bool, +) -> Result<(), String> { + let ext = { + let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + manager + .get_extension(&extension_id) + .map_err(|e| format!("Extension with ID '{extension_id}' not found: {e}"))? + }; + + if enabled { + let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; + if !cloud_logged_in { + let manager = SettingsManager::instance(); + let settings = manager + .load_settings() + .map_err(|e| format!("Failed to load settings: {e}"))?; + if settings.sync_server_url.is_none() { + return Err( + "Sync server not configured. Please configure sync settings first.".to_string(), + ); + } + let token = manager.get_sync_token(&app_handle).await.ok().flatten(); + if token.is_none() { + return Err("Sync token not configured. Please configure sync settings first.".to_string()); + } + } + } + + let mut updated_ext = ext; + updated_ext.sync_enabled = enabled; + if !enabled { + updated_ext.last_sync = None; + } + + { + let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + manager + .update_extension_internal(&updated_ext) + .map_err(|e| format!("Failed to update extension sync: {e}"))?; + } + + let _ = events::emit("extensions-changed", ()); + + if enabled { + if let Some(scheduler) = super::get_global_scheduler() { + scheduler.queue_extension_sync(extension_id).await; + } + } + + Ok(()) +} + +#[tauri::command] +pub async fn set_extension_group_sync_enabled( + app_handle: tauri::AppHandle, + extension_group_id: String, + enabled: bool, +) -> Result<(), String> { + let group = { + let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + manager + .get_group(&extension_group_id) + .map_err(|e| format!("Extension group with ID '{extension_group_id}' not found: {e}"))? + }; + + if enabled { + let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; + if !cloud_logged_in { + let manager = SettingsManager::instance(); + let settings = manager + .load_settings() + .map_err(|e| format!("Failed to load settings: {e}"))?; + if settings.sync_server_url.is_none() { + return Err( + "Sync server not configured. Please configure sync settings first.".to_string(), + ); + } + let token = manager.get_sync_token(&app_handle).await.ok().flatten(); + if token.is_none() { + return Err("Sync token not configured. Please configure sync settings first.".to_string()); + } + } + } + + let mut updated_group = group; + updated_group.sync_enabled = enabled; + if !enabled { + updated_group.last_sync = None; + } + + { + let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + manager + .update_group_internal(&updated_group) + .map_err(|e| format!("Failed to update extension group sync: {e}"))?; + } + + let _ = events::emit("extensions-changed", ()); + + if enabled { + if let Some(scheduler) = super::get_global_scheduler() { + scheduler + .queue_extension_group_sync(extension_group_id) + .await; + } + } + Ok(()) } diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index c4d366e..97c9ac1 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -13,9 +13,9 @@ pub use engine::{ enable_vpn_sync_if_needed, get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile, is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured, is_vpn_in_use_by_synced_profile, - is_vpn_used_by_synced_profile, request_profile_sync, set_group_sync_enabled, - set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, - trigger_sync_for_profile, SyncEngine, + is_vpn_used_by_synced_profile, request_profile_sync, set_extension_group_sync_enabled, + set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode, + set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine, }; pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest}; pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler}; diff --git a/src-tauri/src/sync/scheduler.rs b/src-tauri/src/sync/scheduler.rs index a63bff4..155f9d4 100644 --- a/src-tauri/src/sync/scheduler.rs +++ b/src-tauri/src/sync/scheduler.rs @@ -35,6 +35,8 @@ pub struct SyncScheduler { pending_proxies: Arc>>, pending_groups: Arc>>, pending_vpns: Arc>>, + pending_extensions: Arc>>, + pending_extension_groups: Arc>>, pending_tombstones: Arc>>, running_profiles: Arc>>, in_flight_profiles: Arc>>, @@ -54,6 +56,8 @@ impl SyncScheduler { pending_proxies: Arc::new(Mutex::new(HashSet::new())), pending_groups: Arc::new(Mutex::new(HashSet::new())), pending_vpns: Arc::new(Mutex::new(HashSet::new())), + pending_extensions: Arc::new(Mutex::new(HashSet::new())), + pending_extension_groups: Arc::new(Mutex::new(HashSet::new())), pending_tombstones: Arc::new(Mutex::new(Vec::new())), running_profiles: Arc::new(Mutex::new(HashSet::new())), in_flight_profiles: Arc::new(Mutex::new(HashSet::new())), @@ -100,6 +104,18 @@ impl SyncScheduler { } drop(pending_vpns); + let pending_extensions = self.pending_extensions.lock().await; + if !pending_extensions.is_empty() { + return true; + } + drop(pending_extensions); + + let pending_extension_groups = self.pending_extension_groups.lock().await; + if !pending_extension_groups.is_empty() { + return true; + } + drop(pending_extension_groups); + let pending_tombstones = self.pending_tombstones.lock().await; if !pending_tombstones.is_empty() { return true; @@ -208,6 +224,16 @@ impl SyncScheduler { pending.insert(group_id); } + pub async fn queue_extension_sync(&self, extension_id: String) { + let mut pending = self.pending_extensions.lock().await; + pending.insert(extension_id); + } + + pub async fn queue_extension_group_sync(&self, extension_group_id: String) { + let mut pending = self.pending_extension_groups.lock().await; + pending.insert(extension_group_id); + } + pub async fn queue_tombstone(&self, entity_type: String, entity_id: String) { let mut pending = self.pending_tombstones.lock().await; if !pending @@ -234,7 +260,7 @@ impl SyncScheduler { let sync_enabled_profiles: Vec<_> = profiles .into_iter() - .filter(|p| p.is_sync_enabled()) + .filter(|p| p.is_sync_enabled() && !p.is_cross_os()) .collect(); if sync_enabled_profiles.is_empty() { @@ -286,6 +312,8 @@ impl SyncScheduler { SyncWorkItem::Proxy(id) => scheduler.queue_proxy_sync(id).await, SyncWorkItem::Group(id) => scheduler.queue_group_sync(id).await, SyncWorkItem::Vpn(id) => scheduler.queue_vpn_sync(id).await, + SyncWorkItem::Extension(id) => scheduler.queue_extension_sync(id).await, + SyncWorkItem::ExtensionGroup(id) => scheduler.queue_extension_group_sync(id).await, SyncWorkItem::Tombstone(entity_type, entity_id) => { scheduler.queue_tombstone(entity_type, entity_id).await } @@ -306,6 +334,8 @@ impl SyncScheduler { self.process_pending_proxies(app_handle).await; self.process_pending_groups(app_handle).await; self.process_pending_vpns(app_handle).await; + self.process_pending_extensions(app_handle).await; + self.process_pending_extension_groups(app_handle).await; self.process_pending_tombstones(app_handle).await; } @@ -356,7 +386,7 @@ impl SyncScheduler { profile_manager.list_profiles().ok().and_then(|profiles| { profiles .into_iter() - .find(|p| p.id.to_string() == profile_id && p.is_sync_enabled()) + .find(|p| p.id.to_string() == profile_id && p.is_sync_enabled() && !p.is_cross_os()) }) }; @@ -385,6 +415,8 @@ impl SyncScheduler { && self.pending_proxies.lock().await.is_empty() && self.pending_groups.lock().await.is_empty() && self.pending_vpns.lock().await.is_empty() + && self.pending_extensions.lock().await.is_empty() + && self.pending_extension_groups.lock().await.is_empty() }; match result { @@ -618,6 +650,82 @@ impl SyncScheduler { } } + async fn process_pending_extensions(&self, app_handle: &tauri::AppHandle) { + let extensions_to_sync: Vec = { + let mut pending = self.pending_extensions.lock().await; + let list: Vec = pending.drain().collect(); + list + }; + + if extensions_to_sync.is_empty() { + return; + } + + match SyncEngine::create_from_settings(app_handle).await { + Ok(engine) => { + for ext_id in extensions_to_sync { + log::info!("Syncing extension {}", ext_id); + if let Err(e) = engine + .sync_extension_by_id_with_handle(&ext_id, app_handle) + .await + { + log::error!("Failed to sync extension {}: {}", ext_id, e); + } + } + + if !self.is_sync_in_progress().await { + log::debug!("All syncs completed after extension sync, triggering cleanup"); + let registry = + crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(); + if let Err(e) = registry.cleanup_unused_binaries() { + log::warn!("Cleanup after sync failed: {e}"); + } + } + } + Err(e) => { + log::error!("Failed to create sync engine: {}", e); + } + } + } + + async fn process_pending_extension_groups(&self, app_handle: &tauri::AppHandle) { + let groups_to_sync: Vec = { + let mut pending = self.pending_extension_groups.lock().await; + let list: Vec = pending.drain().collect(); + list + }; + + if groups_to_sync.is_empty() { + return; + } + + match SyncEngine::create_from_settings(app_handle).await { + Ok(engine) => { + for group_id in groups_to_sync { + log::info!("Syncing extension group {}", group_id); + if let Err(e) = engine + .sync_extension_group_by_id_with_handle(&group_id, app_handle) + .await + { + log::error!("Failed to sync extension group {}: {}", group_id, e); + } + } + + if !self.is_sync_in_progress().await { + log::debug!("All syncs completed after extension group sync, triggering cleanup"); + let registry = + crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(); + if let Err(e) = registry.cleanup_unused_binaries() { + log::warn!("Cleanup after sync failed: {e}"); + } + } + } + Err(e) => { + log::error!("Failed to create sync engine: {}", e); + } + } + } + async fn process_pending_tombstones(&self, _app_handle: &tauri::AppHandle) { let tombstones: Vec<(String, String)> = { let mut pending = self.pending_tombstones.lock().await; @@ -695,6 +803,32 @@ impl SyncScheduler { } } } + "extension" => { + let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + if let Ok(ext) = manager.get_extension(&entity_id) { + if ext.sync_enabled { + log::info!( + "Extension {} was deleted remotely, deleting locally", + entity_id + ); + let _ = manager.delete_extension_internal(&entity_id); + let _ = events::emit("extensions-changed", ()); + } + } + } + "extension_group" => { + let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + if let Ok(group) = manager.get_group(&entity_id) { + if group.sync_enabled { + log::info!( + "Extension group {} was deleted remotely, deleting locally", + entity_id + ); + let _ = manager.delete_group_internal(&entity_id); + let _ = events::emit("extensions-changed", ()); + } + } + } _ => {} } } diff --git a/src-tauri/src/sync/subscription.rs b/src-tauri/src/sync/subscription.rs index cc09e71..8ea9f19 100644 --- a/src-tauri/src/sync/subscription.rs +++ b/src-tauri/src/sync/subscription.rs @@ -24,6 +24,8 @@ pub enum SyncWorkItem { Proxy(String), Group(String), Vpn(String), + Extension(String), + ExtensionGroup(String), Tombstone(String, String), } @@ -235,6 +237,16 @@ impl SyncSubscription { .strip_prefix("vpns/") .and_then(|s| s.strip_suffix(".json")) .map(|s| SyncWorkItem::Vpn(s.to_string())) + } else if key.starts_with("extensions/") { + key + .strip_prefix("extensions/") + .and_then(|s| s.strip_suffix(".json")) + .map(|s| SyncWorkItem::Extension(s.to_string())) + } else if key.starts_with("extension_groups/") { + key + .strip_prefix("extension_groups/") + .and_then(|s| s.strip_suffix(".json")) + .map(|s| SyncWorkItem::ExtensionGroup(s.to_string())) } else if key.starts_with("tombstones/") { key.strip_prefix("tombstones/").and_then(|rest| { if rest.starts_with("profiles/") { @@ -257,6 +269,16 @@ impl SyncSubscription { .strip_prefix("vpns/") .and_then(|s| s.strip_suffix(".json")) .map(|id| SyncWorkItem::Tombstone("vpn".to_string(), id.to_string())) + } else if rest.starts_with("extensions/") { + rest + .strip_prefix("extensions/") + .and_then(|s| s.strip_suffix(".json")) + .map(|id| SyncWorkItem::Tombstone("extension".to_string(), id.to_string())) + } else if rest.starts_with("extension_groups/") { + rest + .strip_prefix("extension_groups/") + .and_then(|s| s.strip_suffix(".json")) + .map(|id| SyncWorkItem::Tombstone("extension_group".to_string(), id.to_string())) } else { None } diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index eeffa7f..66f843b 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -396,6 +396,7 @@ impl WayfernManager { url: Option<&str>, proxy_url: Option<&str>, ephemeral: bool, + extension_paths: &[String], ) -> Result> { let executable_path = if let Some(path) = &config.executable_path { let p = PathBuf::from(path); @@ -448,6 +449,10 @@ impl WayfernManager { args.push("--disable-sync".to_string()); } + if !extension_paths.is_empty() { + args.push(format!("--load-extension={}", extension_paths.join(","))); + } + // Don't add URL to args - we'll navigate via CDP after setting fingerprint // This ensures fingerprint is applied at navigation commit time @@ -834,6 +839,7 @@ impl WayfernManager { url, proxy_url, profile.ephemeral, + &[], ) .await } diff --git a/src/app/page.tsx b/src/app/page.tsx index 6d36039..542dc01 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,6 +10,7 @@ import { CookieCopyDialog } from "@/components/cookie-copy-dialog"; import { CookieManagementDialog } from "@/components/cookie-management-dialog"; import { CreateProfileDialog } from "@/components/create-profile-dialog"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; +import { ExtensionManagementDialog } from "@/components/extension-management-dialog"; import { GroupAssignmentDialog } from "@/components/group-assignment-dialog"; import { GroupBadges } from "@/components/group-badges"; import { GroupManagementDialog } from "@/components/group-management-dialog"; @@ -139,6 +140,8 @@ export default function Home() { useState(false); const [groupManagementDialogOpen, setGroupManagementDialogOpen] = useState(false); + const [extensionManagementDialogOpen, setExtensionManagementDialogOpen] = + useState(false); const [groupAssignmentDialogOpen, setGroupAssignmentDialogOpen] = useState(false); const [proxyAssignmentDialogOpen, setProxyAssignmentDialogOpen] = @@ -500,23 +503,38 @@ export default function Home() { camoufoxConfig?: CamoufoxConfig; wayfernConfig?: WayfernConfig; groupId?: string; + extensionGroupId?: string; ephemeral?: boolean; }) => { try { - await invoke("create_browser_profile_new", { - name: profileData.name, - browserStr: profileData.browserStr, - version: profileData.version, - releaseType: profileData.releaseType, - proxyId: profileData.proxyId, - vpnId: profileData.vpnId, - camoufoxConfig: profileData.camoufoxConfig, - wayfernConfig: profileData.wayfernConfig, - groupId: - profileData.groupId || - (selectedGroupId !== "default" ? selectedGroupId : undefined), - ephemeral: profileData.ephemeral, - }); + const profile = await invoke( + "create_browser_profile_new", + { + name: profileData.name, + browserStr: profileData.browserStr, + version: profileData.version, + releaseType: profileData.releaseType, + proxyId: profileData.proxyId, + vpnId: profileData.vpnId, + camoufoxConfig: profileData.camoufoxConfig, + wayfernConfig: profileData.wayfernConfig, + groupId: + profileData.groupId || + (selectedGroupId !== "default" ? selectedGroupId : undefined), + ephemeral: profileData.ephemeral, + }, + ); + + if (profileData.extensionGroupId) { + try { + await invoke("assign_extension_group_to_profile", { + profileId: profile.id, + extensionGroupId: profileData.extensionGroupId, + }); + } catch (err) { + console.error("Failed to assign extension group:", err); + } + } // No need to manually reload - useProfileEvents will handle the update } catch (error) { @@ -1014,6 +1032,7 @@ export default function Home() { onSettingsDialogOpen={setSettingsDialogOpen} onSyncConfigDialogOpen={setSyncConfigDialogOpen} onIntegrationsDialogOpen={setIntegrationsDialogOpen} + onExtensionManagementDialogOpen={setExtensionManagementDialogOpen} searchQuery={searchQuery} onSearchQueryChange={setSearchQuery} /> @@ -1144,6 +1163,12 @@ export default function Home() { onGroupManagementComplete={handleGroupManagementComplete} /> + setExtensionManagementDialogOpen(false)} + limitedMode={!crossOsUnlocked} + /> + { diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index bb4f94a..39e3e28 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -74,6 +74,7 @@ interface CreateProfileDialogProps { camoufoxConfig?: CamoufoxConfig; wayfernConfig?: WayfernConfig; groupId?: string; + extensionGroupId?: string; ephemeral?: boolean; }) => Promise; selectedGroupId?: string; @@ -166,6 +167,21 @@ export function CreateProfileDialog({ const [showProxyForm, setShowProxyForm] = useState(false); const [isCreating, setIsCreating] = useState(false); const [ephemeral, setEphemeral] = useState(false); + const [selectedExtensionGroupId, setSelectedExtensionGroupId] = + useState(); + const [extensionGroups, setExtensionGroups] = useState< + { id: string; name: string; extension_ids: string[] }[] + >([]); + + useEffect(() => { + if (isOpen) { + invoke<{ id: string; name: string; extension_ids: string[] }[]>( + "list_extension_groups", + ) + .then(setExtensionGroups) + .catch(() => setExtensionGroups([])); + } + }, [isOpen]); const [releaseTypes, setReleaseTypes] = useState(); const [isLoadingReleaseTypes, setIsLoadingReleaseTypes] = useState(false); const [releaseTypesError, setReleaseTypesError] = useState( @@ -406,6 +422,7 @@ export function CreateProfileDialog({ wayfernConfig: finalWayfernConfig, groupId: selectedGroupId !== "default" ? selectedGroupId : undefined, + extensionGroupId: selectedExtensionGroupId, ephemeral, }); } else { @@ -430,6 +447,7 @@ export function CreateProfileDialog({ camoufoxConfig: finalCamoufoxConfig, groupId: selectedGroupId !== "default" ? selectedGroupId : undefined, + extensionGroupId: selectedExtensionGroupId, ephemeral, }); } @@ -1074,6 +1092,37 @@ export function CreateProfileDialog({ )} + + {/* Extension Group */} + {extensionGroups.length > 0 && ( +
+ + +
+ )} diff --git a/src/components/extension-management-dialog.tsx b/src/components/extension-management-dialog.tsx new file mode 100644 index 0000000..248f96b --- /dev/null +++ b/src/components/extension-management-dialog.tsx @@ -0,0 +1,716 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { GoPlus } from "react-icons/go"; +import { LuPencil, LuPuzzle, LuTrash2, LuUpload } from "react-icons/lu"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ProBadge } from "@/components/ui/pro-badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import type { Extension, ExtensionGroup } from "@/types"; +import { DeleteConfirmationDialog } from "./delete-confirmation-dialog"; +import { RippleButton } from "./ui/ripple"; + +interface ExtensionManagementDialogProps { + isOpen: boolean; + onClose: () => void; + limitedMode: boolean; +} + +export function ExtensionManagementDialog({ + isOpen, + onClose, + limitedMode, +}: ExtensionManagementDialogProps) { + const { t } = useTranslation(); + const [extensions, setExtensions] = useState([]); + const [extensionGroups, setExtensionGroups] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + // Extension upload state + const [isUploading, setIsUploading] = useState(false); + const [extensionName, setExtensionName] = useState(""); + const [showUploadForm, setShowUploadForm] = useState(false); + const [pendingFile, setPendingFile] = useState<{ + name: string; + data: number[]; + } | null>(null); + + // Group state + const [showCreateGroup, setShowCreateGroup] = useState(false); + const [newGroupName, setNewGroupName] = useState(""); + const [editingGroup, setEditingGroup] = useState(null); + const [editGroupName, setEditGroupName] = useState(""); + + // Delete state + const [extensionToDelete, setExtensionToDelete] = useState( + null, + ); + const [groupToDelete, setGroupToDelete] = useState( + null, + ); + const [isDeleting, setIsDeleting] = useState(false); + + // Tab + const [activeTab, setActiveTab] = useState<"extensions" | "groups">( + "extensions", + ); + + const loadData = useCallback(async () => { + if (limitedMode) return; + setIsLoading(true); + try { + const [exts, groups] = await Promise.all([ + invoke("list_extensions"), + invoke("list_extension_groups"), + ]); + setExtensions(exts); + setExtensionGroups(groups); + } catch { + // User may not have pro subscription + setExtensions([]); + setExtensionGroups([]); + } finally { + setIsLoading(false); + } + }, [limitedMode]); + + useEffect(() => { + if (isOpen) { + void loadData(); + } + }, [isOpen, loadData]); + + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const validExtensions = [".xpi", ".crx", ".zip"]; + const isValid = validExtensions.some((ext) => + file.name.toLowerCase().endsWith(ext), + ); + if (!isValid) { + showErrorToast(t("extensions.invalidFileType")); + return; + } + + const reader = new FileReader(); + reader.onload = (event) => { + const arrayBuffer = event.target?.result as ArrayBuffer; + const data = Array.from(new Uint8Array(arrayBuffer)); + const baseName = file.name + .replace(/\.(xpi|crx|zip)$/i, "") + .replace(/[-_]/g, " "); + setExtensionName(baseName); + setPendingFile({ name: file.name, data }); + setShowUploadForm(true); + }; + reader.onerror = () => { + showErrorToast(t("extensions.readError")); + }; + reader.readAsArrayBuffer(file); + + // Reset input + e.target.value = ""; + }, + [t], + ); + + const handleUpload = useCallback(async () => { + if (!pendingFile || !extensionName.trim()) return; + setIsUploading(true); + try { + await invoke("add_extension", { + name: extensionName.trim(), + fileName: pendingFile.name, + fileData: pendingFile.data, + }); + showSuccessToast(t("extensions.uploadSuccess")); + setShowUploadForm(false); + setPendingFile(null); + setExtensionName(""); + void loadData(); + } catch (err) { + showErrorToast(err instanceof Error ? err.message : String(err)); + } finally { + setIsUploading(false); + } + }, [pendingFile, extensionName, loadData, t]); + + const handleDeleteExtension = useCallback(async () => { + if (!extensionToDelete) return; + setIsDeleting(true); + try { + await invoke("delete_extension", { extensionId: extensionToDelete.id }); + showSuccessToast(t("extensions.deleteSuccess")); + setExtensionToDelete(null); + void loadData(); + } catch (err) { + showErrorToast(err instanceof Error ? err.message : String(err)); + } finally { + setIsDeleting(false); + } + }, [extensionToDelete, loadData, t]); + + const handleCreateGroup = useCallback(async () => { + if (!newGroupName.trim()) return; + try { + await invoke("create_extension_group", { name: newGroupName.trim() }); + showSuccessToast(t("extensions.groupCreateSuccess")); + setShowCreateGroup(false); + setNewGroupName(""); + void loadData(); + } catch (err) { + showErrorToast(err instanceof Error ? err.message : String(err)); + } + }, [newGroupName, loadData, t]); + + const handleUpdateGroup = useCallback(async () => { + if (!editingGroup || !editGroupName.trim()) return; + try { + await invoke("update_extension_group", { + groupId: editingGroup.id, + name: editGroupName.trim(), + }); + showSuccessToast(t("extensions.groupUpdateSuccess")); + setEditingGroup(null); + setEditGroupName(""); + void loadData(); + } catch (err) { + showErrorToast(err instanceof Error ? err.message : String(err)); + } + }, [editingGroup, editGroupName, loadData, t]); + + const handleDeleteGroup = useCallback(async () => { + if (!groupToDelete) return; + setIsDeleting(true); + try { + await invoke("delete_extension_group", { groupId: groupToDelete.id }); + showSuccessToast(t("extensions.groupDeleteSuccess")); + setGroupToDelete(null); + void loadData(); + } catch (err) { + showErrorToast(err instanceof Error ? err.message : String(err)); + } finally { + setIsDeleting(false); + } + }, [groupToDelete, loadData, t]); + + const handleAddToGroup = useCallback( + async (groupId: string, extensionId: string) => { + try { + await invoke("add_extension_to_group", { groupId, extensionId }); + void loadData(); + } catch (err) { + showErrorToast(err instanceof Error ? err.message : String(err)); + } + }, + [loadData], + ); + + const handleRemoveFromGroup = useCallback( + async (groupId: string, extensionId: string) => { + try { + await invoke("remove_extension_from_group", { groupId, extensionId }); + void loadData(); + } catch (err) { + showErrorToast(err instanceof Error ? err.message : String(err)); + } + }, + [loadData], + ); + + const getCompatibilityBadge = (compat: string[]) => { + if (compat.includes("chromium") && compat.includes("firefox")) { + return ( + {t("extensions.compatibility.both")} + ); + } + if (compat.includes("chromium")) { + return ( + + {t("extensions.compatibility.chromium")} + + ); + } + if (compat.includes("firefox")) { + return ( + + {t("extensions.compatibility.firefox")} + + ); + } + return null; + }; + + return ( + <> + + + + + + {t("extensions.title")} + {limitedMode && } + + {t("extensions.description")} + + +
+ {limitedMode && ( + <> +
+
+
+
+
+
+
+ + + {t("extensions.proRequired")} + +
+
+ + )} + +
+ {/* Tab selector */} +
+ + +
+ + {/* Notice */} +
+ {t("extensions.managedNotice")} +
+ + {activeTab === "extensions" && ( +
+
+ +
+ + +
+
+ + {/* Upload form */} + {showUploadForm && pendingFile && ( +
+
+ {t("extensions.selectedFile")}:{" "} + + {pendingFile.name} + +
+
+ setExtensionName(e.target.value)} + placeholder={t("extensions.namePlaceholder")} + className="flex-1" + /> + + {isUploading + ? t("common.buttons.loading") + : t("common.buttons.add")} + + +
+
+ )} + + {/* Extensions list */} + {isLoading ? ( +
+ {t("common.buttons.loading")} +
+ ) : extensions.length === 0 ? ( +
+ {t("extensions.empty")} +
+ ) : ( +
+ + + + + {t("common.labels.name")} + + {t("common.labels.type")} + + + {t("extensions.compatibility.label")} + + + {t("common.labels.actions")} + + + + + {extensions.map((ext) => ( + + + {ext.name} + + + + .{ext.file_type} + + + + {getCompatibilityBadge( + ext.browser_compatibility, + )} + + + + + + + + {t("extensions.delete")} + + + + + ))} + +
+
+
+ )} +
+ )} + + {activeTab === "groups" && ( +
+
+ + setShowCreateGroup(true)} + className="flex gap-2 items-center" + disabled={limitedMode} + > + + {t("extensions.createGroup")} + +
+ + {/* Create group form */} + {showCreateGroup && ( +
+ setNewGroupName(e.target.value)} + placeholder={t("extensions.groupNamePlaceholder")} + className="flex-1" + onKeyDown={(e) => { + if (e.key === "Enter") void handleCreateGroup(); + }} + /> + + {t("common.buttons.create")} + + +
+ )} + + {/* Groups list */} + {extensionGroups.length === 0 ? ( +
+ {t("extensions.noGroups")} +
+ ) : ( +
+ {extensionGroups.map((group) => ( +
+
+ {editingGroup?.id === group.id ? ( +
+ + setEditGroupName(e.target.value) + } + className="flex-1" + onKeyDown={(e) => { + if (e.key === "Enter") + void handleUpdateGroup(); + }} + /> + + {t("common.buttons.save")} + + +
+ ) : ( + <> + + {group.name} + +
+ + + + + + {t("common.buttons.edit")} + + + + + + + + {t("extensions.deleteGroup")} + + +
+ + )} +
+ + {/* Extension assignment */} +
+ {group.extension_ids.length > 0 && ( +
+ {group.extension_ids.map((extId) => { + const ext = extensions.find( + (e) => e.id === extId, + ); + if (!ext) return null; + return ( + + {ext.name} + + + ); + })} +
+ )} + {extensions.filter( + (e) => !group.extension_ids.includes(e.id), + ).length > 0 && ( + + )} +
+
+ ))} +
+ )} +
+ )} +
+
+ + + + {t("common.buttons.close")} + + + +
+ + {/* Delete extension confirmation */} + setExtensionToDelete(null)} + onConfirm={handleDeleteExtension} + title={t("extensions.deleteConfirmTitle")} + description={t("extensions.deleteConfirmDescription", { + name: extensionToDelete?.name ?? "", + })} + isLoading={isDeleting} + /> + + {/* Delete group confirmation */} + setGroupToDelete(null)} + onConfirm={handleDeleteGroup} + title={t("extensions.deleteGroupConfirmTitle")} + description={t("extensions.deleteGroupConfirmDescription", { + name: groupToDelete?.name ?? "", + })} + isLoading={isDeleting} + /> + + ); +} diff --git a/src/components/home-header.tsx b/src/components/home-header.tsx index 196ac39..4beb49f 100644 --- a/src/components/home-header.tsx +++ b/src/components/home-header.tsx @@ -2,7 +2,14 @@ import { useTranslation } from "react-i18next"; import { FaDownload } from "react-icons/fa"; import { FiWifi } from "react-icons/fi"; import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go"; -import { LuCloud, LuPlug, LuSearch, LuUsers, LuX } from "react-icons/lu"; +import { + LuCloud, + LuPlug, + LuPuzzle, + LuSearch, + LuUsers, + LuX, +} from "react-icons/lu"; import { Logo } from "./icons/logo"; import { Button } from "./ui/button"; import { CardTitle } from "./ui/card"; @@ -23,6 +30,7 @@ type Props = { onCreateProfileDialogOpen: (open: boolean) => void; onSyncConfigDialogOpen: (open: boolean) => void; onIntegrationsDialogOpen: (open: boolean) => void; + onExtensionManagementDialogOpen: (open: boolean) => void; searchQuery: string; onSearchQueryChange: (query: string) => void; }; @@ -35,6 +43,7 @@ const HomeHeader = ({ onCreateProfileDialogOpen, onSyncConfigDialogOpen, onIntegrationsDialogOpen, + onExtensionManagementDialogOpen, searchQuery, onSearchQueryChange, }: Props) => { @@ -124,6 +133,14 @@ const HomeHeader = ({ {t("header.menu.groups")} + { + onExtensionManagementDialogOpen(true); + }} + > + + {t("header.menu.extensions")} + { onSyncConfigDialogOpen(true); diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 423b27e..b36a47d 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -1582,6 +1582,7 @@ export function ProfilesDataTable({ const osName = profile.host_os ? getOSDisplayName(profile.host_os) : "another OS"; + const crossOsTooltip = t("crossOs.viewOnly", { os: osName }); const OsIcon = profile.host_os === "macos" ? FaApple @@ -1606,10 +1607,7 @@ export function ProfilesDataTable({ -

- This profile was created on {osName} and is not supported on - this system -

+

{crossOsTooltip}

); @@ -1620,14 +1618,10 @@ export function ProfilesDataTable({ const osName = profile.host_os ? getOSDisplayName(profile.host_os) : "another OS"; + const crossOsTooltip = t("crossOs.viewOnly", { os: osName }); return ( - This profile was created on {osName} and is not supported on - this system -

- } + content={

{crossOsTooltip}

} sideOffset={4} horizontalOffset={8} > @@ -2305,7 +2299,7 @@ export function ProfilesDataTable({ }, }, ], - [], + [t], ); const table = useReactTable({ @@ -2362,25 +2356,34 @@ export function ProfilesDataTable({ {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - )) + table.getRowModel().rows.map((row) => { + const rowIsCrossOs = isCrossOsProfile(row.original); + const crossOsTitle = rowIsCrossOs + ? t("crossOs.viewOnly", { + os: getOSDisplayName(row.original.host_os ?? ""), + }) + : undefined; + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ); + }) ) : ( (null); + const [extensionGroupName, setExtensionGroupName] = React.useState< + string | null + >(null); + const [bypassRules, setBypassRules] = React.useState([]); + const [newRule, setNewRule] = React.useState(""); React.useEffect(() => { if (!isOpen || !profile?.group_id) { @@ -117,11 +125,33 @@ export function ProfileInfoDialog({ })(); }, [isOpen, profile?.group_id]); + React.useEffect(() => { + if (!isOpen || !profile?.extension_group_id) { + setExtensionGroupName(null); + return; + } + (async () => { + try { + const group = await invoke<{ name: string } | null>( + "get_extension_group_for_profile", + { profileId: profile.id }, + ); + setExtensionGroupName(group?.name ?? null); + } catch { + setExtensionGroupName(null); + } + })(); + }, [isOpen, profile?.extension_group_id, profile?.id]); + React.useEffect(() => { if (!isOpen) { setCopied(false); + setNewRule(""); } - }, [isOpen]); + if (isOpen && profile) { + setBypassRules(profile.proxy_bypass_rules ?? []); + } + }, [isOpen, profile]); if (!profile) return null; @@ -163,6 +193,31 @@ export function ProfileInfoDialog({ action(); }; + const updateBypassRules = async (rules: string[]) => { + if (!profile) return; + try { + await invoke("update_profile_proxy_bypass_rules", { + profileId: profile.id, + rules, + }); + setBypassRules(rules); + } catch { + // ignore + } + }; + + const handleAddRule = () => { + const trimmed = newRule.trim(); + if (!trimmed || bypassRules.includes(trimmed)) return; + const updated = [...bypassRules, trimmed]; + setNewRule(""); + void updateBypassRules(updated); + }; + + const handleRemoveRule = (rule: string) => { + void updateBypassRules(bypassRules.filter((r) => r !== rule)); + }; + const infoFields: { label: string; value: React.ReactNode }[] = [ { label: t("profileInfo.fields.profileId"), @@ -203,6 +258,10 @@ export function ProfileInfoDialog({ label: t("profileInfo.fields.group"), value: groupName ?? t("profileInfo.values.none"), }, + { + label: t("profileInfo.fields.extensionGroup"), + value: extensionGroupName ?? t("profileInfo.values.none"), + }, { label: t("profileInfo.fields.tags"), value: @@ -349,6 +408,9 @@ export function ProfileInfoDialog({ {t("profileInfo.tabs.info")} + + {t("profileInfo.tabs.network")} + {t("profileInfo.tabs.settings")} @@ -365,6 +427,63 @@ export function ProfileInfoDialog({ ))} + +
+
+

+ {t("profileInfo.network.bypassRules")} +

+

+ {t("profileInfo.network.bypassRulesDescription")} +

+
+
+ setNewRule(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleAddRule(); + }} + placeholder={t("profileInfo.network.rulePlaceholder")} + className="flex-1 text-sm" + /> + +
+ {bypassRules.length === 0 ? ( +

+ {t("profileInfo.network.noRules")} +

+ ) : ( +
+ {bypassRules.map((rule) => ( +
+ {rule} + +
+ ))} +
+ )} +

+ {t("profileInfo.network.ruleTypes")} +

+
+
{visibleActions.map((action) => ( diff --git a/src/hooks/use-extension-events.ts b/src/hooks/use-extension-events.ts new file mode 100644 index 0000000..16bef26 --- /dev/null +++ b/src/hooks/use-extension-events.ts @@ -0,0 +1,73 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { useCallback, useEffect, useState } from "react"; +import type { Extension, ExtensionGroup } from "@/types"; + +export function useExtensionEvents() { + const [extensions, setExtensions] = useState([]); + const [extensionGroups, setExtensionGroups] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const loadExtensions = useCallback(async () => { + try { + const exts = await invoke("list_extensions"); + setExtensions(exts); + setError(null); + } catch (err: unknown) { + console.error("Failed to load extensions:", err); + setExtensions([]); + } + }, []); + + const loadExtensionGroups = useCallback(async () => { + try { + const groups = await invoke("list_extension_groups"); + setExtensionGroups(groups); + setError(null); + } catch (err: unknown) { + console.error("Failed to load extension groups:", err); + setExtensionGroups([]); + } + }, []); + + const loadAll = useCallback(async () => { + await Promise.all([loadExtensions(), loadExtensionGroups()]); + }, [loadExtensions, loadExtensionGroups]); + + useEffect(() => { + let unlisten: (() => void) | undefined; + + const setup = async () => { + try { + await loadAll(); + unlisten = await listen("extensions-changed", () => { + void loadAll(); + }); + } catch (err) { + console.error("Failed to setup extension event listeners:", err); + setError( + `Failed to setup extension event listeners: ${JSON.stringify(err)}`, + ); + } finally { + setIsLoading(false); + } + }; + + void setup(); + + return () => { + if (unlisten) unlisten(); + }; + }, [loadAll]); + + return { + extensions, + extensionGroups, + isLoading, + error, + loadExtensions, + loadExtensionGroups, + loadAll, + }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 69ca99d..845e213 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -146,7 +146,8 @@ "groups": "Groups", "syncService": "Account", "integrations": "Integrations", - "importProfile": "Import Profile" + "importProfile": "Import Profile", + "extensions": "Extensions" } }, "profiles": { @@ -682,6 +683,7 @@ "title": "Profile Details", "tabs": { "info": "Info", + "network": "Network", "settings": "Settings" }, "fields": { @@ -695,7 +697,8 @@ "syncStatus": "Sync Status", "lastLaunched": "Last Launched", "hostOs": "Host OS", - "ephemeral": "Ephemeral" + "ephemeral": "Ephemeral", + "extensionGroup": "Extension Group" }, "values": { "none": "None", @@ -703,10 +706,55 @@ "copied": "Copied!", "yes": "Yes" }, + "network": { + "bypassRules": "Proxy Bypass Rules", + "bypassRulesDescription": "Requests matching these rules will connect directly, bypassing the proxy.", + "addRule": "Add Rule", + "rulePlaceholder": "e.g. example.com, 192.168.1.*, .*\\.local", + "noRules": "No bypass rules configured.", + "ruleTypes": "Supports hostnames, IP addresses, and regex patterns." + }, "actions": { "manageCookies": "Manage Cookies" } }, + "extensions": { + "title": "Extensions", + "description": "Manage browser extensions and extension groups for your profiles.", + "upload": "Upload", + "delete": "Delete", + "extensionsTab": "Extensions", + "groupsTab": "Groups", + "managedNotice": "Extensions managed here will replace any manually installed extensions in profiles when launched.", + "proRequired": "Extension management is a Pro feature", + "empty": "No extensions uploaded yet.", + "noGroups": "No extension groups created yet.", + "createGroup": "Create Group", + "addToGroup": "Add extension...", + "removeFromGroup": "Remove from group", + "deleteGroup": "Delete group", + "extensionGroup": "Extension Group", + "compatibility": { + "label": "Compatibility", + "chromium": "Chromium", + "firefox": "Firefox", + "both": "Chromium & Firefox" + }, + "selectedFile": "Selected file", + "namePlaceholder": "Extension name", + "groupNamePlaceholder": "Group name", + "uploadSuccess": "Extension uploaded successfully", + "deleteSuccess": "Extension deleted successfully", + "groupCreateSuccess": "Extension group created successfully", + "groupUpdateSuccess": "Extension group updated successfully", + "groupDeleteSuccess": "Extension group deleted successfully", + "deleteConfirmTitle": "Delete Extension", + "deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", + "deleteGroupConfirmTitle": "Delete Extension Group", + "deleteGroupConfirmDescription": "Are you sure you want to delete the group \"{{name}}\"? This action cannot be undone.", + "invalidFileType": "Invalid file type. Please upload a .crx, .xpi, or .zip file.", + "readError": "Failed to read the extension file." + }, "pro": { "badge": "PRO", "fingerprintLocked": "Fingerprint editing is a Pro feature", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 213683e..ad8f07e 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -146,7 +146,8 @@ "groups": "Grupos", "syncService": "Cuenta", "integrations": "Integraciones", - "importProfile": "Importar Perfil" + "importProfile": "Importar Perfil", + "extensions": "Extensiones" } }, "profiles": { @@ -682,6 +683,7 @@ "title": "Detalles del Perfil", "tabs": { "info": "Info", + "network": "Red", "settings": "Configuración" }, "fields": { @@ -695,7 +697,8 @@ "syncStatus": "Estado de Sincronización", "lastLaunched": "Último Lanzamiento", "hostOs": "SO Host", - "ephemeral": "Efímero" + "ephemeral": "Efímero", + "extensionGroup": "Grupo de Extensiones" }, "values": { "none": "Ninguno", @@ -703,10 +706,55 @@ "copied": "¡Copiado!", "yes": "Sí" }, + "network": { + "bypassRules": "Reglas de Omisión de Proxy", + "bypassRulesDescription": "Las solicitudes que coincidan con estas reglas se conectarán directamente, omitiendo el proxy.", + "addRule": "Agregar Regla", + "rulePlaceholder": "ej. example.com, 192.168.1.*, .*\\.local", + "noRules": "No hay reglas de omisión configuradas.", + "ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex." + }, "actions": { "manageCookies": "Administrar Cookies" } }, + "extensions": { + "title": "Extensiones", + "description": "Administra extensiones de navegador y grupos de extensiones para tus perfiles.", + "upload": "Subir", + "delete": "Eliminar", + "extensionsTab": "Extensiones", + "groupsTab": "Grupos", + "managedNotice": "Las extensiones administradas aquí reemplazarán cualquier extensión instalada manualmente en los perfiles al iniciarlos.", + "proRequired": "La gestión de extensiones es una función Pro", + "empty": "No se han subido extensiones aún.", + "noGroups": "No se han creado grupos de extensiones aún.", + "createGroup": "Crear Grupo", + "addToGroup": "Agregar extensión...", + "removeFromGroup": "Eliminar del grupo", + "deleteGroup": "Eliminar grupo", + "extensionGroup": "Grupo de Extensiones", + "compatibility": { + "label": "Compatibilidad", + "chromium": "Chromium", + "firefox": "Firefox", + "both": "Chromium y Firefox" + }, + "selectedFile": "Archivo seleccionado", + "namePlaceholder": "Nombre de la extensión", + "groupNamePlaceholder": "Nombre del grupo", + "uploadSuccess": "Extensión subida exitosamente", + "deleteSuccess": "Extensión eliminada exitosamente", + "groupCreateSuccess": "Grupo de extensiones creado exitosamente", + "groupUpdateSuccess": "Grupo de extensiones actualizado exitosamente", + "groupDeleteSuccess": "Grupo de extensiones eliminado exitosamente", + "deleteConfirmTitle": "Eliminar Extensión", + "deleteConfirmDescription": "¿Estás seguro de que deseas eliminar \"{{name}}\"? Esta acción no se puede deshacer.", + "deleteGroupConfirmTitle": "Eliminar Grupo de Extensiones", + "deleteGroupConfirmDescription": "¿Estás seguro de que deseas eliminar el grupo \"{{name}}\"? Esta acción no se puede deshacer.", + "invalidFileType": "Tipo de archivo no válido. Suba un archivo .crx, .xpi o .zip.", + "readError": "No se pudo leer el archivo de extensión." + }, "pro": { "badge": "PRO", "fingerprintLocked": "La edición de huellas digitales es una función Pro", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 134c73c..5eb5c99 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -146,7 +146,8 @@ "groups": "Groupes", "syncService": "Compte", "integrations": "Intégrations", - "importProfile": "Importer un profil" + "importProfile": "Importer un profil", + "extensions": "Extensions" } }, "profiles": { @@ -682,6 +683,7 @@ "title": "Détails du Profil", "tabs": { "info": "Info", + "network": "Réseau", "settings": "Paramètres" }, "fields": { @@ -695,7 +697,8 @@ "syncStatus": "État de Synchronisation", "lastLaunched": "Dernier Lancement", "hostOs": "OS Hôte", - "ephemeral": "Éphémère" + "ephemeral": "Éphémère", + "extensionGroup": "Groupe d'Extensions" }, "values": { "none": "Aucun", @@ -703,10 +706,55 @@ "copied": "Copié !", "yes": "Oui" }, + "network": { + "bypassRules": "Règles de Contournement du Proxy", + "bypassRulesDescription": "Les requêtes correspondant à ces règles se connecteront directement, contournant le proxy.", + "addRule": "Ajouter une Règle", + "rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local", + "noRules": "Aucune règle de contournement configurée.", + "ruleTypes": "Prend en charge les noms d'hôte, les adresses IP et les expressions régulières." + }, "actions": { "manageCookies": "Gérer les Cookies" } }, + "extensions": { + "title": "Extensions", + "description": "Gérez les extensions de navigateur et les groupes d'extensions pour vos profils.", + "upload": "Télécharger", + "delete": "Supprimer", + "extensionsTab": "Extensions", + "groupsTab": "Groupes", + "managedNotice": "Les extensions gérées ici remplaceront toutes les extensions installées manuellement dans les profils lors du lancement.", + "proRequired": "La gestion des extensions est une fonctionnalité Pro", + "empty": "Aucune extension téléchargée pour l'instant.", + "noGroups": "Aucun groupe d'extensions créé pour l'instant.", + "createGroup": "Créer un Groupe", + "addToGroup": "Ajouter une extension...", + "removeFromGroup": "Retirer du groupe", + "deleteGroup": "Supprimer le groupe", + "extensionGroup": "Groupe d'Extensions", + "compatibility": { + "label": "Compatibilité", + "chromium": "Chromium", + "firefox": "Firefox", + "both": "Chromium et Firefox" + }, + "selectedFile": "Fichier sélectionné", + "namePlaceholder": "Nom de l'extension", + "groupNamePlaceholder": "Nom du groupe", + "uploadSuccess": "Extension téléchargée avec succès", + "deleteSuccess": "Extension supprimée avec succès", + "groupCreateSuccess": "Groupe d'extensions créé avec succès", + "groupUpdateSuccess": "Groupe d'extensions mis à jour avec succès", + "groupDeleteSuccess": "Groupe d'extensions supprimé avec succès", + "deleteConfirmTitle": "Supprimer l'Extension", + "deleteConfirmDescription": "Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cette action est irréversible.", + "deleteGroupConfirmTitle": "Supprimer le Groupe d'Extensions", + "deleteGroupConfirmDescription": "Êtes-vous sûr de vouloir supprimer le groupe \"{{name}}\" ? Cette action est irréversible.", + "invalidFileType": "Type de fichier non valide. Veuillez télécharger un fichier .crx, .xpi ou .zip.", + "readError": "Impossible de lire le fichier d'extension." + }, "pro": { "badge": "PRO", "fingerprintLocked": "La modification d'empreinte est une fonctionnalité Pro", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e70bfa9..ef3646e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -146,7 +146,8 @@ "groups": "グループ", "syncService": "アカウント", "integrations": "統合", - "importProfile": "プロファイルをインポート" + "importProfile": "プロファイルをインポート", + "extensions": "拡張機能" } }, "profiles": { @@ -682,6 +683,7 @@ "title": "プロフィール詳細", "tabs": { "info": "情報", + "network": "ネットワーク", "settings": "設定" }, "fields": { @@ -695,7 +697,8 @@ "syncStatus": "同期ステータス", "lastLaunched": "最終起動", "hostOs": "ホストOS", - "ephemeral": "エフェメラル" + "ephemeral": "エフェメラル", + "extensionGroup": "拡張機能グループ" }, "values": { "none": "なし", @@ -703,10 +706,55 @@ "copied": "コピーしました!", "yes": "はい" }, + "network": { + "bypassRules": "プロキシバイパスルール", + "bypassRulesDescription": "これらのルールに一致するリクエストは、プロキシをバイパスして直接接続します。", + "addRule": "ルールを追加", + "rulePlaceholder": "例: example.com, 192.168.1.*, .*\\.local", + "noRules": "バイパスルールは設定されていません。", + "ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。" + }, "actions": { "manageCookies": "Cookieを管理" } }, + "extensions": { + "title": "拡張機能", + "description": "プロファイル用のブラウザ拡張機能と拡張機能グループを管理します。", + "upload": "アップロード", + "delete": "削除", + "extensionsTab": "拡張機能", + "groupsTab": "グループ", + "managedNotice": "ここで管理される拡張機能は、起動時にプロファイルに手動でインストールされた拡張機能を置き換えます。", + "proRequired": "拡張機能管理はプロ機能です", + "empty": "まだ拡張機能がアップロードされていません。", + "noGroups": "まだ拡張機能グループが作成されていません。", + "createGroup": "グループを作成", + "addToGroup": "拡張機能を追加...", + "removeFromGroup": "グループから削除", + "deleteGroup": "グループを削除", + "extensionGroup": "拡張機能グループ", + "compatibility": { + "label": "互換性", + "chromium": "Chromium", + "firefox": "Firefox", + "both": "Chromium & Firefox" + }, + "selectedFile": "選択されたファイル", + "namePlaceholder": "拡張機能名", + "groupNamePlaceholder": "グループ名", + "uploadSuccess": "拡張機能が正常にアップロードされました", + "deleteSuccess": "拡張機能が正常に削除されました", + "groupCreateSuccess": "拡張機能グループが正常に作成されました", + "groupUpdateSuccess": "拡張機能グループが正常に更新されました", + "groupDeleteSuccess": "拡張機能グループが正常に削除されました", + "deleteConfirmTitle": "拡張機能を削除", + "deleteConfirmDescription": "「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。", + "deleteGroupConfirmTitle": "拡張機能グループを削除", + "deleteGroupConfirmDescription": "グループ「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。", + "invalidFileType": "無効なファイルタイプです。.crx、.xpi、または .zip ファイルをアップロードしてください。", + "readError": "拡張機能ファイルの読み取りに失敗しました。" + }, "pro": { "badge": "PRO", "fingerprintLocked": "フィンガープリント編集はプロ機能です", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 955b122..7393c3a 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -146,7 +146,8 @@ "groups": "Grupos", "syncService": "Conta", "integrations": "Integrações", - "importProfile": "Importar Perfil" + "importProfile": "Importar Perfil", + "extensions": "Extensões" } }, "profiles": { @@ -682,6 +683,7 @@ "title": "Detalhes do Perfil", "tabs": { "info": "Info", + "network": "Rede", "settings": "Configurações" }, "fields": { @@ -695,7 +697,8 @@ "syncStatus": "Status de Sincronização", "lastLaunched": "Último Lançamento", "hostOs": "SO Host", - "ephemeral": "Efêmero" + "ephemeral": "Efêmero", + "extensionGroup": "Grupo de Extensões" }, "values": { "none": "Nenhum", @@ -703,10 +706,55 @@ "copied": "Copiado!", "yes": "Sim" }, + "network": { + "bypassRules": "Regras de Bypass de Proxy", + "bypassRulesDescription": "Solicitações que correspondam a estas regras se conectarão diretamente, ignorando o proxy.", + "addRule": "Adicionar Regra", + "rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local", + "noRules": "Nenhuma regra de bypass configurada.", + "ruleTypes": "Suporta nomes de host, endereços IP e padrões regex." + }, "actions": { "manageCookies": "Gerenciar Cookies" } }, + "extensions": { + "title": "Extensões", + "description": "Gerencie extensões de navegador e grupos de extensões para seus perfis.", + "upload": "Enviar", + "delete": "Excluir", + "extensionsTab": "Extensões", + "groupsTab": "Grupos", + "managedNotice": "As extensões gerenciadas aqui substituirão quaisquer extensões instaladas manualmente nos perfis ao serem iniciados.", + "proRequired": "O gerenciamento de extensões é um recurso Pro", + "empty": "Nenhuma extensão enviada ainda.", + "noGroups": "Nenhum grupo de extensões criado ainda.", + "createGroup": "Criar Grupo", + "addToGroup": "Adicionar extensão...", + "removeFromGroup": "Remover do grupo", + "deleteGroup": "Excluir grupo", + "extensionGroup": "Grupo de Extensões", + "compatibility": { + "label": "Compatibilidade", + "chromium": "Chromium", + "firefox": "Firefox", + "both": "Chromium e Firefox" + }, + "selectedFile": "Arquivo selecionado", + "namePlaceholder": "Nome da extensão", + "groupNamePlaceholder": "Nome do grupo", + "uploadSuccess": "Extensão enviada com sucesso", + "deleteSuccess": "Extensão excluída com sucesso", + "groupCreateSuccess": "Grupo de extensões criado com sucesso", + "groupUpdateSuccess": "Grupo de extensões atualizado com sucesso", + "groupDeleteSuccess": "Grupo de extensões excluído com sucesso", + "deleteConfirmTitle": "Excluir Extensão", + "deleteConfirmDescription": "Tem certeza de que deseja excluir \"{{name}}\"? Esta ação não pode ser desfeita.", + "deleteGroupConfirmTitle": "Excluir Grupo de Extensões", + "deleteGroupConfirmDescription": "Tem certeza de que deseja excluir o grupo \"{{name}}\"? Esta ação não pode ser desfeita.", + "invalidFileType": "Tipo de arquivo inválido. Envie um arquivo .crx, .xpi ou .zip.", + "readError": "Falha ao ler o arquivo de extensão." + }, "pro": { "badge": "PRO", "fingerprintLocked": "A edição de impressão digital é um recurso Pro", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 5fe83db..f6cef7f 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -146,7 +146,8 @@ "groups": "Группы", "syncService": "Аккаунт", "integrations": "Интеграции", - "importProfile": "Импорт профиля" + "importProfile": "Импорт профиля", + "extensions": "Расширения" } }, "profiles": { @@ -682,6 +683,7 @@ "title": "Детали профиля", "tabs": { "info": "Информация", + "network": "Сеть", "settings": "Настройки" }, "fields": { @@ -695,7 +697,8 @@ "syncStatus": "Статус синхронизации", "lastLaunched": "Последний запуск", "hostOs": "ОС хоста", - "ephemeral": "Эфемерный" + "ephemeral": "Эфемерный", + "extensionGroup": "Группа расширений" }, "values": { "none": "Нет", @@ -703,10 +706,55 @@ "copied": "Скопировано!", "yes": "Да" }, + "network": { + "bypassRules": "Правила обхода прокси", + "bypassRulesDescription": "Запросы, соответствующие этим правилам, будут подключаться напрямую, минуя прокси.", + "addRule": "Добавить правило", + "rulePlaceholder": "напр. example.com, 192.168.1.*, .*\\.local", + "noRules": "Правила обхода не настроены.", + "ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений." + }, "actions": { "manageCookies": "Управление Cookie" } }, + "extensions": { + "title": "Расширения", + "description": "Управляйте расширениями браузера и группами расширений для ваших профилей.", + "upload": "Загрузить", + "delete": "Удалить", + "extensionsTab": "Расширения", + "groupsTab": "Группы", + "managedNotice": "Расширения, управляемые здесь, заменят все вручную установленные расширения в профилях при запуске.", + "proRequired": "Управление расширениями — функция Pro", + "empty": "Расширения ещё не загружены.", + "noGroups": "Группы расширений ещё не созданы.", + "createGroup": "Создать группу", + "addToGroup": "Добавить расширение...", + "removeFromGroup": "Удалить из группы", + "deleteGroup": "Удалить группу", + "extensionGroup": "Группа расширений", + "compatibility": { + "label": "Совместимость", + "chromium": "Chromium", + "firefox": "Firefox", + "both": "Chromium и Firefox" + }, + "selectedFile": "Выбранный файл", + "namePlaceholder": "Название расширения", + "groupNamePlaceholder": "Название группы", + "uploadSuccess": "Расширение успешно загружено", + "deleteSuccess": "Расширение успешно удалено", + "groupCreateSuccess": "Группа расширений успешно создана", + "groupUpdateSuccess": "Группа расширений успешно обновлена", + "groupDeleteSuccess": "Группа расширений успешно удалена", + "deleteConfirmTitle": "Удалить расширение", + "deleteConfirmDescription": "Вы уверены, что хотите удалить «{{name}}»? Это действие нельзя отменить.", + "deleteGroupConfirmTitle": "Удалить группу расширений", + "deleteGroupConfirmDescription": "Вы уверены, что хотите удалить группу «{{name}}»? Это действие нельзя отменить.", + "invalidFileType": "Недопустимый тип файла. Загрузите файл .crx, .xpi или .zip.", + "readError": "Не удалось прочитать файл расширения." + }, "pro": { "badge": "PRO", "fingerprintLocked": "Редактирование отпечатка — функция Pro", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8652db3..8a55786 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -146,7 +146,8 @@ "groups": "分组", "syncService": "账户", "integrations": "集成", - "importProfile": "导入配置文件" + "importProfile": "导入配置文件", + "extensions": "扩展程序" } }, "profiles": { @@ -682,6 +683,7 @@ "title": "配置文件详情", "tabs": { "info": "信息", + "network": "网络", "settings": "设置" }, "fields": { @@ -695,7 +697,8 @@ "syncStatus": "同步状态", "lastLaunched": "上次启动", "hostOs": "主机操作系统", - "ephemeral": "临时" + "ephemeral": "临时", + "extensionGroup": "扩展程序组" }, "values": { "none": "无", @@ -703,10 +706,55 @@ "copied": "已复制!", "yes": "是" }, + "network": { + "bypassRules": "代理绕过规则", + "bypassRulesDescription": "匹配这些规则的请求将直接连接,绕过代理。", + "addRule": "添加规则", + "rulePlaceholder": "例如 example.com, 192.168.1.*, .*\\.local", + "noRules": "未配置绕过规则。", + "ruleTypes": "支持主机名、IP地址和正则表达式模式。" + }, "actions": { "manageCookies": "管理 Cookie" } }, + "extensions": { + "title": "扩展程序", + "description": "管理配置文件的浏览器扩展程序和扩展程序组。", + "upload": "上传", + "delete": "删除", + "extensionsTab": "扩展程序", + "groupsTab": "分组", + "managedNotice": "此处管理的扩展程序将在启动时替换配置文件中手动安装的所有扩展程序。", + "proRequired": "扩展程序管理是 Pro 功能", + "empty": "尚未上传任何扩展程序。", + "noGroups": "尚未创建任何扩展程序组。", + "createGroup": "创建分组", + "addToGroup": "添加扩展程序...", + "removeFromGroup": "从分组中移除", + "deleteGroup": "删除分组", + "extensionGroup": "扩展程序组", + "compatibility": { + "label": "兼容性", + "chromium": "Chromium", + "firefox": "Firefox", + "both": "Chromium 和 Firefox" + }, + "selectedFile": "已选文件", + "namePlaceholder": "扩展程序名称", + "groupNamePlaceholder": "分组名称", + "uploadSuccess": "扩展程序上传成功", + "deleteSuccess": "扩展程序删除成功", + "groupCreateSuccess": "扩展程序组创建成功", + "groupUpdateSuccess": "扩展程序组更新成功", + "groupDeleteSuccess": "扩展程序组删除成功", + "deleteConfirmTitle": "删除扩展程序", + "deleteConfirmDescription": "确定要删除「{{name}}」吗?此操作无法撤消。", + "deleteGroupConfirmTitle": "删除扩展程序组", + "deleteGroupConfirmDescription": "确定要删除分组「{{name}}」吗?此操作无法撤消。", + "invalidFileType": "无效的文件类型。请上传 .crx、.xpi 或 .zip 文件。", + "readError": "读取扩展程序文件失败。" + }, "pro": { "badge": "PRO", "fingerprintLocked": "指纹编辑是 Pro 功能", diff --git a/src/types.ts b/src/types.ts index f4ff0b7..7e4267c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,30 @@ export interface BrowserProfile { last_sync?: number; // Timestamp of last successful sync (epoch seconds) host_os?: string; // OS where profile was created ("macos", "windows", "linux") ephemeral?: boolean; + extension_group_id?: string; + proxy_bypass_rules?: string[]; +} + +export interface Extension { + id: string; + name: string; + file_name: string; + file_type: string; + browser_compatibility: string[]; + created_at: number; + updated_at: number; + sync_enabled?: boolean; + last_sync?: number; +} + +export interface ExtensionGroup { + id: string; + name: string; + extension_ids: string[]; + created_at: number; + updated_at: number; + sync_enabled?: boolean; + last_sync?: number; } export type SyncMode = "Disabled" | "Regular" | "Encrypted";