mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-29 07:16:11 +02:00
1428 lines
40 KiB
Rust
1428 lines
40 KiB
Rust
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<String>,
|
|
pub created_at: u64,
|
|
pub updated_at: u64,
|
|
#[serde(default)]
|
|
pub sync_enabled: bool,
|
|
#[serde(default)]
|
|
pub last_sync: Option<u64>,
|
|
#[serde(default)]
|
|
pub version: Option<String>,
|
|
#[serde(default)]
|
|
pub description: Option<String>,
|
|
#[serde(default)]
|
|
pub author: Option<String>,
|
|
#[serde(default)]
|
|
pub homepage_url: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ExtensionGroup {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub extension_ids: Vec<String>,
|
|
pub created_at: u64,
|
|
pub updated_at: u64,
|
|
#[serde(default)]
|
|
pub sync_enabled: bool,
|
|
#[serde(default)]
|
|
pub last_sync: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct ExtensionGroupsData {
|
|
groups: Vec<ExtensionGroup>,
|
|
}
|
|
|
|
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<String> {
|
|
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<String> {
|
|
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<String>,
|
|
Option<String>,
|
|
Option<String>,
|
|
Option<String>,
|
|
Option<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, 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<u8>, 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<String> = 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::<u32>(), 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::<u32>(), 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<u8>,
|
|
) -> Result<Extension, Box<dyn std::error::Error>> {
|
|
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<Extension, Box<dyn std::error::Error>> {
|
|
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<Vec<Extension>, Box<dyn std::error::Error>> {
|
|
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::<Extension>(&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<String>,
|
|
file_name: Option<String>,
|
|
file_data: Option<Vec<u8>>,
|
|
) -> Result<Extension, Box<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<ExtensionGroupsData, Box<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<ExtensionGroup, Box<dyn std::error::Error>> {
|
|
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<ExtensionGroup, Box<dyn std::error::Error>> {
|
|
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<Vec<ExtensionGroup>, Box<dyn std::error::Error>> {
|
|
let data = self.load_groups_data()?;
|
|
Ok(data.groups)
|
|
}
|
|
|
|
pub fn update_group(
|
|
&self,
|
|
id: &str,
|
|
name: Option<String>,
|
|
extension_ids: Option<Vec<String>>,
|
|
) -> Result<ExtensionGroup, Box<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<ExtensionGroup, Box<dyn std::error::Error>> {
|
|
// 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<ExtensionGroup, Box<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
self.update_extension_internal(ext)
|
|
}
|
|
|
|
pub fn delete_extension_internal(&self, id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<Vec<String>, Box<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<usize> {
|
|
// 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<String> {
|
|
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<ExtensionManager> = Mutex::new(ExtensionManager::new());
|
|
}
|
|
|
|
// Tauri commands
|
|
|
|
#[tauri::command]
|
|
pub async fn list_extensions() -> Result<Vec<Extension>, 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<String> {
|
|
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<u8>,
|
|
) -> Result<Extension, 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<String>,
|
|
file_name: Option<String>,
|
|
file_data: Option<Vec<u8>>,
|
|
) -> Result<Extension, 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> {
|
|
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<Vec<ExtensionGroup>, 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<ExtensionGroup, 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<String>,
|
|
extension_ids: Option<Vec<String>>,
|
|
) -> Result<ExtensionGroup, 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> {
|
|
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<ExtensionGroup, 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<ExtensionGroup, 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<String>,
|
|
) -> Result<crate::profile::BrowserProfile, 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<Option<ExtensionGroup>, 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());
|
|
}
|
|
}
|