feat: extension management

This commit is contained in:
zhom
2026-03-02 07:26:42 +04:00
parent a723c8b30b
commit 8a96d18e46
36 changed files with 3915 additions and 86 deletions
+107
View File
@@ -78,6 +78,7 @@ pub struct UpdateProfileRequest {
pub camoufox_config: Option<serde_json::Value>,
pub group_id: Option<String>,
pub tags: Option<Vec<String>>,
pub extension_group_id: Option<String>,
}
#[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<ApiServerState>,
) -> Result<Json<Vec<crate::extension_manager::Extension>>, 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<ApiServerState>,
) -> Result<Json<Vec<crate::extension_manager::ExtensionGroup>>, 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<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
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<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
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,
+5
View File
@@ -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<Option<PathBuf>> = 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]
+10
View File
@@ -61,6 +61,10 @@ impl AutoUpdater {
let mut browser_profiles: HashMap<String, Vec<BrowserProfile>> = 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(),
}
}
+10 -1
View File
@@ -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::<u16>("port").copied();
let profile_id = start_matches.get_one::<String>("profile-id").cloned();
let bypass_rules: Vec<String> = start_matches
.get_one::<String>("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
+51
View File
@@ -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<dyn std::error::Error + Send + Sync> {
@@ -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
{
+2
View File
@@ -273,6 +273,8 @@ mod tests {
last_sync: None,
host_os: None,
ephemeral,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
}
}
File diff suppressed because it is too large Load Diff
+29 -2
View File
@@ -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
+257 -2
View File
@@ -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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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]
+73 -1
View File
@@ -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<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
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<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
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<String>,
) -> Result<BrowserProfile, String> {
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,
+4
View File
@@ -57,6 +57,10 @@ pub struct BrowserProfile {
pub host_os: Option<String>, // OS where profile was created ("macos", "windows", "linux")
#[serde(default)]
pub ephemeral: bool,
#[serde(default)]
pub extension_group_id: Option<String>,
#[serde(default)]
pub proxy_bypass_rules: Vec<String>,
}
pub fn default_release_type() -> String {
+2
View File
@@ -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
+8
View File
@@ -1192,6 +1192,7 @@ impl ProxyManager {
proxy_settings: Option<&ProxySettings>,
browser_pid: u32,
profile_id: Option<&str>,
bypass_rules: Vec<String>,
) -> Result<ProxySettings, String> {
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
+5 -3
View File
@@ -12,13 +12,14 @@ pub async fn start_proxy_process(
upstream_url: Option<String>,
port: Option<u16>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
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<String>,
port: Option<u16>,
profile_id: Option<String>,
bypass_rules: Vec<String>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
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
+67 -12
View File
@@ -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<Vec<CompiledRule>>,
}
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<S> {
inner: S,
@@ -133,19 +166,21 @@ impl AsyncWrite for PrependReader {
async fn handle_request(
req: Request<hyper::body::Incoming>,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
) -> Result<Response<Full<Bytes>>, 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<hyper::body::Incoming>,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
) -> Result<Response<Full<Bytes>>, 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<hyper::body::Incoming>,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
) -> Result<Response<Full<Bytes>>, 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<dyn std::er
}
});
let bypass_matcher = BypassMatcher::new(&config.bypass_rules);
// Keep the runtime alive with an infinite loop
// This ensures the process doesn't exit even if there are no active connections
loop {
@@ -1014,6 +1059,7 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
log::error!("DEBUG: Accepted connection from {:?}", peer_addr);
let upstream = upstream_url.clone();
let matcher = bypass_matcher.clone();
tokio::task::spawn(async move {
// Read first bytes to detect CONNECT requests
@@ -1108,7 +1154,9 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
"DEBUG: Handling CONNECT manually for: {}",
String::from_utf8_lossy(&full_request[..full_request.len().min(200)])
);
if let Err(e) = handle_connect_from_buffer(stream, full_request, upstream).await {
if let Err(e) =
handle_connect_from_buffer(stream, full_request, upstream, matcher).await
{
log::error!("Error handling CONNECT request: {:?}", e);
} else {
log::error!("DEBUG: CONNECT handled successfully");
@@ -1130,7 +1178,8 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
inner: stream,
};
let io = TokioIo::new(prepended_reader);
let service = service_fn(move |req| handle_request(req, upstream.clone()));
let service =
service_fn(move |req| handle_request(req, upstream.clone(), matcher.clone()));
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
log::error!("Error serving connection: {:?}", err);
@@ -1156,6 +1205,7 @@ async fn handle_connect_from_buffer(
mut client_stream: TcpStream,
request_buffer: Vec<u8>,
upstream_url: Option<String>,
bypass_matcher: BypassMatcher,
) -> Result<(), Box<dyn std::error::Error>> {
// 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)?;
+8
View File
@@ -12,6 +12,8 @@ pub struct ProxyConfig {
pub pid: Option<u32>,
#[serde(default)]
pub profile_id: Option<String>,
#[serde(default)]
pub bypass_rules: Vec<String>,
}
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<String>) -> Self {
self.bypass_rules = bypass_rules;
self
}
}
pub fn get_storage_dir() -> PathBuf {
+503
View File
@@ -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<Utc> = 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<Utc> = 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<UnsyncedEntityCounts, String> {
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(())
}
+3 -3
View File
@@ -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};
+136 -2
View File
@@ -35,6 +35,8 @@ pub struct SyncScheduler {
pending_proxies: Arc<Mutex<HashSet<String>>>,
pending_groups: Arc<Mutex<HashSet<String>>>,
pending_vpns: Arc<Mutex<HashSet<String>>>,
pending_extensions: Arc<Mutex<HashSet<String>>>,
pending_extension_groups: Arc<Mutex<HashSet<String>>>,
pending_tombstones: Arc<Mutex<Vec<(String, String)>>>,
running_profiles: Arc<Mutex<HashSet<String>>>,
in_flight_profiles: Arc<Mutex<HashSet<String>>>,
@@ -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<String> = {
let mut pending = self.pending_extensions.lock().await;
let list: Vec<String> = 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<String> = {
let mut pending = self.pending_extension_groups.lock().await;
let list: Vec<String> = 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", ());
}
}
}
_ => {}
}
}
+22
View File
@@ -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
}
+6
View File
@@ -396,6 +396,7 @@ impl WayfernManager {
url: Option<&str>,
proxy_url: Option<&str>,
ephemeral: bool,
extension_paths: &[String],
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
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
}
+39 -14
View File
@@ -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<BrowserProfile>("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<BrowserProfile>(
"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}
/>
<ExtensionManagementDialog
isOpen={extensionManagementDialogOpen}
onClose={() => setExtensionManagementDialogOpen(false)}
limitedMode={!crossOsUnlocked}
/>
<GroupAssignmentDialog
isOpen={groupAssignmentDialogOpen}
onClose={() => {
+49
View File
@@ -74,6 +74,7 @@ interface CreateProfileDialogProps {
camoufoxConfig?: CamoufoxConfig;
wayfernConfig?: WayfernConfig;
groupId?: string;
extensionGroupId?: string;
ephemeral?: boolean;
}) => Promise<void>;
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<string>();
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<BrowserReleaseTypes>();
const [isLoadingReleaseTypes, setIsLoadingReleaseTypes] = useState(false);
const [releaseTypesError, setReleaseTypesError] = useState<string | null>(
@@ -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({
</div>
)}
</div>
{/* Extension Group */}
{extensionGroups.length > 0 && (
<div className="space-y-2">
<Label>{t("extensions.extensionGroup")}</Label>
<Select
value={selectedExtensionGroupId || "none"}
onValueChange={(val) =>
setSelectedExtensionGroupId(
val === "none" ? undefined : val,
)
}
>
<SelectTrigger>
<SelectValue
placeholder={t("profileInfo.values.none")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("profileInfo.values.none")}
</SelectItem>
{extensionGroups.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name} ({g.extension_ids.length})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</TabsContent>
@@ -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<Extension[]>([]);
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
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<ExtensionGroup | null>(null);
const [editGroupName, setEditGroupName] = useState("");
// Delete state
const [extensionToDelete, setExtensionToDelete] = useState<Extension | null>(
null,
);
const [groupToDelete, setGroupToDelete] = useState<ExtensionGroup | null>(
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<Extension[]>("list_extensions"),
invoke<ExtensionGroup[]>("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<HTMLInputElement>) => {
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 (
<Badge variant="secondary">{t("extensions.compatibility.both")}</Badge>
);
}
if (compat.includes("chromium")) {
return (
<Badge variant="secondary">
{t("extensions.compatibility.chromium")}
</Badge>
);
}
if (compat.includes("firefox")) {
return (
<Badge variant="secondary">
{t("extensions.compatibility.firefox")}
</Badge>
);
}
return null;
};
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuPuzzle className="w-5 h-5" />
{t("extensions.title")}
{limitedMode && <ProBadge />}
</DialogTitle>
<DialogDescription>{t("extensions.description")}</DialogDescription>
</DialogHeader>
<div className="relative">
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
<span className="text-sm font-medium text-muted-foreground">
{t("extensions.proRequired")}
</span>
</div>
</div>
</>
)}
<div className="space-y-4">
{/* Tab selector */}
<div className="flex gap-2 border-b">
<button
type="button"
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "extensions"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActiveTab("extensions")}
disabled={limitedMode}
>
{t("extensions.extensionsTab")}
</button>
<button
type="button"
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "groups"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActiveTab("groups")}
disabled={limitedMode}
>
{t("extensions.groupsTab")}
</button>
</div>
{/* Notice */}
<div className="rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
{t("extensions.managedNotice")}
</div>
{activeTab === "extensions" && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<Label>{t("extensions.extensionsTab")}</Label>
<div>
<label htmlFor="ext-file-input">
<RippleButton
size="sm"
className="flex gap-2 items-center"
disabled={limitedMode}
onClick={() =>
document.getElementById("ext-file-input")?.click()
}
>
<LuUpload className="w-4 h-4" />
{t("extensions.upload")}
</RippleButton>
</label>
<input
id="ext-file-input"
type="file"
accept=".xpi,.crx,.zip"
className="hidden"
onChange={handleFileSelect}
disabled={limitedMode}
/>
</div>
</div>
{/* Upload form */}
{showUploadForm && pendingFile && (
<div className="space-y-3 rounded-md border p-3">
<div className="text-sm text-muted-foreground">
{t("extensions.selectedFile")}:{" "}
<span className="font-medium text-foreground">
{pendingFile.name}
</span>
</div>
<div className="flex gap-2">
<Input
value={extensionName}
onChange={(e) => setExtensionName(e.target.value)}
placeholder={t("extensions.namePlaceholder")}
className="flex-1"
/>
<RippleButton
size="sm"
onClick={handleUpload}
disabled={isUploading || !extensionName.trim()}
>
{isUploading
? t("common.buttons.loading")
: t("common.buttons.add")}
</RippleButton>
<Button
size="sm"
variant="outline"
onClick={() => {
setShowUploadForm(false);
setPendingFile(null);
setExtensionName("");
}}
>
{t("common.buttons.cancel")}
</Button>
</div>
</div>
)}
{/* Extensions list */}
{isLoading ? (
<div className="text-sm text-muted-foreground">
{t("common.buttons.loading")}
</div>
) : extensions.length === 0 ? (
<div className="text-sm text-muted-foreground">
{t("extensions.empty")}
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[200px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("common.labels.name")}</TableHead>
<TableHead className="w-24">
{t("common.labels.type")}
</TableHead>
<TableHead className="w-32">
{t("extensions.compatibility.label")}
</TableHead>
<TableHead className="w-20">
{t("common.labels.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{extensions.map((ext) => (
<TableRow key={ext.id}>
<TableCell className="font-medium">
{ext.name}
</TableCell>
<TableCell>
<Badge variant="outline">
.{ext.file_type}
</Badge>
</TableCell>
<TableCell>
{getCompatibilityBadge(
ext.browser_compatibility,
)}
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() =>
setExtensionToDelete(ext)
}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("extensions.delete")}
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
)}
{activeTab === "groups" && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<Label>{t("extensions.groupsTab")}</Label>
<RippleButton
size="sm"
onClick={() => setShowCreateGroup(true)}
className="flex gap-2 items-center"
disabled={limitedMode}
>
<GoPlus className="w-4 h-4" />
{t("extensions.createGroup")}
</RippleButton>
</div>
{/* Create group form */}
{showCreateGroup && (
<div className="flex gap-2 items-center">
<Input
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder={t("extensions.groupNamePlaceholder")}
className="flex-1"
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreateGroup();
}}
/>
<RippleButton
size="sm"
onClick={handleCreateGroup}
disabled={!newGroupName.trim()}
>
{t("common.buttons.create")}
</RippleButton>
<Button
size="sm"
variant="outline"
onClick={() => {
setShowCreateGroup(false);
setNewGroupName("");
}}
>
{t("common.buttons.cancel")}
</Button>
</div>
)}
{/* Groups list */}
{extensionGroups.length === 0 ? (
<div className="text-sm text-muted-foreground">
{t("extensions.noGroups")}
</div>
) : (
<div className="space-y-3">
{extensionGroups.map((group) => (
<div
key={group.id}
className="rounded-md border p-3 space-y-2"
>
<div className="flex justify-between items-center">
{editingGroup?.id === group.id ? (
<div className="flex gap-2 items-center flex-1">
<Input
value={editGroupName}
onChange={(e) =>
setEditGroupName(e.target.value)
}
className="flex-1"
onKeyDown={(e) => {
if (e.key === "Enter")
void handleUpdateGroup();
}}
/>
<RippleButton
size="sm"
onClick={handleUpdateGroup}
>
{t("common.buttons.save")}
</RippleButton>
<Button
size="sm"
variant="outline"
onClick={() => setEditingGroup(null)}
>
{t("common.buttons.cancel")}
</Button>
</div>
) : (
<>
<span className="font-medium">
{group.name}
</span>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingGroup(group);
setEditGroupName(group.name);
}}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("common.buttons.edit")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setGroupToDelete(group)}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("extensions.deleteGroup")}
</TooltipContent>
</Tooltip>
</div>
</>
)}
</div>
{/* Extension assignment */}
<div className="space-y-1">
{group.extension_ids.length > 0 && (
<div className="flex flex-wrap gap-1">
{group.extension_ids.map((extId) => {
const ext = extensions.find(
(e) => e.id === extId,
);
if (!ext) return null;
return (
<Badge
key={extId}
variant="secondary"
className="flex items-center gap-1"
>
{ext.name}
<button
type="button"
className="ml-1 hover:text-destructive"
onClick={() =>
handleRemoveFromGroup(group.id, extId)
}
>
×
</button>
</Badge>
);
})}
</div>
)}
{extensions.filter(
(e) => !group.extension_ids.includes(e.id),
).length > 0 && (
<Select
value=""
onValueChange={(extId) =>
handleAddToGroup(group.id, extId)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue
placeholder={t("extensions.addToGroup")}
/>
</SelectTrigger>
<SelectContent>
{extensions
.filter(
(e) =>
!group.extension_ids.includes(e.id),
)
.map((ext) => (
<SelectItem key={ext.id} value={ext.id}>
{ext.name} (.{ext.file_type})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete extension confirmation */}
<DeleteConfirmationDialog
isOpen={extensionToDelete !== null}
onClose={() => setExtensionToDelete(null)}
onConfirm={handleDeleteExtension}
title={t("extensions.deleteConfirmTitle")}
description={t("extensions.deleteConfirmDescription", {
name: extensionToDelete?.name ?? "",
})}
isLoading={isDeleting}
/>
{/* Delete group confirmation */}
<DeleteConfirmationDialog
isOpen={groupToDelete !== null}
onClose={() => setGroupToDelete(null)}
onConfirm={handleDeleteGroup}
title={t("extensions.deleteGroupConfirmTitle")}
description={t("extensions.deleteGroupConfirmDescription", {
name: groupToDelete?.name ?? "",
})}
isLoading={isDeleting}
/>
</>
);
}
+18 -1
View File
@@ -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 = ({
<LuUsers className="mr-2 w-4 h-4" />
{t("header.menu.groups")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onExtensionManagementDialogOpen(true);
}}
>
<LuPuzzle className="mr-2 w-4 h-4" />
{t("header.menu.extensions")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onSyncConfigDialogOpen(true);
+33 -30
View File
@@ -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({
</span>
</TooltipTrigger>
<TooltipContent>
<p>
This profile was created on {osName} and is not supported on
this system
</p>
<p>{crossOsTooltip}</p>
</TooltipContent>
</Tooltip>
);
@@ -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 (
<NonHoverableTooltip
content={
<p>
This profile was created on {osName} and is not supported on
this system
</p>
}
content={<p>{crossOsTooltip}</p>}
sideOffset={4}
horizontalOffset={8}
>
@@ -2305,7 +2299,7 @@ export function ProfilesDataTable({
},
},
],
[],
[t],
);
const table = useReactTable({
@@ -2362,25 +2356,34 @@ export function ProfilesDataTable({
</TableHeader>
<TableBody className="overflow-visible">
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={cn(
"overflow-visible hover:bg-accent/50",
isCrossOsProfile(row.original) && "opacity-60",
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="overflow-visible">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
table.getRowModel().rows.map((row) => {
const rowIsCrossOs = isCrossOsProfile(row.original);
const crossOsTitle = rowIsCrossOs
? t("crossOs.viewOnly", {
os: getOSDisplayName(row.original.host_os ?? ""),
})
: undefined;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
title={crossOsTitle}
className={cn(
"overflow-visible hover:bg-accent/50",
rowIsCrossOs && "opacity-60",
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="overflow-visible">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
);
})
) : (
<TableRow>
<TableCell
+120 -1
View File
@@ -13,9 +13,11 @@ import {
LuFingerprint,
LuGlobe,
LuGroup,
LuPlus,
LuRefreshCw,
LuSettings,
LuTrash2,
LuX,
} from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -26,6 +28,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ProBadge } from "@/components/ui/pro-badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
@@ -100,6 +103,11 @@ export function ProfileInfoDialog({
const { t } = useTranslation();
const [copied, setCopied] = React.useState(false);
const [groupName, setGroupName] = React.useState<string | null>(null);
const [extensionGroupName, setExtensionGroupName] = React.useState<
string | null
>(null);
const [bypassRules, setBypassRules] = React.useState<string[]>([]);
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({
<TabsTrigger value="info" className="flex-1">
{t("profileInfo.tabs.info")}
</TabsTrigger>
<TabsTrigger value="network" className="flex-1">
{t("profileInfo.tabs.network")}
</TabsTrigger>
<TabsTrigger value="settings" className="flex-1">
{t("profileInfo.tabs.settings")}
</TabsTrigger>
@@ -365,6 +427,63 @@ export function ProfileInfoDialog({
))}
</div>
</TabsContent>
<TabsContent value="network">
<div className="flex flex-col gap-3 py-2">
<div>
<h4 className="text-sm font-medium">
{t("profileInfo.network.bypassRules")}
</h4>
<p className="text-xs text-muted-foreground mt-1">
{t("profileInfo.network.bypassRulesDescription")}
</p>
</div>
<div className="flex gap-2">
<Input
value={newRule}
onChange={(e) => setNewRule(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddRule();
}}
placeholder={t("profileInfo.network.rulePlaceholder")}
className="flex-1 text-sm"
/>
<Button
size="sm"
onClick={handleAddRule}
disabled={!newRule.trim()}
>
<LuPlus className="w-4 h-4 mr-1" />
{t("profileInfo.network.addRule")}
</Button>
</div>
{bypassRules.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{t("profileInfo.network.noRules")}
</p>
) : (
<div className="flex flex-col gap-1.5 max-h-48 overflow-y-auto">
{bypassRules.map((rule) => (
<div
key={rule}
className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md bg-muted text-sm"
>
<span className="font-mono text-xs truncate">{rule}</span>
<button
type="button"
onClick={() => handleRemoveRule(rule)}
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
>
<LuX className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
{t("profileInfo.network.ruleTypes")}
</p>
</div>
</TabsContent>
<TabsContent value="settings">
<div className="flex flex-col py-1">
{visibleActions.map((action) => (
+73
View File
@@ -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<Extension[]>([]);
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadExtensions = useCallback(async () => {
try {
const exts = await invoke<Extension[]>("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<ExtensionGroup[]>("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,
};
}
+50 -2
View File
@@ -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",
+50 -2
View File
@@ -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",
+50 -2
View File
@@ -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",
+50 -2
View File
@@ -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": "フィンガープリント編集はプロ機能です",
+50 -2
View File
@@ -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",
+50 -2
View File
@@ -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",
+50 -2
View File
@@ -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 功能",
+24
View File
@@ -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";