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, #[serde(default)] pub version: Option, #[serde(default)] pub description: Option, #[serde(default)] pub author: Option, #[serde(default)] pub homepage_url: 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, } } fn find_zip_start(data: &[u8]) -> usize { for i in 0..data.len().saturating_sub(3) { if data[i] == 0x50 && data[i + 1] == 0x4B && data[i + 2] == 0x03 && data[i + 3] == 0x04 { return i; } } 0 } #[allow(clippy::type_complexity)] fn extract_manifest_metadata( file_data: &[u8], file_type: &str, ) -> ( Option, Option, Option, Option, Option, ) { let zip_start = if file_type == "crx" { find_zip_start(file_data) } else { 0 }; let cursor = std::io::Cursor::new(&file_data[zip_start..]); let mut archive = match zip::ZipArchive::new(cursor) { Ok(a) => a, Err(_) => return (None, None, None, None, None), }; let manifest_content = if let Ok(mut file) = archive.by_name("manifest.json") { let mut contents = String::new(); if std::io::Read::read_to_string(&mut file, &mut contents).is_ok() { Some(contents) } else { None } } else { None }; let manifest_content = match manifest_content { Some(c) => c, None => return (None, None, None, None, None), }; let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) { Ok(v) => v, Err(_) => return (None, None, None, None, None), }; let name = manifest .get("name") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let version = manifest .get("version") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let description = manifest .get("description") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let author = manifest .get("author") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let homepage_url = manifest .get("homepage_url") .or_else(|| manifest.get("homepage")) .and_then(|v| v.as_str()) .map(|s| s.to_string()); (name, version, description, author, homepage_url) } fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec, String)> { let zip_start = if file_type == "crx" { find_zip_start(file_data) } else { 0 }; let cursor = std::io::Cursor::new(&file_data[zip_start..]); let mut archive = match zip::ZipArchive::new(cursor) { Ok(a) => a, Err(_) => return None, }; let icon_path = { let manifest_content = if let Ok(mut file) = archive.by_name("manifest.json") { let mut contents = String::new(); if std::io::Read::read_to_string(&mut file, &mut contents).is_ok() { Some(contents) } else { None } } else { None }; let manifest_content = manifest_content?; let manifest: serde_json::Value = serde_json::from_str(&manifest_content).ok()?; let mut best_path: Option = None; let mut best_size: u32 = 0; if let Some(icons) = manifest.get("icons").and_then(|v| v.as_object()) { for (size_str, path_val) in icons { if let (Ok(size), Some(path)) = (size_str.parse::(), path_val.as_str()) { if size > best_size { best_size = size; best_path = Some(path.to_string()); } } } } if best_path.is_none() { for key in &["action", "browser_action"] { if let Some(action) = manifest.get(*key) { if let Some(icon) = action.get("default_icon") { if let Some(path) = icon.as_str() { best_path = Some(path.to_string()); } else if let Some(icons) = icon.as_object() { for (size_str, path_val) in icons { if let (Ok(size), Some(path)) = (size_str.parse::(), path_val.as_str()) { if size > best_size { best_size = size; best_path = Some(path.to_string()); } } } } } } } } best_path }; let icon_path = icon_path?; let clean_path = icon_path.trim_start_matches('/'); let mut file = archive.by_name(clean_path).ok()?; let mut data = Vec::new(); std::io::Read::read_to_end(&mut file, &mut data).ok()?; let ext = clean_path .rsplit('.') .next() .unwrap_or("png") .to_lowercase(); Some((data, ext)) } 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 (manifest_name, version, description, author, homepage_url) = extract_manifest_metadata(&file_data, &file_type); let final_name = if manifest_name.is_some() { manifest_name.clone().unwrap_or(name) } else { name }; let ext = Extension { id: uuid::Uuid::new_v4().to_string(), name: final_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, version, description, author, homepage_url, }; let file_dir = self.get_file_dir(&ext.id); fs::create_dir_all(&file_dir)?; fs::write(file_dir.join(&file_name), &file_data)?; if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&file_data, &ext.file_type) { let icon_path = self .get_extension_dir(&ext.id) .join(format!("icon.{icon_ext}")); let _ = fs::write(icon_path, icon_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)?; let explicit_name_provided = name.is_some(); 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); let (manifest_name, version, description, author, homepage_url) = extract_manifest_metadata(&data, &new_file_type); if let Some(v) = version { ext.version = Some(v); } if let Some(d) = description { ext.description = Some(d); } if let Some(a) = author { ext.author = Some(a); } if let Some(h) = homepage_url { ext.homepage_url = Some(h); } if let Some(mn) = manifest_name { if !explicit_name_provided { ext.name = mn; } } if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&data, &new_file_type) { let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}")); let _ = fs::write(icon_path, icon_data); } } 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", "wayfern" => "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", "wayfern" => "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) } pub fn ensure_icons_extracted(&self) { let extensions = match self.list_extensions() { Ok(exts) => exts, Err(_) => return, }; for ext in extensions { let ext_dir = self.get_extension_dir(&ext.id); let has_icon = ext_dir .read_dir() .map(|entries| { entries .filter_map(|e| e.ok()) .any(|e| e.file_name().to_string_lossy().starts_with("icon.")) }) .unwrap_or(false); if has_icon { continue; } let file_dir = self.get_file_dir(&ext.id); let file_path = file_dir.join(&ext.file_name); if let Ok(file_data) = fs::read(&file_path) { if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&file_data, &ext.file_type) { let icon_path = ext_dir.join(format!("icon.{icon_ext}")); let _ = fs::write(icon_path, icon_data); } } if ext.version.is_none() && ext.description.is_none() { let file_path = file_dir.join(&ext.file_name); if let Ok(file_data) = fs::read(&file_path) { let (manifest_name, version, description, author, homepage_url) = extract_manifest_metadata(&file_data, &ext.file_type); if version.is_some() || description.is_some() || author.is_some() || homepage_url.is_some() || manifest_name.is_some() { let mut updated_ext = ext.clone(); if let Some(v) = version { updated_ext.version = Some(v); } if let Some(d) = description { updated_ext.description = Some(d); } if let Some(a) = author { updated_ext.author = Some(a); } if let Some(h) = homepage_url { updated_ext.homepage_url = Some(h); } let metadata_path = self.get_metadata_path(&ext.id); if let Ok(json) = serde_json::to_string_pretty(&updated_ext) { let _ = fs::write(metadata_path, json); } } } } } } pub fn get_extension_icon(&self, ext_id: &str) -> Option { let ext_dir = self.get_extension_dir(ext_id); let entries = ext_dir.read_dir().ok()?; for entry in entries.filter_map(|e| e.ok()) { let name = entry.file_name().to_string_lossy().to_string(); if name.starts_with("icon.") { let icon_path = entry.path(); let data = fs::read(&icon_path).ok()?; let ext = name.rsplit('.').next().unwrap_or("png"); let mime = match ext { "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "svg" => "image/svg+xml", "gif" => "image/gif", "webp" => "image/webp", _ => "image/png", }; use base64::Engine; let b64 = base64::engine::general_purpose::STANDARD.encode(&data); return Some(format!("data:{};base64,{}", mime, b64)); } } None } } // 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> { let mgr = EXTENSION_MANAGER.lock().unwrap(); mgr .list_extensions() .map_err(|e| format!("Failed to list extensions: {e}")) } #[tauri::command] pub fn get_extension_icon(extension_id: String) -> Option { let manager = crate::extension_manager::ExtensionManager::new(); manager.get_extension_icon(&extension_id) } #[tauri::command] pub async fn add_extension( name: String, file_name: String, file_data: Vec, ) -> Result { 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 { 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> { 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> { 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 { 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 { 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> { 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 { 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 { 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 { // 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()); } }