mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-03 21:58:02 +02:00
feat: extension management
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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", ());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user