From 7d03968123bd40340de290bfdb4a8b72dec44a87 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:37:43 +0400 Subject: [PATCH] refactor: dynamic proxy --- next-env.d.ts | 2 +- src-tauri/src/api_server.rs | 72 ++- src-tauri/src/auto_updater.rs | 1 + src-tauri/src/browser.rs | 1 + src-tauri/src/browser_runner.rs | 58 ++- src-tauri/src/ephemeral_dirs.rs | 1 + src-tauri/src/lib.rs | 63 +-- src-tauri/src/mcp_server.rs | 196 ++++---- src-tauri/src/profile/manager.rs | 102 ++++ src-tauri/src/profile/types.rs | 2 + src-tauri/src/profile_importer.rs | 3 + src-tauri/src/proxy_manager.rs | 325 +++++-------- src/app/page.tsx | 2 + src/components/create-profile-dialog.tsx | 42 ++ src/components/profile-info-dialog.tsx | 109 +++-- src/components/proxy-check-button.tsx | 4 +- src/components/proxy-form-dialog.tsx | 512 +++++++-------------- src/components/proxy-management-dialog.tsx | 8 - src/i18n/locales/en.json | 9 + src/i18n/locales/es.json | 9 + src/i18n/locales/fr.json | 9 + src/i18n/locales/ja.json | 9 + src/i18n/locales/pt.json | 9 + src/i18n/locales/ru.json | 9 + src/i18n/locales/zh.json | 9 + src/types.ts | 3 +- 26 files changed, 732 insertions(+), 837 deletions(-) diff --git a/next-env.d.ts b/next-env.d.ts index b87975d..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./dist/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 01043f5..ae903df 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -31,6 +31,7 @@ pub struct ApiProfile { pub browser: String, pub version: String, pub proxy_id: Option, + pub launch_hook: Option, pub process_id: Option, pub last_launch: Option, pub release_type: String, @@ -59,6 +60,7 @@ pub struct CreateProfileRequest { pub browser: String, pub version: String, pub proxy_id: Option, + pub launch_hook: Option, pub release_type: Option, #[schema(value_type = Object)] pub camoufox_config: Option, @@ -74,6 +76,7 @@ pub struct UpdateProfileRequest { pub browser: Option, pub version: Option, pub proxy_id: Option, + pub launch_hook: Option, pub release_type: Option, #[schema(value_type = Object)] pub camoufox_config: Option, @@ -111,17 +114,13 @@ struct ApiProxyResponse { name: String, #[schema(value_type = Object)] proxy_settings: ProxySettings, - dynamic_proxy_url: Option, - dynamic_proxy_format: Option, } #[derive(Debug, Deserialize, ToSchema)] struct CreateProxyRequest { name: String, #[schema(value_type = Object)] - proxy_settings: Option, - dynamic_proxy_url: Option, - dynamic_proxy_format: Option, + proxy_settings: ProxySettings, } #[derive(Debug, Deserialize, ToSchema)] @@ -129,8 +128,6 @@ struct UpdateProxyRequest { name: Option, #[schema(value_type = Object)] proxy_settings: Option, - dynamic_proxy_url: Option, - dynamic_proxy_format: Option, } #[derive(Debug, Deserialize, ToSchema)] @@ -486,6 +483,7 @@ async fn get_profiles() -> Result, StatusCode> { browser: profile.browser.clone(), version: profile.version.clone(), proxy_id: profile.proxy_id.clone(), + launch_hook: profile.launch_hook.clone(), process_id: profile.process_id, last_launch: profile.last_launch, release_type: profile.release_type.clone(), @@ -541,6 +539,7 @@ async fn get_profile( browser: profile.browser.clone(), version: profile.version.clone(), proxy_id: profile.proxy_id.clone(), + launch_hook: profile.launch_hook.clone(), process_id: profile.process_id, last_launch: profile.last_launch, release_type: profile.release_type.clone(), @@ -612,6 +611,7 @@ async fn create_profile( request.group_id.clone(), false, None, + request.launch_hook.clone(), ) .await { @@ -641,6 +641,7 @@ async fn create_profile( browser: profile.browser, version: profile.version, proxy_id: profile.proxy_id, + launch_hook: profile.launch_hook, process_id: profile.process_id, last_launch: profile.last_launch, release_type: profile.release_type, @@ -714,6 +715,21 @@ async fn update_profile( } } + if let Some(launch_hook) = request.launch_hook { + let normalized = if launch_hook.trim().is_empty() { + None + } else { + Some(launch_hook) + }; + + if profile_manager + .update_profile_launch_hook(&state.app_handle, &id, normalized) + .is_err() + { + return Err(StatusCode::BAD_REQUEST); + } + } + if let Some(camoufox_config) = request.camoufox_config { let config: Result = serde_json::from_value(camoufox_config); match config { @@ -1035,8 +1051,6 @@ async fn get_proxies( .map(|p| ApiProxyResponse { id: p.id, name: p.name, - dynamic_proxy_url: p.dynamic_proxy_url, - dynamic_proxy_format: p.dynamic_proxy_format, proxy_settings: p.proxy_settings, }) .collect(), @@ -1070,8 +1084,6 @@ async fn get_proxy( id: proxy.id, name: proxy.name, proxy_settings: proxy.proxy_settings, - dynamic_proxy_url: proxy.dynamic_proxy_url, - dynamic_proxy_format: proxy.dynamic_proxy_format, })) } else { Err(StatusCode::NOT_FOUND) @@ -1097,27 +1109,16 @@ async fn create_proxy( State(state): State, Json(request): Json, ) -> Result, StatusCode> { - let result = if let (Some(url), Some(format)) = - (&request.dynamic_proxy_url, &request.dynamic_proxy_format) - { - PROXY_MANAGER.create_dynamic_proxy( - &state.app_handle, - request.name.clone(), - url.clone(), - format.clone(), - ) - } else if let Some(settings) = request.proxy_settings { - PROXY_MANAGER.create_stored_proxy(&state.app_handle, request.name.clone(), settings) - } else { - return Err(StatusCode::BAD_REQUEST); - }; + let result = PROXY_MANAGER.create_stored_proxy( + &state.app_handle, + request.name.clone(), + request.proxy_settings, + ); match result { Ok(proxy) => Ok(Json(ApiProxyResponse { id: proxy.id, name: proxy.name, - dynamic_proxy_url: proxy.dynamic_proxy_url, - dynamic_proxy_format: proxy.dynamic_proxy_format, proxy_settings: proxy.proxy_settings, })), Err(_) => Err(StatusCode::BAD_REQUEST), @@ -1148,26 +1149,13 @@ async fn update_proxy( State(state): State, Json(request): Json, ) -> Result, StatusCode> { - let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(&id) || request.dynamic_proxy_url.is_some(); - - let result = if is_dynamic { - PROXY_MANAGER.update_dynamic_proxy( - &state.app_handle, - &id, - request.name, - request.dynamic_proxy_url, - request.dynamic_proxy_format, - ) - } else { - PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings) - }; + let result = + PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings); match result { Ok(proxy) => Ok(Json(ApiProxyResponse { id: proxy.id, name: proxy.name, - dynamic_proxy_url: proxy.dynamic_proxy_url, - dynamic_proxy_format: proxy.dynamic_proxy_format, proxy_settings: proxy.proxy_settings, })), Err(_) => Err(StatusCode::NOT_FOUND), diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 3eed136..7898653 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -683,6 +683,7 @@ mod tests { process_id: None, proxy_id: None, vpn_id: None, + launch_hook: None, last_launch: None, release_type: "stable".to_string(), camoufox_config: None, diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index f555535..4ab742f 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -1199,6 +1199,7 @@ mod tests { version: "1.0.0".to_string(), proxy_id: None, vpn_id: None, + launch_hook: None, process_id: None, last_launch: None, release_type: "stable".to_string(), diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index d7b9855..a791f10 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -9,7 +9,7 @@ use crate::proxy_manager::PROXY_MANAGER; use crate::wayfern_manager::{WayfernConfig, WayfernManager}; use serde::Serialize; use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use sysinfo::System; pub struct BrowserRunner { pub profile_manager: &'static ProfileManager, @@ -60,8 +60,6 @@ impl BrowserRunner { /// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy, /// then resolve the proxy settings with profile-specific sid for sticky sessions. - /// Resolve proxy settings for a profile, returning an error for dynamic proxy failures. - /// Returns Ok(None) when no proxy is configured, Ok(Some) on success, Err on dynamic fetch failure. async fn resolve_proxy_with_refresh( &self, proxy_id: Option<&String>, @@ -72,13 +70,6 @@ impl BrowserRunner { None => return Ok(None), }; - // Handle dynamic proxies: fetch from URL at launch time - if PROXY_MANAGER.is_dynamic_proxy(proxy_id) { - log::info!("Fetching dynamic proxy settings for proxy {proxy_id}"); - let settings = PROXY_MANAGER.resolve_dynamic_proxy(proxy_id).await?; - return Ok(Some(settings)); - } - if PROXY_MANAGER.is_cloud_or_derived(proxy_id) { log::info!("Refreshing cloud proxy credentials before launch for proxy {proxy_id}"); CLOUD_AUTH.sync_cloud_proxy().await; @@ -92,6 +83,38 @@ impl BrowserRunner { Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id)) } + async fn resolve_launch_hook_proxy( + &self, + profile: &BrowserProfile, + ) -> Result, String> { + let Some(url) = profile.launch_hook.as_deref() else { + return Ok(None); + }; + + log::info!( + "Calling launch hook for profile {} (ID: {})", + profile.name, + profile.id + ); + + PROXY_MANAGER + .fetch_proxy_from_url(url, Duration::from_millis(500)) + .await + } + + async fn resolve_launch_proxy( + &self, + profile: &BrowserProfile, + ) -> Result, String> { + if let Some(proxy_settings) = self.resolve_launch_hook_proxy(profile).await? { + return Ok(Some(proxy_settings)); + } + + self + .resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string())) + .await + } + /// Get the executable path for a browser profile /// This is a common helper to eliminate code duplication across the codebase pub fn get_browser_executable_path( @@ -147,9 +170,8 @@ impl BrowserRunner { }); // Always start a local proxy for Camoufox (for traffic monitoring and geoip support) - // Refresh cloud proxy credentials if needed before resolving let mut upstream_proxy = self - .resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string())) + .resolve_launch_proxy(profile) .await .map_err(|e| -> Box { e.into() })?; @@ -408,9 +430,8 @@ impl BrowserRunner { }); // Always start a local proxy for Wayfern (for traffic monitoring and geoip support) - // Refresh cloud proxy credentials if needed before resolving let mut upstream_proxy = self - .resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string())) + .resolve_launch_proxy(profile) .await .map_err(|e| -> Box { e.into() })?; @@ -763,10 +784,8 @@ impl BrowserRunner { headless: bool, ) -> Result> { // Always start a local proxy for API launches - // Determine upstream proxy if configured; otherwise use DIRECT - // Refresh cloud proxy credentials before resolving let upstream_proxy = self - .resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string())) + .resolve_launch_proxy(profile) .await .map_err(|e| -> Box { e.into() })?; @@ -2273,10 +2292,7 @@ pub async fn launch_browser_profile( // Determine upstream proxy if configured; otherwise use DIRECT (no upstream) // Refresh cloud proxy credentials and inject profile-specific sid let mut upstream_proxy = BrowserRunner::instance() - .resolve_proxy_with_refresh( - profile_for_launch.proxy_id.as_ref(), - Some(&profile_for_launch.id.to_string()), - ) + .resolve_launch_proxy(&profile_for_launch) .await?; // If profile has a VPN instead of proxy, start VPN worker and use it as upstream diff --git a/src-tauri/src/ephemeral_dirs.rs b/src-tauri/src/ephemeral_dirs.rs index 42db82d..b69ce53 100644 --- a/src-tauri/src/ephemeral_dirs.rs +++ b/src-tauri/src/ephemeral_dirs.rs @@ -260,6 +260,7 @@ mod tests { version: "1.0".to_string(), proxy_id: None, vpn_id: None, + launch_hook: None, process_id: None, last_launch: None, release_type: "stable".to_string(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aa86e3f..2911958 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -67,8 +67,9 @@ 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_dns_blocklist, - update_profile_note, update_profile_proxy, update_profile_proxy_bypass_rules, - update_profile_tags, update_profile_vpn, update_wayfern_config, + update_profile_launch_hook, update_profile_note, update_profile_proxy, + update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn, + update_wayfern_config, }; use browser_version_manager::{ @@ -212,19 +213,13 @@ async fn create_stored_proxy( app_handle: tauri::AppHandle, name: String, proxy_settings: Option, - dynamic_proxy_url: Option, - dynamic_proxy_format: Option, ) -> Result { - if let (Some(url), Some(format)) = (&dynamic_proxy_url, &dynamic_proxy_format) { - crate::proxy_manager::PROXY_MANAGER - .create_dynamic_proxy(&app_handle, name, url.clone(), format.clone()) - .map_err(|e| format!("Failed to create dynamic proxy: {e}")) - } else if let Some(settings) = proxy_settings { + if let Some(settings) = proxy_settings { crate::proxy_manager::PROXY_MANAGER .create_stored_proxy(&app_handle, name, settings) .map_err(|e| format!("Failed to create stored proxy: {e}")) } else { - Err("Either proxy_settings or dynamic proxy URL and format are required".to_string()) + Err("proxy_settings is required".to_string()) } } @@ -239,26 +234,10 @@ async fn update_stored_proxy( proxy_id: String, name: Option, proxy_settings: Option, - dynamic_proxy_url: Option, - dynamic_proxy_format: Option, ) -> Result { - // Check if this is a dynamic proxy update - let is_dynamic = crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id); - if is_dynamic || dynamic_proxy_url.is_some() { - crate::proxy_manager::PROXY_MANAGER - .update_dynamic_proxy( - &app_handle, - &proxy_id, - name, - dynamic_proxy_url, - dynamic_proxy_format, - ) - .map_err(|e| format!("Failed to update dynamic proxy: {e}")) - } else { - crate::proxy_manager::PROXY_MANAGER - .update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings) - .map_err(|e| format!("Failed to update stored proxy: {e}")) - } + crate::proxy_manager::PROXY_MANAGER + .update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings) + .map_err(|e| format!("Failed to update stored proxy: {e}")) } #[tauri::command] @@ -273,13 +252,8 @@ async fn check_proxy_validity( proxy_id: String, proxy_settings: Option, ) -> Result { - // For dynamic proxies, fetch settings first let settings = if let Some(s) = proxy_settings { s - } else if crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id) { - crate::proxy_manager::PROXY_MANAGER - .resolve_dynamic_proxy(&proxy_id) - .await? } else { crate::proxy_manager::PROXY_MANAGER .get_proxy_settings_by_id(&proxy_id) @@ -290,24 +264,6 @@ async fn check_proxy_validity( .await } -#[tauri::command] -async fn fetch_dynamic_proxy( - url: String, - format: String, -) -> Result { - let settings = crate::proxy_manager::PROXY_MANAGER - .fetch_dynamic_proxy(&url, &format) - .await?; - - // Validate the proxy actually works by connecting through it - crate::proxy_manager::PROXY_MANAGER - .check_proxy_validity("_dynamic_test", &settings) - .await - .map_err(|e| format!("Proxy resolved but connection failed: {e}"))?; - - Ok(settings) -} - #[tauri::command] fn get_cached_proxy_check(proxy_id: String) -> Option { crate::proxy_manager::PROXY_MANAGER.get_cached_proxy_check(&proxy_id) @@ -1189,6 +1145,7 @@ async fn generate_sample_fingerprint( process_id: None, proxy_id: None, vpn_id: None, + launch_hook: None, last_launch: None, release_type: "stable".to_string(), camoufox_config: None, @@ -1889,6 +1846,7 @@ pub fn run() { update_profile_vpn, update_profile_tags, update_profile_note, + update_profile_launch_hook, update_profile_proxy_bypass_rules, update_profile_dns_blocklist, check_browser_status, @@ -1929,7 +1887,6 @@ pub fn run() { update_stored_proxy, delete_stored_proxy, check_proxy_validity, - fetch_dynamic_proxy, get_cached_proxy_check, export_proxies, import_proxies_json, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 0acb16b..45b91ce 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -508,6 +508,10 @@ impl McpServer { "type": "string", "description": "Optional proxy UUID to assign" }, + "launch_hook": { + "type": "string", + "description": "Optional HTTP(S) URL to call before launch for transient proxy overrides" + }, "group_id": { "type": "string", "description": "Optional group UUID to assign" @@ -539,6 +543,10 @@ impl McpServer { "type": "string", "description": "Proxy UUID to assign (empty string to remove)" }, + "launch_hook": { + "type": "string", + "description": "Launch hook URL to assign (empty string to remove)" + }, "group_id": { "type": "string", "description": "Group UUID to assign (empty string to remove)" @@ -713,7 +721,7 @@ impl McpServer { }, McpTool { name: "create_proxy".to_string(), - description: "Create a new proxy configuration. For regular proxies, provide proxy_type/host/port. For dynamic proxies, provide dynamic_proxy_url and dynamic_proxy_format instead.".to_string(), + description: "Create a new proxy configuration.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { @@ -741,18 +749,9 @@ impl McpServer { "password": { "type": "string", "description": "Optional password for authentication (for regular proxies)" - }, - "dynamic_proxy_url": { - "type": "string", - "description": "URL to fetch proxy settings from (for dynamic proxies)" - }, - "dynamic_proxy_format": { - "type": "string", - "enum": ["json", "text"], - "description": "Format of the dynamic proxy response: 'json' for JSON object or 'text' for text like host:port:user:pass (for dynamic proxies)" } }, - "required": ["name"] + "required": ["name", "proxy_type", "host", "port"] }), }, McpTool { @@ -789,15 +788,6 @@ impl McpServer { "password": { "type": "string", "description": "Optional password for authentication (for regular proxies)" - }, - "dynamic_proxy_url": { - "type": "string", - "description": "URL to fetch proxy settings from (for dynamic proxies)" - }, - "dynamic_proxy_format": { - "type": "string", - "enum": ["json", "text"], - "description": "Format of the dynamic proxy response (for dynamic proxies)" } }, "required": ["proxy_id"] @@ -1809,6 +1799,10 @@ impl McpServer { .get("proxy_id") .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let launch_hook = arguments + .get("launch_hook") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); let group_id = arguments .get("group_id") .and_then(|v| v.as_str()) @@ -1838,8 +1832,19 @@ impl McpServer { let mut profile = ProfileManager::instance() .create_profile_with_group( - app_handle, name, browser, version, "stable", proxy_id, None, None, None, group_id, false, + app_handle, + name, + browser, + version, + "stable", + proxy_id, None, + None, + None, + group_id, + false, + None, + launch_hook, ) .await .map_err(|e| McpError { @@ -1907,6 +1912,19 @@ impl McpServer { })?; } + if let Some(launch_hook) = arguments.get("launch_hook").and_then(|v| v.as_str()) { + let normalized = if launch_hook.is_empty() { + None + } else { + Some(launch_hook.to_string()) + }; + pm.update_profile_launch_hook(app_handle, profile_id, normalized) + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to update launch hook: {e}"), + })?; + } + if let Some(group_id) = arguments.get("group_id").and_then(|v| v.as_str()) { let gid = if group_id.is_empty() { None @@ -2361,74 +2379,54 @@ impl McpServer { message: "MCP server not properly initialized".to_string(), })?; - // Check if this is a dynamic proxy creation - let dynamic_url = arguments.get("dynamic_proxy_url").and_then(|v| v.as_str()); - let dynamic_format = arguments - .get("dynamic_proxy_format") - .and_then(|v| v.as_str()); + let proxy_type = arguments + .get("proxy_type") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing proxy_type".to_string(), + })?; - let proxy = if let (Some(url), Some(format)) = (dynamic_url, dynamic_format) { - PROXY_MANAGER - .create_dynamic_proxy( - app_handle, - name.to_string(), - url.to_string(), - format.to_string(), - ) - .map_err(|e| McpError { - code: -32000, - message: format!("Failed to create dynamic proxy: {e}"), - })? - } else { - let proxy_type = arguments - .get("proxy_type") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError { - code: -32602, - message: "Missing proxy_type (required for regular proxies)".to_string(), - })?; + let host = arguments + .get("host") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing host".to_string(), + })?; - let host = arguments - .get("host") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError { - code: -32602, - message: "Missing host (required for regular proxies)".to_string(), - })?; + let port = arguments + .get("port") + .and_then(|v| v.as_u64()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing port".to_string(), + })? as u16; - let port = arguments - .get("port") - .and_then(|v| v.as_u64()) - .ok_or_else(|| McpError { - code: -32602, - message: "Missing port (required for regular proxies)".to_string(), - })? as u16; + let username = arguments + .get("username") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let password = arguments + .get("password") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); - let username = arguments - .get("username") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let password = arguments - .get("password") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let proxy_settings = ProxySettings { - proxy_type: proxy_type.to_string(), - host: host.to_string(), - port, - username, - password, - }; - - PROXY_MANAGER - .create_stored_proxy(app_handle, name.to_string(), proxy_settings) - .map_err(|e| McpError { - code: -32000, - message: format!("Failed to create proxy: {e}"), - })? + let proxy_settings = ProxySettings { + proxy_type: proxy_type.to_string(), + host: host.to_string(), + port, + username, + password, }; + let proxy = PROXY_MANAGER + .create_stored_proxy(app_handle, name.to_string(), proxy_settings) + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to create proxy: {e}"), + })?; + Ok(serde_json::json!({ "content": [{ "type": "text", @@ -2517,32 +2515,12 @@ impl McpServer { message: "MCP server not properly initialized".to_string(), })?; - // Check for dynamic proxy fields - let dynamic_url = arguments - .get("dynamic_proxy_url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let dynamic_format = arguments - .get("dynamic_proxy_format") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(proxy_id) || dynamic_url.is_some(); - - let proxy = if is_dynamic { - PROXY_MANAGER - .update_dynamic_proxy(app_handle, proxy_id, name, dynamic_url, dynamic_format) - .map_err(|e| McpError { - code: -32000, - message: format!("Failed to update dynamic proxy: {e}"), - })? - } else { - PROXY_MANAGER - .update_stored_proxy(app_handle, proxy_id, name, proxy_settings) - .map_err(|e| McpError { - code: -32000, - message: format!("Failed to update proxy: {e}"), - })? - }; + let proxy = PROXY_MANAGER + .update_stored_proxy(app_handle, proxy_id, name, proxy_settings) + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to update proxy: {e}"), + })?; Ok(serde_json::json!({ "content": [{ diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 7ccd148..3d071bb 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -10,6 +10,7 @@ use crate::wayfern_manager::WayfernConfig; use std::fs::{self, create_dir_all}; use std::path::{Path, PathBuf}; use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System}; +use url::Url; pub struct ProfileManager { camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager, @@ -36,6 +37,25 @@ impl ProfileManager { crate::app_dirs::binaries_dir() } + fn normalize_launch_hook( + launch_hook: Option, + ) -> Result, Box> { + let Some(raw) = launch_hook else { + return Ok(None); + }; + + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + let parsed = Url::parse(trimmed).map_err(|e| format!("Invalid launch hook URL: {e}"))?; + match parsed.scheme() { + "http" | "https" => Ok(Some(parsed.to_string())), + _ => Err("Launch hook URL must use http or https".into()), + } + } + #[allow(clippy::too_many_arguments)] pub async fn create_profile_with_group( &self, @@ -51,11 +71,14 @@ impl ProfileManager { group_id: Option, ephemeral: bool, dns_blocklist: Option, + launch_hook: Option, ) -> Result> { if proxy_id.is_some() && vpn_id.is_some() { return Err("Cannot set both proxy_id and vpn_id".into()); } + let launch_hook = Self::normalize_launch_hook(launch_hook)?; + // Sync cloud proxy credentials if the profile uses a cloud or cloud-derived proxy if let Some(ref pid) = proxy_id { if PROXY_MANAGER.is_cloud_or_derived(pid) || pid == crate::proxy_manager::CLOUD_PROXY_ID { @@ -142,6 +165,7 @@ impl ProfileManager { version: version.to_string(), proxy_id: proxy_id.clone(), vpn_id: None, + launch_hook: launch_hook.clone(), process_id: None, last_launch: None, release_type: release_type.to_string(), @@ -242,6 +266,7 @@ impl ProfileManager { version: version.to_string(), proxy_id: proxy_id.clone(), vpn_id: None, + launch_hook: launch_hook.clone(), process_id: None, last_launch: None, release_type: release_type.to_string(), @@ -296,6 +321,7 @@ impl ProfileManager { version: version.to_string(), proxy_id: proxy_id.clone(), vpn_id: vpn_id.clone(), + launch_hook, process_id: None, last_launch: None, release_type: release_type.to_string(), @@ -739,6 +765,35 @@ impl ProfileManager { Ok(profile) } + pub fn update_profile_launch_hook( + &self, + _app_handle: &tauri::AppHandle, + profile_id: &str, + launch_hook: Option, + ) -> Result> { + let profile_uuid = + uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?; + let profiles = self.list_profiles()?; + let mut profile = profiles + .into_iter() + .find(|p| p.id == profile_uuid) + .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; + + profile.launch_hook = Self::normalize_launch_hook(launch_hook)?; + + self.save_profile(&profile)?; + + if let Err(e) = events::emit("profile-updated", &profile) { + log::warn!("Warning: Failed to emit profile update event: {e}"); + } + + if let Err(e) = events::emit_empty("profiles-changed") { + log::warn!("Warning: Failed to emit profiles-changed event: {e}"); + } + + Ok(profile) + } + pub fn update_profile_proxy_bypass_rules( &self, _app_handle: &tauri::AppHandle, @@ -913,6 +968,7 @@ impl ProfileManager { version: source.version, proxy_id: source.proxy_id, vpn_id: source.vpn_id, + launch_hook: source.launch_hook, process_id: None, last_launch: None, release_type: source.release_type, @@ -1970,6 +2026,36 @@ mod tests { "PAC URL should percent-encode spaces: {pac_line}" ); } + + #[test] + fn test_normalize_launch_hook_accepts_http_and_https() { + let http = + ProfileManager::normalize_launch_hook(Some(" http://localhost:3000/hook ".to_string())) + .unwrap(); + let https = ProfileManager::normalize_launch_hook(Some( + "https://example.com/hooks/profile-launch".to_string(), + )) + .unwrap(); + + assert_eq!(http.as_deref(), Some("http://localhost:3000/hook")); + assert_eq!( + https.as_deref(), + Some("https://example.com/hooks/profile-launch") + ); + } + + #[test] + fn test_normalize_launch_hook_clears_empty_values() { + let result = ProfileManager::normalize_launch_hook(Some(" ".to_string())).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_normalize_launch_hook_rejects_invalid_scheme() { + let err = ProfileManager::normalize_launch_hook(Some("ftp://example.com/hook".to_string())) + .unwrap_err(); + assert!(err.to_string().contains("http or https")); + } } #[allow(clippy::too_many_arguments)] @@ -1987,6 +2073,7 @@ pub async fn create_browser_profile_with_group( group_id: Option, ephemeral: bool, dns_blocklist: Option, + launch_hook: Option, ) -> Result { let profile_manager = ProfileManager::instance(); profile_manager @@ -2003,6 +2090,7 @@ pub async fn create_browser_profile_with_group( group_id, ephemeral, dns_blocklist, + launch_hook, ) .await .map_err(|e| format!("Failed to create profile: {e}")) @@ -2066,6 +2154,18 @@ pub fn update_profile_note( .map_err(|e| format!("Failed to update profile note: {e}")) } +#[tauri::command] +pub fn update_profile_launch_hook( + app_handle: tauri::AppHandle, + profile_id: String, + launch_hook: Option, +) -> Result { + let profile_manager = ProfileManager::instance(); + profile_manager + .update_profile_launch_hook(&app_handle, &profile_id, launch_hook) + .map_err(|e| format!("Failed to update profile launch hook: {e}")) +} + #[tauri::command] pub fn update_profile_proxy_bypass_rules( app_handle: tauri::AppHandle, @@ -2128,6 +2228,7 @@ pub async fn create_browser_profile_new( group_id: Option, ephemeral: Option, dns_blocklist: Option, + launch_hook: Option, ) -> Result { let fingerprint_os = camoufox_config .as_ref() @@ -2156,6 +2257,7 @@ pub async fn create_browser_profile_new( group_id, ephemeral.unwrap_or(false), dns_blocklist, + launch_hook, ) .await } diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index 8776598..c2f8505 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -32,6 +32,8 @@ pub struct BrowserProfile { #[serde(default)] pub vpn_id: Option, // Reference to stored VPN config #[serde(default)] + pub launch_hook: Option, + #[serde(default)] pub process_id: Option, #[serde(default)] pub last_launch: Option, diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 2abb30e..50d2d20 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -565,6 +565,7 @@ impl ProfileImporter { version: version.clone(), proxy_id: proxy_id.clone(), vpn_id: None, + launch_hook: None, process_id: None, last_launch: None, release_type: "stable".to_string(), @@ -644,6 +645,7 @@ impl ProfileImporter { version: version.clone(), proxy_id: proxy_id.clone(), vpn_id: None, + launch_hook: None, process_id: None, last_launch: None, release_type: "stable".to_string(), @@ -694,6 +696,7 @@ impl ProfileImporter { version, proxy_id, vpn_id: None, + launch_hook: None, process_id: None, last_launch: None, release_type: "stable".to_string(), diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index d7a32f4..0081473 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -145,10 +145,6 @@ impl StoredProxy { } } - pub fn is_dynamic(&self) -> bool { - self.dynamic_proxy_url.is_some() - } - /// Migrate legacy geo_state to geo_region pub fn migrate_geo_fields(&mut self) { if self.geo_region.is_none() && self.geo_state.is_some() { @@ -1066,20 +1062,13 @@ impl ProxyManager { self.load_proxy_check_cache(proxy_id) } - // Check if a stored proxy is dynamic - pub fn is_dynamic_proxy(&self, proxy_id: &str) -> bool { - let stored_proxies = self.stored_proxies.lock().unwrap(); - stored_proxies.get(proxy_id).is_some_and(|p| p.is_dynamic()) - } - - // Fetch proxy settings from a dynamic proxy URL - pub async fn fetch_dynamic_proxy( + pub async fn fetch_proxy_from_url( &self, url: &str, - format: &str, - ) -> Result { + timeout: std::time::Duration, + ) -> Result, String> { let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(15)) + .timeout(timeout) .build() .map_err(|e| format!("Failed to create HTTP client: {e}"))?; @@ -1087,33 +1076,39 @@ impl ProxyManager { .get(url) .send() .await - .map_err(|e| format!("Failed to fetch dynamic proxy: {e}"))?; + .map_err(|e| format!("Failed to fetch launch hook: {e}"))?; + + if response.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(None); + } if !response.status().is_success() { - return Err(format!( - "Dynamic proxy URL returned status {}", - response.status() - )); + return Err(format!("Launch hook returned status {}", response.status())); } let body = response .text() .await - .map_err(|e| format!("Failed to read dynamic proxy response: {e}"))?; + .map_err(|e| format!("Failed to read launch hook response: {e}"))?; let body = body.trim(); if body.is_empty() { - return Err("Dynamic proxy URL returned empty response".to_string()); + return Err("Launch hook returned empty response".to_string()); } - match format { - "json" => Self::parse_dynamic_proxy_json(body), - "text" => Self::parse_dynamic_proxy_text(body), - _ => Err(format!("Unsupported dynamic proxy format: {format}")), + if let Ok(settings) = Self::parse_dynamic_proxy_json(body) { + return Ok(Some(settings)); + } + + match Self::parse_dynamic_proxy_text(body) { + Ok(settings) => Ok(Some(settings)), + Err(text_error) => Err(format!( + "Failed to parse launch hook response: {text_error}" + )), } } - // Parse JSON format: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." } + // Parse JSON proxy payload: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." } fn parse_dynamic_proxy_json(body: &str) -> Result { let json: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))?; @@ -1179,7 +1174,7 @@ impl ProxyManager { }) } - // Parse text format using the same logic as proxy import + // Parse plain text proxy payload using the same logic as proxy import fn parse_dynamic_proxy_text(body: &str) -> Result { let line = body .lines() @@ -1210,136 +1205,6 @@ impl ProxyManager { } } - // Resolve dynamic proxy: fetch from URL and return settings - pub async fn resolve_dynamic_proxy(&self, proxy_id: &str) -> Result { - let (url, format) = { - let stored_proxies = self.stored_proxies.lock().unwrap(); - let proxy = stored_proxies - .get(proxy_id) - .ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?; - - match (&proxy.dynamic_proxy_url, &proxy.dynamic_proxy_format) { - (Some(url), Some(format)) => (url.clone(), format.clone()), - _ => return Err("Proxy is not a dynamic proxy".to_string()), - } - }; - - self.fetch_dynamic_proxy(&url, &format).await - } - - // Create a dynamic stored proxy - pub fn create_dynamic_proxy( - &self, - _app_handle: &tauri::AppHandle, - name: String, - url: String, - format: String, - ) -> Result { - { - let stored_proxies = self.stored_proxies.lock().unwrap(); - if stored_proxies.values().any(|p| p.name == name) { - return Err(format!("Proxy with name '{name}' already exists")); - } - } - - let placeholder_settings = ProxySettings { - proxy_type: "http".to_string(), - host: "dynamic".to_string(), - port: 0, - username: None, - password: None, - }; - - let mut stored_proxy = StoredProxy::new(name, placeholder_settings); - stored_proxy.dynamic_proxy_url = Some(url); - stored_proxy.dynamic_proxy_format = Some(format); - - { - let mut stored_proxies = self.stored_proxies.lock().unwrap(); - stored_proxies.insert(stored_proxy.id.clone(), stored_proxy.clone()); - } - - if let Err(e) = self.save_proxy(&stored_proxy) { - log::warn!("Failed to save proxy: {e}"); - } - - if let Err(e) = events::emit_empty("proxies-changed") { - log::error!("Failed to emit proxies-changed event: {e}"); - } - - if stored_proxy.sync_enabled { - if let Some(scheduler) = crate::sync::get_global_scheduler() { - let id = stored_proxy.id.clone(); - tauri::async_runtime::spawn(async move { - scheduler.queue_proxy_sync(id).await; - }); - } - } - - Ok(stored_proxy) - } - - // Update a dynamic proxy's URL and format - pub fn update_dynamic_proxy( - &self, - _app_handle: &tauri::AppHandle, - proxy_id: &str, - name: Option, - url: Option, - format: Option, - ) -> Result { - { - let stored_proxies = self.stored_proxies.lock().unwrap(); - if !stored_proxies.contains_key(proxy_id) { - return Err(format!("Proxy with ID '{proxy_id}' not found")); - } - if let Some(ref new_name) = name { - if stored_proxies - .values() - .any(|p| p.id != proxy_id && p.name == *new_name) - { - return Err(format!("Proxy with name '{new_name}' already exists")); - } - } - } - - let updated_proxy = { - let mut stored_proxies = self.stored_proxies.lock().unwrap(); - let stored_proxy = stored_proxies.get_mut(proxy_id).unwrap(); - - if let Some(new_name) = name { - stored_proxy.update_name(new_name); - } - if let Some(new_url) = url { - stored_proxy.dynamic_proxy_url = Some(new_url); - } - if let Some(new_format) = format { - stored_proxy.dynamic_proxy_format = Some(new_format); - } - - stored_proxy.clone() - }; - - if let Err(e) = self.save_proxy(&updated_proxy) { - log::warn!("Failed to save proxy: {e}"); - } - - if let Err(e) = events::emit_empty("proxies-changed") { - log::error!("Failed to emit proxies-changed event: {e}"); - } - - if updated_proxy.sync_enabled { - if let Some(scheduler) = crate::sync::get_global_scheduler() { - let id = updated_proxy.id.clone(); - tauri::async_runtime::spawn(async move { - scheduler.queue_proxy_sync(id).await; - }); - } - } - - Ok(updated_proxy) - } - // Export all proxies as JSON pub fn export_proxies_json(&self) -> Result { let stored_proxies = self.stored_proxies.lock().unwrap(); @@ -2239,6 +2104,8 @@ mod tests { use hyper::Response; use hyper_util::rt::TokioIo; use tokio::net::TcpListener; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; // Helper function to build donut-proxy binary for testing async fn ensure_donut_proxy_binary() -> Result> { @@ -3668,74 +3535,102 @@ mod tests { assert!(err.contains("Empty")); } - #[test] - fn test_stored_proxy_is_dynamic() { - let mut proxy = StoredProxy::new( - "test".to_string(), - ProxySettings { - proxy_type: "http".to_string(), - host: "h.com".to_string(), - port: 80, - username: None, - password: None, - }, - ); - assert!(!proxy.is_dynamic()); + #[tokio::test] + async fn test_fetch_proxy_from_url_parses_json_response() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/hook")) + .respond_with( + ResponseTemplate::new(200).set_body_string( + r#"{"host":"proxy.example.com","port":3128,"type":"socks5","username":"user","password":"pass"}"#, + ), + ) + .mount(&server) + .await; - proxy.dynamic_proxy_url = Some("https://api.example.com/proxy".to_string()); - assert!(proxy.is_dynamic()); - } - - #[test] - fn test_is_dynamic_proxy_via_manager() { let pm = ProxyManager::new(); + let result = pm + .fetch_proxy_from_url( + &format!("{}/hook", server.uri()), + Duration::from_millis(500), + ) + .await + .unwrap() + .unwrap(); - let mut proxy = StoredProxy::new( - "DynTest".to_string(), - ProxySettings { - proxy_type: "http".to_string(), - host: "dynamic".to_string(), - port: 0, - username: None, - password: None, - }, - ); - proxy.dynamic_proxy_url = Some("https://api.example.com/proxy".to_string()); - proxy.dynamic_proxy_format = Some("json".to_string()); - - let id = proxy.id.clone(); - pm.stored_proxies.lock().unwrap().insert(id.clone(), proxy); - - assert!(pm.is_dynamic_proxy(&id)); - assert!(!pm.is_dynamic_proxy("nonexistent")); + assert_eq!(result.host, "proxy.example.com"); + assert_eq!(result.port, 3128); + assert_eq!(result.proxy_type, "socks5"); + assert_eq!(result.username.as_deref(), Some("user")); + assert_eq!(result.password.as_deref(), Some("pass")); } #[tokio::test] - async fn test_resolve_dynamic_proxy_not_dynamic() { + async fn test_fetch_proxy_from_url_parses_text_response() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/hook")) + .respond_with(ResponseTemplate::new(200).set_body_string("socks5://user:pass@1.2.3.4:1080")) + .mount(&server) + .await; + let pm = ProxyManager::new(); + let result = pm + .fetch_proxy_from_url( + &format!("{}/hook", server.uri()), + Duration::from_millis(500), + ) + .await + .unwrap() + .unwrap(); - let proxy = StoredProxy::new( - "Regular".to_string(), - ProxySettings { - proxy_type: "http".to_string(), - host: "1.2.3.4".to_string(), - port: 8080, - username: None, - password: None, - }, - ); - let id = proxy.id.clone(); - pm.stored_proxies.lock().unwrap().insert(id.clone(), proxy); - - let err = pm.resolve_dynamic_proxy(&id).await.unwrap_err(); - assert!(err.contains("not a dynamic proxy")); + assert_eq!(result.host, "1.2.3.4"); + assert_eq!(result.port, 1080); + assert_eq!(result.proxy_type, "socks5"); + assert_eq!(result.username.as_deref(), Some("user")); + assert_eq!(result.password.as_deref(), Some("pass")); } #[tokio::test] - async fn test_resolve_dynamic_proxy_not_found() { - let pm = ProxyManager::new(); + async fn test_fetch_proxy_from_url_returns_none_for_no_content() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/hook")) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; - let err = pm.resolve_dynamic_proxy("nonexistent").await.unwrap_err(); - assert!(err.contains("not found")); + let pm = ProxyManager::new(); + let result = pm + .fetch_proxy_from_url( + &format!("{}/hook", server.uri()), + Duration::from_millis(500), + ) + .await + .unwrap(); + + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_fetch_proxy_from_url_respects_timeout() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/hook")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_millis(200)) + .set_body_string(r#"{"host":"1.2.3.4","port":8080}"#), + ) + .mount(&server) + .await; + + let pm = ProxyManager::new(); + let err = pm + .fetch_proxy_from_url(&format!("{}/hook", server.uri()), Duration::from_millis(50)) + .await + .unwrap_err(); + + assert!(err.contains("Failed to fetch launch hook")); } } diff --git a/src/app/page.tsx b/src/app/page.tsx index 924736d..63be864 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -516,6 +516,7 @@ export default function Home() { extensionGroupId?: string; ephemeral?: boolean; dnsBlocklist?: string; + launchHook?: string; }) => { try { const profile = await invoke( @@ -534,6 +535,7 @@ export default function Home() { (selectedGroupId !== "default" ? selectedGroupId : undefined), ephemeral: profileData.ephemeral, dnsBlocklist: profileData.dnsBlocklist, + launchHook: profileData.launchHook, }, ); diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index fb7f515..afc45d2 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -85,6 +85,7 @@ interface CreateProfileDialogProps { extensionGroupId?: string; ephemeral?: boolean; dnsBlocklist?: string; + launchHook?: string; }) => Promise; selectedGroupId?: string; crossOsUnlocked?: boolean; @@ -126,6 +127,7 @@ export function CreateProfileDialog({ const [selectedProxyId, setSelectedProxyId] = useState(); const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false); const [dnsBlocklist, setDnsBlocklist] = useState(""); + const [launchHook, setLaunchHook] = useState(""); // Camoufox anti-detect states const [camoufoxConfig, setCamoufoxConfig] = useState(() => ({ @@ -150,6 +152,7 @@ export function CreateProfileDialog({ setSelectedBrowser(null); setProfileName(""); setSelectedProxyId(undefined); + setLaunchHook(""); }; const handleTabChange = (value: string) => { @@ -158,6 +161,7 @@ export function CreateProfileDialog({ setSelectedBrowser(null); setProfileName(""); setSelectedProxyId(undefined); + setLaunchHook(""); }; const [supportedBrowsers, setSupportedBrowsers] = useState([]); @@ -398,6 +402,7 @@ export function CreateProfileDialog({ extensionGroupId: selectedExtensionGroupId, ephemeral, dnsBlocklist: dnsBlocklist || undefined, + launchHook: launchHook.trim() || undefined, }); } else { // Default to Camoufox @@ -424,6 +429,7 @@ export function CreateProfileDialog({ extensionGroupId: selectedExtensionGroupId, ephemeral, dnsBlocklist: dnsBlocklist || undefined, + launchHook: launchHook.trim() || undefined, }); } } else { @@ -448,6 +454,7 @@ export function CreateProfileDialog({ proxyId: selectedProxyId, groupId: selectedGroupId !== "default" ? selectedGroupId : undefined, dnsBlocklist: dnsBlocklist || undefined, + launchHook: launchHook.trim() || undefined, }); } @@ -469,6 +476,7 @@ export function CreateProfileDialog({ setActiveTab("anti-detect"); setSelectedBrowser(null); setSelectedProxyId(undefined); + setLaunchHook(""); setReleaseTypes({}); setIsLoadingReleaseTypes(false); setReleaseTypesError(null); @@ -1167,6 +1175,23 @@ export function CreateProfileDialog({ )} +
+ + { + setLaunchHook(e.target.value); + }} + placeholder={t( + "createProfile.launchHook.placeholder", + )} + disabled={isCreating} + /> +
+ {/* DNS Blocklist */}
@@ -1498,6 +1523,23 @@ export function CreateProfileDialog({
)} + +
+ + { + setLaunchHook(e.target.value); + }} + placeholder={t( + "createProfile.launchHook.placeholder", + )} + disabled={isCreating} + /> +
diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 3b8ba61..19675b6 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -128,6 +128,8 @@ export function ProfileInfoDialog({ const [extensionGroupName, setExtensionGroupName] = React.useState< string | null >(null); + const [launchHookValue, setLaunchHookValue] = React.useState(""); + const [isSavingLaunchHook, setIsSavingLaunchHook] = React.useState(false); React.useEffect(() => { if (!isOpen || !profile?.group_id) { @@ -169,6 +171,12 @@ export function ProfileInfoDialog({ } }, [isOpen]); + React.useEffect(() => { + if (isOpen) { + setLaunchHookValue(profile?.launch_hook ?? ""); + } + }, [isOpen, profile?.launch_hook]); + if (!profile) return null; const ProfileIcon = getProfileIcon(profile); @@ -217,6 +225,22 @@ export function ProfileInfoDialog({ const hasTags = profile.tags && profile.tags.length > 0; const hasNote = !!profile.note; const showCrossOs = isCrossOsProfile(profile); + const trimmedLaunchHook = launchHookValue.trim(); + const savedLaunchHook = profile.launch_hook ?? ""; + + const handleSaveLaunchHook = async () => { + setIsSavingLaunchHook(true); + try { + await invoke("update_profile_launch_hook", { + profileId: profile.id, + launchHook: trimmedLaunchHook || null, + }); + } catch (error) { + console.error("Failed to update launch hook:", error); + } finally { + setIsSavingLaunchHook(false); + } + }; interface ActionItem { icon: React.ReactNode; @@ -474,6 +498,10 @@ export function ProfileInfoDialog({ : t("dnsBlocklist.none") } /> + {/* Sync */} @@ -546,33 +574,62 @@ export function ProfileInfoDialog({
-
- {visibleActions.map((action) => ( - +
+
+ +
+ {visibleActions.map((action) => ( + - ))} + > + {action.icon} + + {action.label} + {action.runningBadge && ( + + {t("common.status.running")} + + )} + {action.proBadge && !action.runningBadge && ( + + )} + + + + ))} +
diff --git a/src/components/proxy-check-button.tsx b/src/components/proxy-check-button.tsx index 282a75b..a352ee9 100644 --- a/src/components/proxy-check-button.tsx +++ b/src/components/proxy-check-button.tsx @@ -50,9 +50,7 @@ export function ProxyCheckButton({ try { const result = await invoke("check_proxy_validity", { proxyId: proxy.id, - proxySettings: proxy.dynamic_proxy_url - ? undefined - : proxy.proxy_settings, + proxySettings: proxy.proxy_settings, }); setLocalResult(result); onCheckComplete?.(result); diff --git a/src/components/proxy-form-dialog.tsx b/src/components/proxy-form-dialog.tsx index 8c24c98..af3ddea 100644 --- a/src/components/proxy-form-dialog.tsx +++ b/src/components/proxy-form-dialog.tsx @@ -21,11 +21,10 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { ProxySettings, StoredProxy } from "@/types"; +import type { StoredProxy } from "@/types"; import { RippleButton } from "./ui/ripple"; -interface RegularFormData { +interface ProxyFormData { name: string; proxy_type: string; host: string; @@ -34,20 +33,21 @@ interface RegularFormData { password: string; } -interface DynamicFormData { - name: string; - url: string; - format: string; -} - -type ProxyMode = "regular" | "dynamic"; - interface ProxyFormDialogProps { isOpen: boolean; onClose: () => void; editingProxy?: StoredProxy | null; } +const DEFAULT_FORM: ProxyFormData = { + name: "", + proxy_type: "http", + host: "", + port: 8080, + username: "", + password: "", +}; + export function ProxyFormDialog({ isOpen, onClose, @@ -55,158 +55,66 @@ export function ProxyFormDialog({ }: ProxyFormDialogProps) { const { t } = useTranslation(); const [isSubmitting, setIsSubmitting] = useState(false); - const [isTesting, setIsTesting] = useState(false); - const [mode, setMode] = useState("regular"); - const [regularForm, setRegularForm] = useState({ - name: "", - proxy_type: "http", - host: "", - port: 8080, - username: "", - password: "", - }); - const [dynamicForm, setDynamicForm] = useState({ - name: "", - url: "", - format: "json", - }); + const [form, setForm] = useState(DEFAULT_FORM); const resetForm = useCallback(() => { - setRegularForm({ - name: "", - proxy_type: "http", - host: "", - port: 8080, - username: "", - password: "", - }); - setDynamicForm({ - name: "", - url: "", - format: "json", - }); - setMode("regular"); + setForm(DEFAULT_FORM); }, []); useEffect(() => { - if (isOpen) { - if (editingProxy) { - if (editingProxy.dynamic_proxy_url) { - setMode("dynamic"); - setDynamicForm({ - name: editingProxy.name, - url: editingProxy.dynamic_proxy_url, - format: editingProxy.dynamic_proxy_format || "json", - }); - } else { - setMode("regular"); - setRegularForm({ - name: editingProxy.name, - proxy_type: editingProxy.proxy_settings.proxy_type, - host: editingProxy.proxy_settings.host, - port: editingProxy.proxy_settings.port, - username: editingProxy.proxy_settings.username ?? "", - password: editingProxy.proxy_settings.password ?? "", - }); - } - } else { - resetForm(); - } - } - }, [isOpen, editingProxy, resetForm]); - - const handleTestDynamic = useCallback(async () => { - if (!dynamicForm.url.trim()) { - toast.error(t("proxies.dynamic.urlRequired")); + if (!isOpen) { return; } - setIsTesting(true); - try { - const settings = await invoke("fetch_dynamic_proxy", { - url: dynamicForm.url.trim(), - format: dynamicForm.format, - }); - toast.success( - t("proxies.dynamic.testSuccess", { - host: settings.host, - port: settings.port, - }), - ); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - toast.error(t("proxies.dynamic.testFailed", { error: errorMessage })); - } finally { - setIsTesting(false); + + if (!editingProxy) { + resetForm(); + return; } - }, [dynamicForm, t]); + + setForm({ + name: editingProxy.name, + proxy_type: editingProxy.proxy_settings.proxy_type, + host: editingProxy.proxy_settings.host, + port: editingProxy.proxy_settings.port, + username: editingProxy.proxy_settings.username ?? "", + password: editingProxy.proxy_settings.password ?? "", + }); + }, [editingProxy, isOpen, resetForm]); const handleSubmit = useCallback(async () => { - if (mode === "regular") { - if (!regularForm.name.trim()) { - toast.error(t("proxies.form.nameRequired", "Proxy name is required")); - return; - } - if (!regularForm.host.trim() || !regularForm.port) { - toast.error( - t("proxies.form.hostPortRequired", "Host and port are required"), - ); - return; - } - } else { - if (!dynamicForm.name.trim()) { - toast.error(t("proxies.form.nameRequired", "Proxy name is required")); - return; - } - if (!dynamicForm.url.trim()) { - toast.error(t("proxies.dynamic.urlRequired")); - return; - } + if (!form.name.trim()) { + toast.error(t("proxies.form.nameRequired", "Proxy name is required")); + return; + } + + if (!form.host.trim() || !form.port) { + toast.error( + t("proxies.form.hostPortRequired", "Host and port are required"), + ); + return; } setIsSubmitting(true); try { + const payload = { + name: form.name.trim(), + proxySettings: { + proxy_type: form.proxy_type, + host: form.host.trim(), + port: form.port, + username: form.username.trim() || undefined, + password: form.password.trim() || undefined, + }, + }; + if (editingProxy) { - if (mode === "dynamic") { - await invoke("update_stored_proxy", { - proxyId: editingProxy.id, - name: dynamicForm.name.trim(), - dynamicProxyUrl: dynamicForm.url.trim(), - dynamicProxyFormat: dynamicForm.format, - }); - } else { - await invoke("update_stored_proxy", { - proxyId: editingProxy.id, - name: regularForm.name.trim(), - proxySettings: { - proxy_type: regularForm.proxy_type, - host: regularForm.host.trim(), - port: regularForm.port, - username: regularForm.username.trim() || undefined, - password: regularForm.password.trim() || undefined, - }, - }); - } + await invoke("update_stored_proxy", { + proxyId: editingProxy.id, + ...payload, + }); toast.success(t("toasts.success.proxyUpdated")); } else { - if (mode === "dynamic") { - await invoke("create_stored_proxy", { - name: dynamicForm.name.trim(), - dynamicProxyUrl: dynamicForm.url.trim(), - dynamicProxyFormat: dynamicForm.format, - }); - } else { - await invoke("create_stored_proxy", { - name: regularForm.name.trim(), - proxySettings: { - proxy_type: regularForm.proxy_type, - host: regularForm.host.trim(), - port: regularForm.port, - username: regularForm.username.trim() || undefined, - password: regularForm.password.trim() || undefined, - }, - }); - } + await invoke("create_stored_proxy", payload); toast.success(t("toasts.success.proxyCreated")); } @@ -219,7 +127,7 @@ export function ProxyFormDialog({ } finally { setIsSubmitting(false); } - }, [mode, regularForm, dynamicForm, editingProxy, onClose, t]); + }, [editingProxy, form, onClose, t]); const handleClose = useCallback(() => { if (!isSubmitting) { @@ -227,17 +135,8 @@ export function ProxyFormDialog({ } }, [isSubmitting, onClose]); - const isRegularValid = - regularForm.name.trim() && - regularForm.host.trim() && - regularForm.port > 0 && - regularForm.port <= 65535; - - const isDynamicValid = dynamicForm.name.trim() && dynamicForm.url.trim(); - - const isFormValid = mode === "regular" ? isRegularValid : isDynamicValid; - - const isEditingDynamic = editingProxy?.dynamic_proxy_url != null; + const isFormValid = + form.name.trim() && form.host.trim() && form.port > 0 && form.port <= 65535; return ( @@ -249,210 +148,109 @@ export function ProxyFormDialog({
- {!editingProxy && ( - { - setMode(v as ProxyMode); +
+ + { + setForm({ ...form, name: e.target.value }); }} + placeholder={t("proxies.form.namePlaceholder")} + disabled={isSubmitting} + /> +
+ +
+ + +
- {editingProxy && isEditingDynamic && ( -

- {t("proxies.dynamic.description")} -

- )} +
+
+ + { + setForm({ ...form, host: e.target.value }); + }} + placeholder={t("proxies.form.hostPlaceholder")} + disabled={isSubmitting} + /> +
- {mode === "regular" ? ( - <> -
- - { - setRegularForm({ ...regularForm, name: e.target.value }); - }} - placeholder="e.g. Office Proxy, Home VPN, etc." - disabled={isSubmitting} - /> -
+
+ + { + setForm({ + ...form, + port: Number.parseInt(e.target.value, 10) || 0, + }); + }} + placeholder={t("proxies.form.portPlaceholder")} + min="1" + max="65535" + disabled={isSubmitting} + /> +
+
-
- - -
+
+
+ + { + setForm({ ...form, username: e.target.value }); + }} + placeholder={t("proxies.form.usernamePlaceholder")} + disabled={isSubmitting} + /> +
-
-
- - { - setRegularForm({ ...regularForm, host: e.target.value }); - }} - placeholder={t("proxies.form.hostPlaceholder")} - disabled={isSubmitting} - /> -
- -
- - { - setRegularForm({ - ...regularForm, - port: parseInt(e.target.value, 10) || 0, - }); - }} - placeholder={t("proxies.form.portPlaceholder")} - min="1" - max="65535" - disabled={isSubmitting} - /> -
-
- -
-
- - { - setRegularForm({ - ...regularForm, - username: e.target.value, - }); - }} - placeholder={t("proxies.form.usernamePlaceholder")} - disabled={isSubmitting} - /> -
- -
- - { - setRegularForm({ - ...regularForm, - password: e.target.value, - }); - }} - placeholder={t("proxies.form.passwordPlaceholder")} - disabled={isSubmitting} - /> -
-
- - ) : ( - <> -
- - { - setDynamicForm({ ...dynamicForm, name: e.target.value }); - }} - placeholder="e.g. My Tunnel" - disabled={isSubmitting} - /> -
- -
- - { - setDynamicForm({ ...dynamicForm, url: e.target.value }); - }} - placeholder={t("proxies.dynamic.urlPlaceholder")} - disabled={isSubmitting} - /> -
- -
- - -

- {dynamicForm.format === "json" - ? t("proxies.dynamic.formatJsonHint") - : t("proxies.dynamic.formatTextHint")} -

-
- - - {isTesting - ? t("proxies.dynamic.testing") - : t("proxies.dynamic.testUrl")} - - - )} +
+ + { + setForm({ ...form, password: e.target.value }); + }} + placeholder={t("proxies.form.passwordPlaceholder")} + disabled={isSubmitting} + /> +
+
@@ -461,7 +259,7 @@ export function ProxyFormDialog({ onClick={handleClose} disabled={isSubmitting} > - {t("common.cancel", "Cancel")} + {t("common.buttons.cancel")} {proxy.name} - {proxy.dynamic_proxy_url && ( - - Dynamic - - )} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index eb4d468..61c3d3c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -229,6 +229,10 @@ "noProxy": "No proxy / VPN", "noProxiesAvailable": "No proxies or VPNs available. Add one to route this profile's traffic." }, + "launchHook": { + "label": "Launch Hook URL", + "placeholder": "https://example.com/hooks/profile-launch" + }, "version": { "fetching": "Fetching available versions...", "fetchError": "Failed to fetch browser versions. Please check your internet connection and try again.", @@ -759,6 +763,7 @@ "browser": "Browser", "releaseType": "Release Type", "proxyVpn": "Proxy / VPN", + "launchHook": "Launch Hook", "group": "Group", "tags": "Tags", "note": "Note", @@ -783,6 +788,10 @@ "noRules": "No bypass rules configured.", "ruleTypes": "Supports hostnames, IP addresses, and regex patterns." }, + "launchHook": { + "label": "Launch Hook URL", + "placeholder": "https://example.com/hooks/profile-launch" + }, "actions": { "manageCookies": "Manage Cookies", "assignExtensionGroup": "Assign Extension Group" diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d9f44db..3ab5a97 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -229,6 +229,10 @@ "noProxy": "Sin proxy / VPN", "noProxiesAvailable": "No hay proxies o VPNs disponibles. Agrega uno para enrutar el tráfico de este perfil." }, + "launchHook": { + "label": "URL del hook de inicio", + "placeholder": "https://example.com/hooks/profile-launch" + }, "version": { "fetching": "Obteniendo versiones disponibles...", "fetchError": "Error al obtener versiones del navegador. Por favor verifica tu conexión a internet e intenta de nuevo.", @@ -759,6 +763,7 @@ "browser": "Navegador", "releaseType": "Tipo de Versión", "proxyVpn": "Proxy / VPN", + "launchHook": "Hook de inicio", "group": "Grupo", "tags": "Etiquetas", "note": "Nota", @@ -783,6 +788,10 @@ "noRules": "No hay reglas de omisión configuradas.", "ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex." }, + "launchHook": { + "label": "URL del hook de inicio", + "placeholder": "https://example.com/hooks/profile-launch" + }, "actions": { "manageCookies": "Administrar Cookies", "assignExtensionGroup": "Asignar Grupo de Extensiones" diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index bb53e01..c89ddc7 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -229,6 +229,10 @@ "noProxy": "Pas de proxy / VPN", "noProxiesAvailable": "Aucun proxy ou VPN disponible. Ajoutez-en un pour router le trafic de ce profil." }, + "launchHook": { + "label": "URL du hook de lancement", + "placeholder": "https://example.com/hooks/profile-launch" + }, "version": { "fetching": "Récupération des versions disponibles...", "fetchError": "Échec de la récupération des versions du navigateur. Veuillez vérifier votre connexion Internet et réessayer.", @@ -759,6 +763,7 @@ "browser": "Navigateur", "releaseType": "Type de Version", "proxyVpn": "Proxy / VPN", + "launchHook": "Hook de lancement", "group": "Groupe", "tags": "Tags", "note": "Note", @@ -783,6 +788,10 @@ "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." }, + "launchHook": { + "label": "URL du hook de lancement", + "placeholder": "https://example.com/hooks/profile-launch" + }, "actions": { "manageCookies": "Gérer les Cookies", "assignExtensionGroup": "Assigner un Groupe d'Extensions" diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c996ee6..a520c56 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -229,6 +229,10 @@ "noProxy": "プロキシ / VPNなし", "noProxiesAvailable": "利用可能なプロキシまたはVPNがありません。このプロファイルのトラフィックをルーティングするために追加してください。" }, + "launchHook": { + "label": "起動フックURL", + "placeholder": "https://example.com/hooks/profile-launch" + }, "version": { "fetching": "利用可能なバージョンを取得中...", "fetchError": "ブラウザバージョンの取得に失敗しました。インターネット接続を確認して再試行してください。", @@ -759,6 +763,7 @@ "browser": "ブラウザ", "releaseType": "リリースタイプ", "proxyVpn": "プロキシ / VPN", + "launchHook": "起動フック", "group": "グループ", "tags": "タグ", "note": "メモ", @@ -783,6 +788,10 @@ "noRules": "バイパスルールは設定されていません。", "ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。" }, + "launchHook": { + "label": "起動フックURL", + "placeholder": "https://example.com/hooks/profile-launch" + }, "actions": { "manageCookies": "Cookieを管理", "assignExtensionGroup": "拡張機能グループを割り当て" diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index afd2567..267e393 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -229,6 +229,10 @@ "noProxy": "Sem proxy / VPN", "noProxiesAvailable": "Nenhum proxy ou VPN disponível. Adicione um para rotear o tráfego deste perfil." }, + "launchHook": { + "label": "URL do hook de inicialização", + "placeholder": "https://example.com/hooks/profile-launch" + }, "version": { "fetching": "Buscando versões disponíveis...", "fetchError": "Falha ao buscar versões do navegador. Por favor, verifique sua conexão com a internet e tente novamente.", @@ -759,6 +763,7 @@ "browser": "Navegador", "releaseType": "Tipo de Versão", "proxyVpn": "Proxy / VPN", + "launchHook": "Hook de inicialização", "group": "Grupo", "tags": "Tags", "note": "Nota", @@ -783,6 +788,10 @@ "noRules": "Nenhuma regra de bypass configurada.", "ruleTypes": "Suporta nomes de host, endereços IP e padrões regex." }, + "launchHook": { + "label": "URL do hook de inicialização", + "placeholder": "https://example.com/hooks/profile-launch" + }, "actions": { "manageCookies": "Gerenciar Cookies", "assignExtensionGroup": "Atribuir Grupo de Extensões" diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 4c9df8b..31d24ce 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -229,6 +229,10 @@ "noProxy": "Без прокси / VPN", "noProxiesAvailable": "Нет доступных прокси или VPN. Добавьте один для маршрутизации трафика этого профиля." }, + "launchHook": { + "label": "URL хука запуска", + "placeholder": "https://example.com/hooks/profile-launch" + }, "version": { "fetching": "Получение доступных версий...", "fetchError": "Не удалось получить версии браузера. Проверьте интернет-соединение и попробуйте снова.", @@ -759,6 +763,7 @@ "browser": "Браузер", "releaseType": "Тип релиза", "proxyVpn": "Прокси / VPN", + "launchHook": "Хук запуска", "group": "Группа", "tags": "Теги", "note": "Заметка", @@ -783,6 +788,10 @@ "noRules": "Правила обхода не настроены.", "ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений." }, + "launchHook": { + "label": "URL хука запуска", + "placeholder": "https://example.com/hooks/profile-launch" + }, "actions": { "manageCookies": "Управление Cookie", "assignExtensionGroup": "Назначить группу расширений" diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index cfe1e5a..2e2241b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -229,6 +229,10 @@ "noProxy": "无代理 / VPN", "noProxiesAvailable": "没有可用的代理或VPN。添加一个来路由此配置文件的流量。" }, + "launchHook": { + "label": "启动钩子 URL", + "placeholder": "https://example.com/hooks/profile-launch" + }, "version": { "fetching": "正在获取可用版本...", "fetchError": "获取浏览器版本失败。请检查您的网络连接并重试。", @@ -759,6 +763,7 @@ "browser": "浏览器", "releaseType": "发布类型", "proxyVpn": "代理 / VPN", + "launchHook": "启动钩子", "group": "分组", "tags": "标签", "note": "备注", @@ -783,6 +788,10 @@ "noRules": "未配置绕过规则。", "ruleTypes": "支持主机名、IP地址和正则表达式模式。" }, + "launchHook": { + "label": "启动钩子 URL", + "placeholder": "https://example.com/hooks/profile-launch" + }, "actions": { "manageCookies": "管理 Cookie", "assignExtensionGroup": "分配扩展程序组" diff --git a/src/types.ts b/src/types.ts index 510a594..0120d65 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,6 +18,7 @@ export interface BrowserProfile { version: string; proxy_id?: string; // Reference to stored proxy vpn_id?: string; // Reference to stored VPN config + launch_hook?: string; process_id?: number; last_launch?: number; release_type: string; // "stable" or "nightly" @@ -135,8 +136,6 @@ export interface StoredProxy { geo_region?: string; geo_city?: string; geo_isp?: string; - dynamic_proxy_url?: string; - dynamic_proxy_format?: string; } export interface LocationItem {