diff --git a/.gitignore b/.gitignore index c65e85d..4f2a279 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,7 @@ nodecar/nodecar-bin .cache/ # env -.env \ No newline at end of file +.env + +# next +next-env.d.ts \ No newline at end of file diff --git a/donut-sync/src/main.ts b/donut-sync/src/main.ts index 3ac4c61..d9cf8e1 100644 --- a/donut-sync/src/main.ts +++ b/donut-sync/src/main.ts @@ -1,4 +1,5 @@ import { NestFactory } from "@nestjs/core"; +import type { NestExpressApplication } from "@nestjs/platform-express"; import { AppModule } from "./app.module.js"; function validateEnv() { @@ -11,7 +12,10 @@ function validateEnv() { async function bootstrap() { validateEnv(); - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); + + // biome-ignore lint/correctness/useHookAtTopLevel: NestJS method, not a React hook + app.useBodyParser("json", { limit: "50mb" }); app.enableCors({ origin: "*", 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_client.rs b/src-tauri/src/api_client.rs index 72ab57d..aff0d2e 100644 --- a/src-tauri/src/api_client.rs +++ b/src-tauri/src/api_client.rs @@ -1124,18 +1124,47 @@ impl ApiClient { log::info!("Fetching Wayfern version from https://donutbrowser.com/wayfern.json"); let url = "https://donutbrowser.com/wayfern.json"; - let response = self - .client - .get(url) - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") - .send() - .await?; + let mut last_err = None; + let mut version_info: Option = None; - if !response.status().is_success() { - return Err(format!("Failed to fetch Wayfern version: {}", response.status()).into()); + for attempt in 1..=3 { + match self + .client + .get(url) + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") + .send() + .await + { + Ok(response) => { + if !response.status().is_success() { + last_err = Some(format!("HTTP {}", response.status())); + } else { + match response.json::().await { + Ok(info) => { + version_info = Some(info); + break; + } + Err(e) => last_err = Some(format!("Failed to parse response: {e}")), + } + } + } + Err(e) => { + log::warn!("Wayfern fetch attempt {attempt}/3 failed: {e}"); + last_err = Some(e.to_string()); + } + } + + if attempt < 3 { + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + } } - let version_info: WayfernVersionInfo = response.json().await?; + let version_info = version_info.ok_or_else(|| { + format!( + "Failed to fetch Wayfern version after 3 attempts: {}", + last_err.unwrap_or_default() + ) + })?; log::info!("Fetched Wayfern version: {}", version_info.version); // Cache the results (unless bypassing cache) diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 5e63ae0..6958c80 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -315,6 +315,7 @@ impl ApiServer { .routes(routes!(download_browser_api)) .routes(routes!(get_browser_versions)) .routes(routes!(check_browser_downloaded)) + .routes(routes!(get_wayfern_token, refresh_wayfern_token)) .split_for_parts(); let api = ApiDoc::openapi(); @@ -333,7 +334,7 @@ impl ApiServer { .with_state(ws_state); let app = Router::new() - .nest("/v1", v1_routes) + .merge(v1_routes) .nest("/ws", ws_routes) .route("/openapi.json", get(move || async move { Json(api) })) .layer(CorsLayer::permissive()) @@ -1501,3 +1502,54 @@ async fn check_browser_downloaded( let is_downloaded = crate::downloaded_browsers_registry::is_browser_downloaded(browser, version); Ok(Json(is_downloaded)) } + +// API Handlers - Wayfern Token + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct WayfernTokenResponse { + pub token: Option, +} + +#[utoipa::path( + get, + path = "/v1/wayfern-token", + responses( + (status = 200, description = "Current wayfern token", body = WayfernTokenResponse), + (status = 401, description = "Unauthorized"), + ), + security( + ("bearer_auth" = []) + ), + tag = "wayfern" +)] +async fn get_wayfern_token( + State(_state): State, +) -> Result, StatusCode> { + let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; + Ok(Json(WayfernTokenResponse { token })) +} + +#[utoipa::path( + post, + path = "/v1/wayfern-token/refresh", + responses( + (status = 200, description = "Refreshed wayfern token", body = WayfernTokenResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Failed to refresh token"), + ), + security( + ("bearer_auth" = []) + ), + tag = "wayfern" +)] +async fn refresh_wayfern_token( + State(_state): State, +) -> Result, (StatusCode, String)> { + crate::cloud_auth::CLOUD_AUTH + .request_wayfern_token() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + + let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; + Ok(Json(WayfernTokenResponse { token })) +} diff --git a/src-tauri/src/app_dirs.rs b/src-tauri/src/app_dirs.rs index 2d41295..83c4876 100644 --- a/src-tauri/src/app_dirs.rs +++ b/src-tauri/src/app_dirs.rs @@ -66,6 +66,10 @@ pub fn proxies_dir() -> PathBuf { data_dir().join("proxies") } +pub fn proxy_workers_dir() -> PathBuf { + cache_dir().join("proxy_workers") +} + pub fn vpn_dir() -> PathBuf { data_dir().join("vpn") } @@ -155,6 +159,7 @@ mod tests { assert!(data_subdir().ends_with("data")); assert!(settings_dir().ends_with("settings")); assert!(proxies_dir().ends_with("proxies")); + assert!(proxy_workers_dir().ends_with("proxy_workers")); assert!(vpn_dir().ends_with("vpn")); assert!(extensions_dir().ends_with("extensions")); } diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 7fd91ac..6c8bb64 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -81,24 +81,25 @@ impl AutoUpdater { } for (browser, profiles) in browser_profiles { - // Get cached versions first, then try to fetch if needed - let versions = if let Some(cached) = self + // Always fetch fresh versions for update checks — stale cache would miss new releases + let versions = match self .browser_version_manager - .get_cached_browser_versions_detailed(&browser) + .fetch_browser_versions_detailed(&browser, false) + .await { - cached - } else if self.browser_version_manager.should_update_cache(&browser) { - // Try to fetch fresh versions - match self - .browser_version_manager - .fetch_browser_versions_detailed(&browser, false) - .await - { - Ok(versions) => versions, - Err(_) => continue, // Skip this browser if fetch fails + Ok(versions) => versions, + Err(e) => { + log::warn!("Failed to fetch versions for {browser}: {e}, trying cache"); + // Fall back to cache if network fails + if let Some(cached) = self + .browser_version_manager + .get_cached_browser_versions_detailed(&browser) + { + cached + } else { + continue; + } } - } else { - continue; // No cached versions and cache doesn't need update }; browser_versions.insert(browser.clone(), versions.clone()); @@ -108,20 +109,29 @@ impl AutoUpdater { if let Some(update) = self.check_profile_update(&profile, &versions)? { // Apply chromium threshold logic if browser == "chromium" { - // For chromium, only show notifications if there are 400+ new versions - let current_version = &profile.version.parse::().unwrap(); - let new_version = &update.new_version.parse::().unwrap(); + // For chromium, only show notifications if there's a significant version jump + // Compare the major version component (first number before the dot) + let current_major: u32 = profile + .version + .split('.') + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + let new_major: u32 = update + .new_version + .split('.') + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); - let result = new_version - current_version; + let result = new_major.saturating_sub(current_major); log::info!( - "Current version: {current_version}, New version: {new_version}, Result: {result}" + "Current major version: {current_major}, New major version: {new_major}, Diff: {result}" ); - if result > 400 { + if result > 0 { notifications.push(update); } else { - log::info!( - "Skipping chromium update notification: only {result} new versions (need 400+)" - ); + log::info!("Skipping chromium update notification: same major version"); } } else { notifications.push(update); @@ -136,15 +146,52 @@ impl AutoUpdater { pub async fn check_for_updates_with_progress(&self, app_handle: &tauri::AppHandle) { log::info!("Starting auto-update check with progress..."); + // Check if auto-updates are disabled in settings + let auto_download = { + let disable = self + .settings_manager + .load_settings() + .map(|s| s.disable_auto_updates) + .unwrap_or(false); + !disable && !cfg!(target_os = "linux") + }; + // Check for browser updates and trigger auto-downloads match self.check_for_updates().await { Ok(update_notifications) => { if !update_notifications.is_empty() { log::info!( - "Found {} browser updates to auto-download", - update_notifications.len() + "Found {} browser updates (auto_download={})", + update_notifications.len(), + auto_download ); + if !auto_download { + // Emit notification events instead of downloading + for notification in update_notifications { + let update_event = serde_json::json!({ + "browser": notification.browser, + "new_version": notification.new_version, + "current_version": notification.current_version, + "affected_profiles": notification.affected_profiles + }); + + if let Err(e) = events::emit("browser-update-available", &update_event) { + log::error!( + "Failed to emit update-available event for {}: {e}", + notification.browser + ); + } else { + log::info!( + "Emitted update-available event for {} {}", + notification.browser, + notification.new_version + ); + } + } + return; + } + // Trigger automatic downloads for each update for notification in update_notifications { log::info!( @@ -323,7 +370,36 @@ impl AutoUpdater { // Check if profile is currently running if profile.process_id.is_some() { - continue; // Skip running profiles + // Store as pending update so it gets applied when browser closes + log::info!( + "Profile {} is running, storing pending update {} -> {}", + profile.name, + profile.version, + new_version + ); + let mut state = self.load_auto_update_state().unwrap_or_default(); + let notification = UpdateNotification { + id: format!("{}_{}_to_{}", browser, profile.version, new_version), + browser: browser.to_string(), + current_version: profile.version.clone(), + new_version: new_version.to_string(), + affected_profiles: vec![profile.name.clone()], + is_stable_update: true, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + // Add if not already pending + if !state + .pending_updates + .iter() + .any(|u| u.id == notification.id) + { + state.pending_updates.push(notification); + let _ = self.save_auto_update_state(&state); + } + continue; } // Check if this is an update (newer version) diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index fedf061..17baef9 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -39,13 +39,23 @@ impl BrowserRunner { } /// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy, - /// then resolve the proxy settings. - async fn resolve_proxy_with_refresh(&self, proxy_id: Option<&String>) -> Option { + /// then resolve the proxy settings with profile-specific sid for sticky sessions. + async fn resolve_proxy_with_refresh( + &self, + proxy_id: Option<&String>, + profile_id: Option<&str>, + ) -> Option { let proxy_id = proxy_id?; 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; } + // For cloud-derived proxies, inject profile-specific sid for sticky sessions + if let Some(pid) = profile_id { + if PROXY_MANAGER.is_cloud_or_derived(proxy_id) { + return PROXY_MANAGER.resolve_proxy_for_profile(proxy_id, pid); + } + } PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) } @@ -106,7 +116,7 @@ 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()) + .resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string())) .await; // If profile has a VPN instead of proxy, start VPN worker and use it as upstream @@ -364,7 +374,7 @@ 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()) + .resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string())) .await; // If profile has a VPN instead of proxy, start VPN worker and use it as upstream @@ -521,6 +531,7 @@ impl BrowserRunner { proxy_url, profile.ephemeral, &extension_paths, + remote_debugging_port, ) .await .map_err(|e| -> Box { @@ -622,7 +633,7 @@ impl BrowserRunner { // Refresh cloud proxy credentials if needed before resolving let _stored_proxy_settings = self - .resolve_proxy_with_refresh(profile.proxy_id.as_ref()) + .resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string())) .await; // Use provided local proxy for Chromium-based browsers launch arguments @@ -1077,10 +1088,10 @@ impl BrowserRunner { ) -> Result> { // Always start a local proxy for API launches // Determine upstream proxy if configured; otherwise use DIRECT - let upstream_proxy = profile - .proxy_id - .as_ref() - .and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id)); + // Refresh cloud proxy credentials before resolving + let upstream_proxy = self + .resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string())) + .await; // Use a temporary PID (1) to start the proxy, we'll update it after browser launch let temp_pid = 1u32; @@ -2539,6 +2550,13 @@ pub async fn launch_browser_profile( // Team lock check: if profile is sync-enabled and user is on a team, acquire lock crate::team_lock::acquire_team_lock_if_needed(&profile).await?; + // Notify sync scheduler that profile is now running + if let Some(scheduler) = crate::sync::get_global_scheduler() { + scheduler + .mark_profile_running(&profile.id.to_string()) + .await; + } + let browser_runner = BrowserRunner::instance(); // Store the internal proxy settings for passing to launch_browser @@ -2569,10 +2587,13 @@ pub async fn launch_browser_profile( // This ensures all traffic goes through the local proxy for monitoring and future features if profile.browser != "camoufox" && profile.browser != "wayfern" { // Determine upstream proxy if configured; otherwise use DIRECT (no upstream) - let mut upstream_proxy = profile_for_launch - .proxy_id - .as_ref() - .and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id)); + // 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()), + ) + .await; // If profile has a VPN instead of proxy, start VPN worker and use it as upstream if upstream_proxy.is_none() { @@ -2746,6 +2767,16 @@ pub async fn kill_browser_profile( // Release team lock if applicable crate::team_lock::release_team_lock_if_needed(&profile).await; + // Notify sync scheduler that profile stopped and queue sync + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let pid = profile.id.to_string(); + scheduler.mark_profile_stopped(&pid).await; + if profile.is_sync_enabled() { + log::info!("Profile '{}' killed, queuing sync", profile.name); + scheduler.queue_profile_sync(pid).await; + } + } + // Auto-update non-running profiles and cleanup unused binaries let browser_for_update = profile.browser.clone(); let app_handle_for_update = app_handle.clone(); diff --git a/src-tauri/src/camoufox_manager.rs b/src-tauri/src/camoufox_manager.rs index 335c8f2..0f1d190 100644 --- a/src-tauri/src/camoufox_manager.rs +++ b/src-tauri/src/camoufox_manager.rs @@ -557,9 +557,11 @@ impl CamoufoxManager { /// Check if a Camoufox server is running with the given process ID async fn is_server_running(&self, process_id: u32) -> bool { // Check if the process is still running - use sysinfo::{Pid, System}; + use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System}; - let system = System::new_all(); + let system = System::new_with_specifics( + RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()), + ); if let Some(process) = system.process(Pid::from(process_id as usize)) { // Check if this is actually a Camoufox process by looking at the command line let cmd = process.cmd(); diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index 8677cc1..8567697 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -81,6 +81,14 @@ struct SyncTokenResponse { sync_token: String, } +#[derive(Debug, Deserialize)] +struct WayfernTokenResponse { + token: String, + #[serde(rename = "expiresIn")] + #[allow(dead_code)] + expires_in: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocationItem { pub code: String, @@ -105,6 +113,7 @@ pub struct CloudAuthManager { client: Client, state: Mutex>, refresh_lock: tokio::sync::Mutex<()>, + wayfern_token: Mutex>, } lazy_static! { @@ -118,6 +127,7 @@ impl CloudAuthManager { client: Client::new(), state: Mutex::new(state), refresh_lock: tokio::sync::Mutex::new(()), + wayfern_token: Mutex::new(None), } } @@ -578,6 +588,9 @@ impl CloudAuthManager { } pub async fn logout(&self) -> Result<(), String> { + // Clear wayfern token + self.clear_wayfern_token().await; + // Disconnect team lock manager crate::team_lock::TEAM_LOCK.disconnect().await; @@ -666,7 +679,7 @@ impl CloudAuthManager { /// API call with 401 retry: if first attempt gets 401, refresh access token and retry once. /// Uses refresh_lock to prevent concurrent token rotations from racing. - async fn api_call_with_retry(&self, make_request: F) -> Result + pub async fn api_call_with_retry(&self, make_request: F) -> Result where F: Fn(String) -> Fut + Send, Fut: std::future::Future> + Send, @@ -697,11 +710,12 @@ impl CloudAuthManager { /// Fetch proxy configuration from the cloud backend async fn fetch_proxy_config(&self) -> Result, String> { - // Check cached user state for proxy bandwidth + // Check cached user state for proxy bandwidth (subscription or extra) { let state = self.state.lock().await; match &*state { - Some(auth) if auth.user.proxy_bandwidth_limit_mb > 0 => {} + Some(auth) + if auth.user.proxy_bandwidth_limit_mb > 0 || auth.user.proxy_bandwidth_extra_mb > 0 => {} _ => return Ok(None), } } @@ -840,13 +854,13 @@ impl CloudAuthManager { .await } - /// Fetch state list for a country from the cloud backend - pub async fn fetch_states(&self, country: &str) -> Result, String> { + /// Fetch region list for a country from the cloud backend + pub async fn fetch_regions(&self, country: &str) -> Result, String> { let country = country.to_string(); self .api_call_with_retry(move |access_token| { let url = format!( - "{CLOUD_API_URL}/api/proxy/locations/states?country={}", + "{CLOUD_API_URL}/api/proxy/locations/regions?country={}", country ); let client = reqwest::Client::new(); @@ -856,37 +870,40 @@ impl CloudAuthManager { .header("Authorization", format!("Bearer {access_token}")) .send() .await - .map_err(|e| format!("Failed to fetch states: {e}"))?; + .map_err(|e| format!("Failed to fetch regions: {e}"))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(format!("States fetch failed ({status}): {body}")); + return Err(format!("Regions fetch failed ({status}): {body}")); } response .json::>() .await - .map_err(|e| format!("Failed to parse states: {e}")) + .map_err(|e| format!("Failed to parse regions: {e}")) } }) .await } - /// Fetch city list for a country+state from the cloud backend + /// Fetch city list for a country, optionally filtered by region pub async fn fetch_cities( &self, country: &str, - state: &str, + region: Option<&str>, ) -> Result, String> { let country = country.to_string(); - let state = state.to_string(); + let region = region.map(|s| s.to_string()); self .api_call_with_retry(move |access_token| { - let url = format!( - "{CLOUD_API_URL}/api/proxy/locations/cities?country={}&state={}", - country, state + let mut url = format!( + "{CLOUD_API_URL}/api/proxy/locations/cities?country={}", + country ); + if let Some(ref r) = region { + url.push_str(&format!("®ion={}", r)); + } let client = reqwest::Client::new(); async move { let response = client @@ -911,8 +928,108 @@ impl CloudAuthManager { .await } + /// Fetch ISP list for a country, optionally filtered by region and city + pub async fn fetch_isps( + &self, + country: &str, + region: Option<&str>, + city: Option<&str>, + ) -> Result, String> { + let country = country.to_string(); + let region = region.map(|s| s.to_string()); + let city = city.map(|s| s.to_string()); + self + .api_call_with_retry(move |access_token| { + let mut url = format!( + "{CLOUD_API_URL}/api/proxy/locations/isps?country={}", + country + ); + if let Some(ref r) = region { + url.push_str(&format!("®ion={}", r)); + } + if let Some(ref c) = city { + url.push_str(&format!("&city={}", c)); + } + let client = reqwest::Client::new(); + async move { + let response = client + .get(&url) + .header("Authorization", format!("Bearer {access_token}")) + .send() + .await + .map_err(|e| format!("Failed to fetch ISPs: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("ISPs fetch failed ({status}): {body}")); + } + + response + .json::>() + .await + .map_err(|e| format!("Failed to parse ISPs: {e}")) + } + }) + .await + } + + /// Request a wayfern token from the cloud API. Only succeeds for paid users. + pub async fn request_wayfern_token(&self) -> Result<(), String> { + if !self.has_active_paid_subscription().await { + self.clear_wayfern_token().await; + return Ok(()); + } + + let token = self + .api_call_with_retry(|access_token| { + let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start"); + let client = reqwest::Client::new(); + async move { + let response = client + .post(&url) + .header("Authorization", format!("Bearer {access_token}")) + .send() + .await + .map_err(|e| format!("Failed to request wayfern token: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Wayfern token request failed ({status}): {body}")); + } + + let result: WayfernTokenResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse wayfern token response: {e}"))?; + + Ok(result.token) + } + }) + .await?; + + let mut wt = self.wayfern_token.lock().await; + *wt = Some(token); + log::info!("Wayfern token acquired"); + Ok(()) + } + + /// Get the current wayfern token, if any. + pub async fn get_wayfern_token(&self) -> Option { + let wt = self.wayfern_token.lock().await; + wt.clone() + } + + /// Clear the cached wayfern token. + pub async fn clear_wayfern_token(&self) { + let mut wt = self.wayfern_token.lock().await; + *wt = None; + } + /// Background loop that refreshes the sync token periodically pub async fn start_sync_token_refresh_loop(app_handle: tauri::AppHandle) { + let mut wayfern_refresh_counter: u32 = 0; loop { tokio::time::sleep(std::time::Duration::from_secs(600)).await; // 10 minutes @@ -920,6 +1037,8 @@ impl CloudAuthManager { continue; } + wayfern_refresh_counter += 1; + // Proactively refresh the access token if it's expired or expiring soon. // This runs first so subsequent API calls use a fresh token. if let Ok(Some(token)) = Self::load_access_token() { @@ -961,6 +1080,18 @@ impl CloudAuthManager { // Sync cloud proxy credentials CLOUD_AUTH.sync_cloud_proxy().await; + // Refresh wayfern token every 12 hours (72 iterations of 10-minute loop) + if wayfern_refresh_counter >= 72 { + wayfern_refresh_counter = 0; + if CLOUD_AUTH.has_active_paid_subscription().await { + if let Err(e) = CLOUD_AUTH.request_wayfern_token().await { + log::warn!("Failed to refresh wayfern token: {e}"); + } + } else { + CLOUD_AUTH.clear_wayfern_token().await; + } + } + let _ = &app_handle; // keep app_handle alive } } @@ -996,6 +1127,11 @@ pub async fn cloud_verify_otp( Ok(None) => log::warn!("Sync token not available despite active subscription"), Err(e) => log::error!("Failed to pre-fetch sync token after login: {e}"), } + + // Request wayfern token for paid users + if let Err(e) = CLOUD_AUTH.request_wayfern_token().await { + log::warn!("Failed to request wayfern token after login: {e}"); + } } // Sync cloud proxy after login @@ -1037,6 +1173,9 @@ pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> { } let _ = manager.remove_sync_token(&app_handle).await; + // Remove cloud-managed and cloud-derived proxies + crate::proxy_manager::PROXY_MANAGER.remove_cloud_proxies(); + let _ = crate::events::emit_empty("cloud-auth-changed"); Ok(()) } @@ -1046,33 +1185,59 @@ pub async fn cloud_has_active_subscription() -> Result { Ok(CLOUD_AUTH.has_active_paid_subscription().await) } +#[tauri::command] +pub async fn cloud_get_wayfern_token() -> Result, String> { + Ok(CLOUD_AUTH.get_wayfern_token().await) +} + +#[tauri::command] +pub async fn cloud_refresh_wayfern_token() -> Result, String> { + CLOUD_AUTH.request_wayfern_token().await?; + Ok(CLOUD_AUTH.get_wayfern_token().await) +} + #[tauri::command] pub async fn cloud_get_countries() -> Result, String> { CLOUD_AUTH.fetch_countries().await } #[tauri::command] -pub async fn cloud_get_states(country: String) -> Result, String> { - CLOUD_AUTH.fetch_states(&country).await +pub async fn cloud_get_regions(country: String) -> Result, String> { + CLOUD_AUTH.fetch_regions(&country).await } #[tauri::command] -pub async fn cloud_get_cities(country: String, state: String) -> Result, String> { - CLOUD_AUTH.fetch_cities(&country, &state).await +pub async fn cloud_get_cities( + country: String, + region: Option, +) -> Result, String> { + CLOUD_AUTH.fetch_cities(&country, region.as_deref()).await +} + +#[tauri::command] +pub async fn cloud_get_isps( + country: String, + region: Option, + city: Option, +) -> Result, String> { + CLOUD_AUTH + .fetch_isps(&country, region.as_deref(), city.as_deref()) + .await } #[tauri::command] pub async fn create_cloud_location_proxy( name: String, country: String, - state: Option, + region: Option, city: Option, + isp: Option, ) -> Result { // If no cloud proxy exists yet, attempt to sync it first if !PROXY_MANAGER.has_cloud_proxy() { CLOUD_AUTH.sync_cloud_proxy().await; } - PROXY_MANAGER.create_cloud_location_proxy(name, country, state, city) + PROXY_MANAGER.create_cloud_location_proxy(name, country, region, city, isp) } #[derive(Debug, Serialize)] @@ -1080,22 +1245,108 @@ pub struct CloudProxyUsage { pub used_mb: i64, pub limit_mb: i64, pub remaining_mb: i64, + pub recurring_limit_mb: i64, + pub extra_limit_mb: i64, +} + +#[derive(Debug, Deserialize)] +struct ProxyUsageResponse { + #[serde(rename = "usedMb")] + used_mb: i64, + #[serde(rename = "limitMb")] + limit_mb: i64, + #[serde(rename = "remainingMb")] + remaining_mb: i64, + #[serde(rename = "recurringLimitMb", default)] + recurring_limit_mb: i64, + #[serde(rename = "extraLimitMb", default)] + extra_limit_mb: i64, } #[tauri::command] pub async fn cloud_get_proxy_usage() -> Result, String> { - let state = CLOUD_AUTH.state.lock().await; - match &*state { - Some(auth) if auth.user.proxy_bandwidth_limit_mb > 0 => { - let used = auth.user.proxy_bandwidth_used_mb; - let limit = auth.user.proxy_bandwidth_limit_mb; - Ok(Some(CloudProxyUsage { - used_mb: used, - limit_mb: limit, - remaining_mb: (limit - used).max(0), - })) + let (has_proxy, cached_recurring, cached_extra) = { + let state = CLOUD_AUTH.state.lock().await; + match &*state { + Some(auth) + if auth.user.proxy_bandwidth_limit_mb > 0 || auth.user.proxy_bandwidth_extra_mb > 0 => + { + ( + true, + auth.user.proxy_bandwidth_limit_mb, + auth.user.proxy_bandwidth_extra_mb, + ) + } + _ => return Ok(None), + } + }; + + if !has_proxy { + return Ok(None); + } + + // Fetch live usage from the API + match CLOUD_AUTH + .api_call_with_retry(|access_token| { + let url = format!("{CLOUD_API_URL}/api/proxy/usage"); + let client = reqwest::Client::new(); + async move { + let response = client + .get(&url) + .header("Authorization", format!("Bearer {access_token}")) + .send() + .await + .map_err(|e| format!("Failed to fetch proxy usage: {e}"))?; + + if !response.status().is_success() { + return Err(format!( + "Proxy usage API returned status {}", + response.status() + )); + } + + response + .json::() + .await + .map_err(|e| format!("Failed to parse proxy usage: {e}")) + } + }) + .await + { + Ok(usage) => Ok(Some(CloudProxyUsage { + used_mb: usage.used_mb, + limit_mb: usage.limit_mb, + remaining_mb: usage.remaining_mb, + recurring_limit_mb: if usage.recurring_limit_mb > 0 { + usage.recurring_limit_mb + } else { + cached_recurring + }, + extra_limit_mb: if usage.recurring_limit_mb > 0 { + usage.extra_limit_mb + } else { + cached_extra + }, + })), + Err(e) => { + log::warn!("Failed to fetch live proxy usage, falling back to cached: {e}"); + // Fallback to cached values + let state = CLOUD_AUTH.state.lock().await; + match &*state { + Some(auth) => { + let used = auth.user.proxy_bandwidth_used_mb; + let total = cached_recurring + cached_extra; + Ok(Some(CloudProxyUsage { + used_mb: used, + limit_mb: total, + remaining_mb: (total - used).max(0), + recurring_limit_mb: cached_recurring, + extra_limit_mb: cached_extra, + })) + } + _ => Ok(None), + } } - _ => Ok(None), } } diff --git a/src-tauri/src/extension_manager.rs b/src-tauri/src/extension_manager.rs index de68542..0ab5a8a 100644 --- a/src-tauri/src/extension_manager.rs +++ b/src-tauri/src/extension_manager.rs @@ -19,6 +19,14 @@ pub struct Extension { pub sync_enabled: bool, #[serde(default)] pub last_sync: Option, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub author: Option, + #[serde(default)] + pub homepage_url: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -71,6 +79,166 @@ fn get_file_type(file_name: &str) -> Option { } } +fn find_zip_start(data: &[u8]) -> usize { + for i in 0..data.len().saturating_sub(3) { + if data[i] == 0x50 && data[i + 1] == 0x4B && data[i + 2] == 0x03 && data[i + 3] == 0x04 { + return i; + } + } + 0 +} + +#[allow(clippy::type_complexity)] +fn extract_manifest_metadata( + file_data: &[u8], + file_type: &str, +) -> ( + Option, + Option, + Option, + Option, + Option, +) { + let zip_start = if file_type == "crx" { + find_zip_start(file_data) + } else { + 0 + }; + + let cursor = std::io::Cursor::new(&file_data[zip_start..]); + let mut archive = match zip::ZipArchive::new(cursor) { + Ok(a) => a, + Err(_) => return (None, None, None, None, None), + }; + + let manifest_content = if let Ok(mut file) = archive.by_name("manifest.json") { + let mut contents = String::new(); + if std::io::Read::read_to_string(&mut file, &mut contents).is_ok() { + Some(contents) + } else { + None + } + } else { + None + }; + + let manifest_content = match manifest_content { + Some(c) => c, + None => return (None, None, None, None, None), + }; + + let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) { + Ok(v) => v, + Err(_) => return (None, None, None, None, None), + }; + + let name = manifest + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let version = manifest + .get("version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let description = manifest + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let author = manifest + .get("author") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let homepage_url = manifest + .get("homepage_url") + .or_else(|| manifest.get("homepage")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + (name, version, description, author, homepage_url) +} + +fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec, String)> { + let zip_start = if file_type == "crx" { + find_zip_start(file_data) + } else { + 0 + }; + + let cursor = std::io::Cursor::new(&file_data[zip_start..]); + let mut archive = match zip::ZipArchive::new(cursor) { + Ok(a) => a, + Err(_) => return None, + }; + + let icon_path = { + let manifest_content = if let Ok(mut file) = archive.by_name("manifest.json") { + let mut contents = String::new(); + if std::io::Read::read_to_string(&mut file, &mut contents).is_ok() { + Some(contents) + } else { + None + } + } else { + None + }; + + let manifest_content = manifest_content?; + let manifest: serde_json::Value = serde_json::from_str(&manifest_content).ok()?; + + let mut best_path: Option = None; + let mut best_size: u32 = 0; + + if let Some(icons) = manifest.get("icons").and_then(|v| v.as_object()) { + for (size_str, path_val) in icons { + if let (Ok(size), Some(path)) = (size_str.parse::(), path_val.as_str()) { + if size > best_size { + best_size = size; + best_path = Some(path.to_string()); + } + } + } + } + + if best_path.is_none() { + for key in &["action", "browser_action"] { + if let Some(action) = manifest.get(*key) { + if let Some(icon) = action.get("default_icon") { + if let Some(path) = icon.as_str() { + best_path = Some(path.to_string()); + } else if let Some(icons) = icon.as_object() { + for (size_str, path_val) in icons { + if let (Ok(size), Some(path)) = (size_str.parse::(), path_val.as_str()) { + if size > best_size { + best_size = size; + best_path = Some(path.to_string()); + } + } + } + } + } + } + } + } + + best_path + }; + + let icon_path = icon_path?; + + let clean_path = icon_path.trim_start_matches('/'); + let mut file = archive.by_name(clean_path).ok()?; + let mut data = Vec::new(); + std::io::Read::read_to_end(&mut file, &mut data).ok()?; + + let ext = clean_path + .rsplit('.') + .next() + .unwrap_or("png") + .to_lowercase(); + + Some((data, ext)) +} + pub struct ExtensionManager; impl ExtensionManager { @@ -108,9 +276,18 @@ impl ExtensionManager { let browser_compatibility = determine_browser_compatibility(&file_type); let now = now_secs(); + let (manifest_name, version, description, author, homepage_url) = + extract_manifest_metadata(&file_data, &file_type); + + let final_name = if manifest_name.is_some() { + manifest_name.clone().unwrap_or(name) + } else { + name + }; + let ext = Extension { id: uuid::Uuid::new_v4().to_string(), - name, + name: final_name, file_name: file_name.clone(), file_type, browser_compatibility, @@ -118,12 +295,23 @@ impl ExtensionManager { updated_at: now, sync_enabled: crate::sync::is_sync_configured(), last_sync: None, + version, + description, + author, + homepage_url, }; let file_dir = self.get_file_dir(&ext.id); fs::create_dir_all(&file_dir)?; fs::write(file_dir.join(&file_name), &file_data)?; + if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&file_data, &ext.file_type) { + let icon_path = self + .get_extension_dir(&ext.id) + .join(format!("icon.{icon_ext}")); + let _ = fs::write(icon_path, icon_data); + } + let metadata_path = self.get_metadata_path(&ext.id); let json = serde_json::to_string_pretty(&ext)?; fs::write(metadata_path, json)?; @@ -187,6 +375,7 @@ impl ExtensionManager { ) -> Result> { let mut ext = self.get_extension(id)?; + let explicit_name_provided = name.is_some(); if let Some(new_name) = name { ext.name = new_name; } @@ -206,6 +395,31 @@ impl ExtensionManager { ext.file_name = new_file_name; ext.file_type = new_file_type.clone(); ext.browser_compatibility = determine_browser_compatibility(&new_file_type); + + let (manifest_name, version, description, author, homepage_url) = + extract_manifest_metadata(&data, &new_file_type); + if let Some(v) = version { + ext.version = Some(v); + } + if let Some(d) = description { + ext.description = Some(d); + } + if let Some(a) = author { + ext.author = Some(a); + } + if let Some(h) = homepage_url { + ext.homepage_url = Some(h); + } + if let Some(mn) = manifest_name { + if !explicit_name_provided { + ext.name = mn; + } + } + + if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&data, &new_file_type) { + let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}")); + let _ = fs::write(icon_path, icon_data); + } } ext.updated_at = now_secs(); @@ -777,6 +991,95 @@ impl ExtensionManager { let magic = [0x50, 0x4B, 0x03, 0x04]; data.windows(4).position(|window| window == magic) } + + pub fn ensure_icons_extracted(&self) { + let extensions = match self.list_extensions() { + Ok(exts) => exts, + Err(_) => return, + }; + + for ext in extensions { + let ext_dir = self.get_extension_dir(&ext.id); + let has_icon = ext_dir + .read_dir() + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .any(|e| e.file_name().to_string_lossy().starts_with("icon.")) + }) + .unwrap_or(false); + + if has_icon { + continue; + } + + let file_dir = self.get_file_dir(&ext.id); + let file_path = file_dir.join(&ext.file_name); + if let Ok(file_data) = fs::read(&file_path) { + if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&file_data, &ext.file_type) { + let icon_path = ext_dir.join(format!("icon.{icon_ext}")); + let _ = fs::write(icon_path, icon_data); + } + } + + if ext.version.is_none() && ext.description.is_none() { + let file_path = file_dir.join(&ext.file_name); + if let Ok(file_data) = fs::read(&file_path) { + let (manifest_name, version, description, author, homepage_url) = + extract_manifest_metadata(&file_data, &ext.file_type); + if version.is_some() + || description.is_some() + || author.is_some() + || homepage_url.is_some() + || manifest_name.is_some() + { + let mut updated_ext = ext.clone(); + if let Some(v) = version { + updated_ext.version = Some(v); + } + if let Some(d) = description { + updated_ext.description = Some(d); + } + if let Some(a) = author { + updated_ext.author = Some(a); + } + if let Some(h) = homepage_url { + updated_ext.homepage_url = Some(h); + } + let metadata_path = self.get_metadata_path(&ext.id); + if let Ok(json) = serde_json::to_string_pretty(&updated_ext) { + let _ = fs::write(metadata_path, json); + } + } + } + } + } + } + + pub fn get_extension_icon(&self, ext_id: &str) -> Option { + let ext_dir = self.get_extension_dir(ext_id); + let entries = ext_dir.read_dir().ok()?; + for entry in entries.filter_map(|e| e.ok()) { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("icon.") { + let icon_path = entry.path(); + let data = fs::read(&icon_path).ok()?; + let ext = name.rsplit('.').next().unwrap_or("png"); + let mime = match ext { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "svg" => "image/svg+xml", + "gif" => "image/gif", + "webp" => "image/webp", + _ => "image/png", + }; + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&data); + return Some(format!("data:{};base64,{}", mime, b64)); + } + } + None + } } // Global instance @@ -800,6 +1103,12 @@ pub async fn list_extensions() -> Result, String> { .map_err(|e| format!("Failed to list extensions: {e}")) } +#[tauri::command] +pub fn get_extension_icon(extension_id: String) -> Option { + let manager = crate::extension_manager::ExtensionManager::new(); + manager.get_extension_icon(&extension_id) +} + #[tauri::command] pub async fn add_extension( name: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b268c5f..436d574 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -117,8 +117,9 @@ 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, + delete_extension, delete_extension_group, get_extension_group_for_profile, get_extension_icon, + list_extension_groups, list_extensions, remove_extension_from_group, update_extension, + update_extension_group, }; use group_manager::{ @@ -303,7 +304,33 @@ async fn copy_profile_cookies( { return Err("Cookie copying requires an active Pro subscription".to_string()); } - cookie_manager::CookieManager::copy_cookies(&app_handle, request).await + let target_ids = request.target_profile_ids.clone(); + let results = cookie_manager::CookieManager::copy_cookies(&app_handle, request).await?; + + // Trigger sync for target profiles that have sync enabled + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let profile_manager = profile::manager::ProfileManager::instance(); + if let Ok(profiles) = profile_manager.list_profiles() { + let sync_ids: Vec = target_ids + .iter() + .filter(|tid| { + profiles + .iter() + .any(|p| p.id.to_string() == **tid && p.is_sync_enabled()) + }) + .cloned() + .collect(); + if !sync_ids.is_empty() { + tauri::async_runtime::spawn(async move { + for id in sync_ids { + scheduler.queue_profile_sync(id).await; + } + }); + } + } + } + + Ok(results) } #[tauri::command] @@ -318,7 +345,25 @@ async fn import_cookies_from_file( { return Err("Cookie import requires an active Pro subscription".to_string()); } - cookie_manager::CookieManager::import_cookies(&app_handle, &profile_id, &content).await + let result = + cookie_manager::CookieManager::import_cookies(&app_handle, &profile_id, &content).await?; + + // Trigger sync for the profile if sync is enabled + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let profile_manager = profile::manager::ProfileManager::instance(); + if let Ok(profiles) = profile_manager.list_profiles() { + if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) { + if profile.is_sync_enabled() { + let pid = profile_id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_profile_sync(pid).await; + }); + } + } + } + } + + Ok(result) } #[tauri::command] @@ -756,6 +801,62 @@ async fn list_active_vpn_connections() -> Result, String> { ) } +#[tauri::command] +async fn generate_sample_fingerprint( + app_handle: tauri::AppHandle, + browser: String, + version: String, + config_json: String, +) -> Result { + let temp_profile = crate::profile::BrowserProfile { + id: uuid::Uuid::new_v4(), + name: "temp_fingerprint_gen".to_string(), + browser: browser.clone(), + version: version.clone(), + process_id: None, + proxy_id: None, + vpn_id: None, + last_launch: None, + release_type: "stable".to_string(), + camoufox_config: None, + wayfern_config: None, + group_id: None, + tags: Vec::new(), + note: None, + sync_mode: crate::profile::types::SyncMode::Disabled, + encryption_salt: None, + last_sync: None, + host_os: None, + ephemeral: false, + extension_group_id: None, + proxy_bypass_rules: Vec::new(), + created_by_id: None, + created_by_email: None, + }; + + if browser == "camoufox" { + let config: crate::camoufox_manager::CamoufoxConfig = + serde_json::from_str(&config_json).map_err(|e| format!("Failed to parse config: {e}"))?; + let manager = crate::camoufox_manager::CamoufoxManager::instance(); + manager + .generate_fingerprint_config(&app_handle, &temp_profile, &config) + .await + .map_err(|e| format!("Failed to generate fingerprint: {e}")) + } else if browser == "wayfern" { + let config: crate::wayfern_manager::WayfernConfig = + serde_json::from_str(&config_json).map_err(|e| format!("Failed to parse config: {e}"))?; + let manager = crate::wayfern_manager::WayfernManager::instance(); + manager + .generate_fingerprint_config(&app_handle, &temp_profile, &config) + .await + .map_err(|e| format!("Failed to generate fingerprint: {e}")) + } else { + Err(format!( + "Unsupported browser for fingerprint generation: {browser}" + )) + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let args: Vec = env::args().collect(); @@ -818,6 +919,12 @@ pub fn run() { // Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk) ephemeral_dirs::recover_ephemeral_dirs(); + // Extract icons and metadata for existing extensions that don't have them yet + { + let mgr = extension_manager::ExtensionManager::new(); + mgr.ensure_icons_extracted(); + } + // Start the daemon for tray icon if let Err(e) = daemon_spawn::ensure_daemon_running() { log::warn!("Failed to start daemon: {e}"); @@ -961,6 +1068,71 @@ pub fn run() { version_updater::VersionUpdater::run_background_task().await; }); + // TODO(v0.17+): Remove this migration block after a few releases. + // Migrate proxy/VPN worker configs from old proxies/ dir to new proxy_workers/ cache dir. + // Before v0.16, ephemeral worker configs (proxy_*, vpnw_*) lived alongside persistent + // StoredProxy files in proxies/. Now they live in cache_dir/proxy_workers/. + { + let old_dir = crate::app_dirs::proxies_dir(); + let new_dir = crate::app_dirs::proxy_workers_dir(); + if old_dir.exists() { + if let Err(e) = std::fs::create_dir_all(&new_dir) { + log::error!("Failed to create proxy_workers dir: {e}"); + } else if let Ok(entries) = std::fs::read_dir(&old_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if (name.starts_with("proxy_") || name.starts_with("vpnw_")) + && name.ends_with(".json") + { + let dest = new_dir.join(name); + match std::fs::rename(&path, &dest) { + Ok(()) => log::info!("Migrated worker config {name} to proxy_workers/"), + Err(e) => { + // rename fails across filesystems, fall back to copy+delete + if let Ok(content) = std::fs::read(&path) { + if std::fs::write(&dest, &content).is_ok() { + let _ = std::fs::remove_file(&path); + log::info!("Migrated worker config {name} to proxy_workers/ (copy)"); + } + } else { + log::warn!("Failed to migrate worker config {name}: {e}"); + } + } + } + } + } + } + } + } + } + + // Clear stale process IDs from profiles (processes that died while app was closed) + { + let profile_manager = crate::profile::ProfileManager::instance(); + if let Ok(profiles) = profile_manager.list_profiles() { + let system = sysinfo::System::new_with_specifics( + sysinfo::RefreshKind::nothing() + .with_processes(sysinfo::ProcessRefreshKind::everything()), + ); + for profile in profiles { + if let Some(pid) = profile.process_id { + let sysinfo_pid = sysinfo::Pid::from_u32(pid); + if system.process(sysinfo_pid).is_none() { + log::info!( + "Clearing stale process_id {} for profile {}", + pid, + profile.name + ); + let mut updated = profile.clone(); + updated.process_id = None; + let _ = profile_manager.save_profile(&updated); + } + } + } + } + } + let app_handle_auto_updater = app.handle().clone(); // Start the auto-update check task separately @@ -1187,6 +1359,20 @@ pub fn run() { ); } + // Notify sync scheduler of running state changes + if let Some(scheduler) = sync::get_global_scheduler() { + if is_running { + scheduler.mark_profile_running(&profile_id).await; + } else { + scheduler.mark_profile_stopped(&profile_id).await; + // Queue sync after profile stops (if sync is enabled) + if profile.is_sync_enabled() { + log::info!("Profile '{}' stopped, queuing sync", profile.name); + scheduler.queue_profile_sync(profile_id.clone()).await; + } + } + } + last_running_states.insert(profile_id, is_running); } else { // Update the state even if unchanged to ensure we have it tracked @@ -1314,6 +1500,13 @@ pub fn run() { log::warn!("Failed to refresh cloud sync token on startup: {e}"); } cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await; + + // Request wayfern token on startup for paid users + if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await { + if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await { + log::warn!("Failed to request wayfern token on startup: {e}"); + } + } } cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await; }); @@ -1386,6 +1579,7 @@ pub fn run() { import_proxies_from_parsed, update_camoufox_config, update_wayfern_config, + generate_sample_fingerprint, get_profile_groups, get_groups_with_profile_counts, create_profile_group, @@ -1394,6 +1588,7 @@ pub fn run() { assign_profiles_to_group, delete_selected_profiles, list_extensions, + get_extension_icon, add_extension, update_extension, delete_extension, @@ -1464,10 +1659,13 @@ pub fn run() { cloud_auth::cloud_logout, cloud_auth::cloud_get_proxy_usage, cloud_auth::cloud_get_countries, - cloud_auth::cloud_get_states, + cloud_auth::cloud_get_regions, cloud_auth::cloud_get_cities, + cloud_auth::cloud_get_isps, cloud_auth::create_cloud_location_proxy, cloud_auth::restart_sync_service, + cloud_auth::cloud_get_wayfern_token, + cloud_auth::cloud_refresh_wayfern_token, // Team lock commands team_lock::get_team_locks, team_lock::get_team_lock_status, @@ -1514,6 +1712,9 @@ mod tests { "set_extension_sync_enabled", "set_extension_group_sync_enabled", "get_team_lock_status", + "generate_sample_fingerprint", + "cloud_get_wayfern_token", + "cloud_refresh_wayfern_token", ]; // Extract command names from the generate_handler! macro in this file diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 49e5a60..a19c79d 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -108,10 +108,15 @@ pub struct StoredProxy { pub is_cloud_derived: bool, #[serde(default)] pub geo_country: Option, + // Legacy field kept for deserialization compat; mapped to geo_region on load #[serde(default)] pub geo_state: Option, #[serde(default)] + pub geo_region: Option, + #[serde(default)] pub geo_city: Option, + #[serde(default)] + pub geo_isp: Option, } impl StoredProxy { @@ -127,10 +132,24 @@ impl StoredProxy { is_cloud_derived: false, geo_country: None, geo_state: None, + geo_region: None, geo_city: None, + geo_isp: None, } } + /// 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() { + self.geo_region = self.geo_state.take(); + } + } + + /// Get the effective region (prefers geo_region, falls back to geo_state for compat) + pub fn effective_region(&self) -> Option<&String> { + self.geo_region.as_ref().or(self.geo_state.as_ref()) + } + pub fn update_settings(&mut self, proxy_settings: ProxySettings) { self.proxy_settings = proxy_settings; } @@ -298,28 +317,21 @@ impl ProxyManager { if path.extension().is_some_and(|ext| ext == "json") { match fs::read_to_string(&path) { - Ok(content) => { - match serde_json::from_str::(&content) { - Ok(proxy) => { - log::debug!("Loaded stored proxy: {} ({})", proxy.name, proxy.id); - stored_proxies.insert(proxy.id.clone(), proxy); - loaded_count += 1; - } - Err(e) => { - // Check if this is a ProxyConfig file (from proxy_storage.rs) - skip it - if serde_json::from_str::(&content).is_ok() { - log::debug!("Skipping ProxyConfig file (not a StoredProxy): {:?}", path); - } else { - log::warn!( - "Failed to parse proxy file {:?} as StoredProxy: {}", - path, - e - ); - error_count += 1; - } - } + Ok(content) => match serde_json::from_str::(&content) { + Ok(proxy) => { + log::debug!("Loaded stored proxy: {} ({})", proxy.name, proxy.id); + stored_proxies.insert(proxy.id.clone(), proxy); + loaded_count += 1; } - } + Err(e) => { + log::warn!( + "Failed to parse proxy file {:?} as StoredProxy: {}", + path, + e + ); + error_count += 1; + } + }, Err(e) => { log::warn!("Failed to read proxy file {:?}: {}", path, e); error_count += 1; @@ -435,7 +447,9 @@ impl ProxyManager { is_cloud_derived: false, geo_country: None, geo_state: None, + geo_region: None, geo_city: None, + geo_isp: None, }; stored_proxies.insert(CLOUD_PROXY_ID.to_string(), cloud_proxy.clone()); drop(stored_proxies); @@ -467,31 +481,117 @@ impl ProxyManager { } } + pub fn remove_cloud_proxies(&self) { + let removed_ids: Vec = { + let mut stored_proxies = self.stored_proxies.lock().unwrap(); + let ids_to_remove: Vec = stored_proxies + .values() + .filter(|p| p.is_cloud_managed || p.is_cloud_derived) + .map(|p| p.id.clone()) + .collect(); + for id in &ids_to_remove { + stored_proxies.remove(id); + } + ids_to_remove + }; + + if !removed_ids.is_empty() { + for id in &removed_ids { + if let Err(e) = self.delete_proxy_file(id) { + log::warn!("Failed to delete cloud proxy file {id}: {e}"); + } + } + if let Err(e) = events::emit_empty("proxies-changed") { + log::error!("Failed to emit proxies-changed event: {e}"); + } + if let Err(e) = events::emit_empty("stored-proxies-changed") { + log::error!("Failed to emit stored-proxies-changed event: {e}"); + } + } + } + // Build a geo-targeted username from base username and location parts - // LP format: username-zone-lightning-region-{country}-st-{state}-city-{city} + // LP v2 format: username-country-{cc}[-region-{region}][-city-{city}][-isp-{isp}] + // Note: sid and ttl are NOT included here — they are injected at browser launch time + // per-profile via resolve_proxy_for_profile() fn build_geo_username( base_username: &str, country: &str, - state: &Option, + region: &Option, city: &Option, + isp: &Option, ) -> String { - let mut username = format!("{}-zone-lightning-region-{}", base_username, country); - if let Some(state) = state { - username = format!("{}-st-{}", username, state); + let mut username = format!("{}-country-{}", base_username, country); + if let Some(region) = region { + username = format!("{}-region-{}", username, region); } if let Some(city) = city { username = format!("{}-city-{}", username, city); } + if let Some(isp) = isp { + username = format!("{}-isp-{}", username, isp); + } username } + /// Generate a deterministic 11-char alphanumeric session ID from a profile UUID. + /// This ensures the same profile always gets the same sticky IP session, + /// even across credential refreshes. + pub fn generate_sid_for_profile(profile_id: &str) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + profile_id.hash(&mut hasher); + let hash = hasher.finish(); + + // Convert to base36 (a-z0-9) and take 11 chars + let chars: Vec = "abcdefghijklmnopqrstuvwxyz0123456789".chars().collect(); + let mut sid = String::with_capacity(11); + let mut val = hash; + for _ in 0..11 { + sid.push(chars[(val % 36) as usize]); + val /= 36; + } + sid + } + + /// Build the full proxy username with sid and ttl for a specific profile launch. + /// This is called at browser launch time, not at proxy creation time. + pub fn build_username_with_sid(base_geo_username: &str, profile_id: &str) -> String { + let sid = Self::generate_sid_for_profile(profile_id); + format!("{}-sid-{}-ttl-1440m", base_geo_username, sid) + } + + /// Resolve proxy settings for a specific profile, injecting profile-specific sid + /// for cloud-derived proxies with geo targeting. + pub fn resolve_proxy_for_profile( + &self, + proxy_id: &str, + profile_id: &str, + ) -> Option { + let stored_proxies = self.stored_proxies.lock().unwrap(); + let proxy = stored_proxies.get(proxy_id)?; + let mut settings = proxy.proxy_settings.clone(); + + // For cloud-derived proxies with geo targeting, inject profile-specific sid + if proxy.is_cloud_derived && proxy.geo_country.is_some() { + if let Some(ref username) = settings.username { + settings.username = Some(Self::build_username_with_sid(username, profile_id)); + } + } + + Some(settings) + } + // Create a cloud-derived location proxy from the base cloud proxy credentials pub fn create_cloud_location_proxy( &self, name: String, country: String, - state: Option, + region: Option, city: Option, + isp: Option, ) -> Result { // Get base cloud proxy credentials let base_proxy = { @@ -508,7 +608,7 @@ impl ProxyManager { .as_ref() .ok_or_else(|| "Cloud proxy has no username".to_string())?; - let geo_username = Self::build_geo_username(base_username, &country, &state, &city); + let geo_username = Self::build_geo_username(base_username, &country, ®ion, &city, &isp); let proxy_settings = ProxySettings { proxy_type: base_proxy.proxy_settings.proxy_type.clone(), @@ -535,8 +635,10 @@ impl ProxyManager { is_cloud_managed: false, is_cloud_derived: true, geo_country: Some(country), - geo_state: state, + geo_state: None, + geo_region: region, geo_city: city, + geo_isp: isp, }; { @@ -583,8 +685,14 @@ impl ProxyManager { None => continue, }; - let geo_username = - Self::build_geo_username(&base_username, &country, &proxy.geo_state, &proxy.geo_city); + let region = proxy.effective_region().cloned(); + let geo_username = Self::build_geo_username( + &base_username, + &country, + ®ion, + &proxy.geo_city, + &proxy.geo_isp, + ); proxy.proxy_settings.username = Some(geo_username); proxy.proxy_settings.password = base_proxy.proxy_settings.password.clone(); @@ -1674,6 +1782,56 @@ impl ProxyManager { Ok(dead_pids) } + + /// Snapshot the set of tracked proxy IDs (for asserting in tests). + #[cfg(test)] + fn tracked_proxy_ids(&self) -> std::collections::HashSet { + let proxies = self.active_proxies.lock().unwrap(); + proxies.values().map(|p| p.id.clone()).collect() + } + + /// Snapshot active proxy count. + #[cfg(test)] + fn active_proxy_count(&self) -> usize { + self.active_proxies.lock().unwrap().len() + } + + /// Snapshot profile-to-proxy-id mapping count. + #[cfg(test)] + fn profile_proxy_mapping_count(&self) -> usize { + self.profile_active_proxy_ids.lock().unwrap().len() + } + + /// Insert a proxy info entry directly (for testing). + #[cfg(test)] + fn insert_active_proxy(&self, browser_pid: u32, info: ProxyInfo) { + self + .active_proxies + .lock() + .unwrap() + .insert(browser_pid, info); + } + + /// Insert a profile-to-proxy mapping directly (for testing). + #[cfg(test)] + fn insert_profile_proxy_mapping(&self, profile_id: String, proxy_id: String) { + self + .profile_active_proxy_ids + .lock() + .unwrap() + .insert(profile_id, proxy_id); + } + + /// Get active proxy info by browser PID (for testing). + #[cfg(test)] + fn get_active_proxy(&self, browser_pid: u32) -> Option { + self + .active_proxies + .lock() + .unwrap() + .get(&browser_pid) + .cloned() + } } // Create a singleton instance of the proxy manager @@ -2125,4 +2283,832 @@ mod tests { Ok(()) } + + // ────────────────────────────────────────────────────────────────────── + // Complex proxy process monitoring tests + // ────────────────────────────────────────────────────────────────────── + + fn make_proxy_info(id: &str, port: u16, profile_id: Option<&str>) -> ProxyInfo { + ProxyInfo { + id: id.to_string(), + local_url: format!("http://127.0.0.1:{port}"), + upstream_host: "10.0.0.1".to_string(), + upstream_port: 3128, + upstream_type: "http".to_string(), + local_port: port, + profile_id: profile_id.map(|s| s.to_string()), + } + } + + #[test] + fn test_pid_mapping_lifecycle() { + let pm = ProxyManager::new(); + + // Initially empty + assert_eq!(pm.active_proxy_count(), 0); + + // Register proxies for 3 browser PIDs + pm.insert_active_proxy(1001, make_proxy_info("px_a", 9001, Some("profile_1"))); + pm.insert_active_proxy(1002, make_proxy_info("px_b", 9002, Some("profile_2"))); + pm.insert_active_proxy(1003, make_proxy_info("px_c", 9003, None)); + + assert_eq!(pm.active_proxy_count(), 3); + + // Verify each PID resolves correctly + let a = pm.get_active_proxy(1001).unwrap(); + assert_eq!(a.id, "px_a"); + assert_eq!(a.local_port, 9001); + assert_eq!(a.profile_id.as_deref(), Some("profile_1")); + + let c = pm.get_active_proxy(1003).unwrap(); + assert!(c.profile_id.is_none()); + + // Unknown PID returns None + assert!(pm.get_active_proxy(9999).is_none()); + } + + #[test] + fn test_update_proxy_pid_remaps_correctly() { + let pm = ProxyManager::new(); + pm.insert_active_proxy(100, make_proxy_info("px_remap", 9010, Some("prof_a"))); + + // Old PID 100 → new PID 200 + pm.update_proxy_pid(100, 200).unwrap(); + + // Old PID should be gone + assert!(pm.get_active_proxy(100).is_none()); + + // New PID should have the same proxy info + let info = pm.get_active_proxy(200).unwrap(); + assert_eq!(info.id, "px_remap"); + assert_eq!(info.local_port, 9010); + assert_eq!(info.profile_id.as_deref(), Some("prof_a")); + } + + #[test] + fn test_update_proxy_pid_error_for_unknown_pid() { + let pm = ProxyManager::new(); + let result = pm.update_proxy_pid(777, 888); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("No proxy found for PID 777")); + } + + #[test] + fn test_profile_proxy_id_mapping_tracks_active_proxy() { + let pm = ProxyManager::new(); + + pm.insert_active_proxy(500, make_proxy_info("px_1", 9100, Some("profile_x"))); + pm.insert_profile_proxy_mapping("profile_x".to_string(), "px_1".to_string()); + + // Verify mapping exists + { + let map = pm.profile_active_proxy_ids.lock().unwrap(); + assert_eq!(map.get("profile_x").unwrap(), "px_1"); + } + + // Simulate profile-specific cleanup: remove the profile mapping + { + let mut map = pm.profile_active_proxy_ids.lock().unwrap(); + map.remove("profile_x"); + } + + assert_eq!(pm.profile_proxy_mapping_count(), 0); + // Active proxy itself should still be there + assert_eq!(pm.active_proxy_count(), 1); + } + + #[test] + fn test_tracked_proxy_ids_returns_all_unique_ids() { + let pm = ProxyManager::new(); + pm.insert_active_proxy(1, make_proxy_info("alpha", 8001, None)); + pm.insert_active_proxy(2, make_proxy_info("beta", 8002, None)); + pm.insert_active_proxy(3, make_proxy_info("gamma", 8003, None)); + + let ids = pm.tracked_proxy_ids(); + assert_eq!(ids.len(), 3); + assert!(ids.contains("alpha")); + assert!(ids.contains("beta")); + assert!(ids.contains("gamma")); + } + + #[tokio::test] + async fn test_concurrent_pid_registration_and_removal() { + use std::sync::Arc; + + let pm = Arc::new(ProxyManager::new()); + let mut handles = vec![]; + + // Phase 1: concurrent insertion of 50 proxies + for i in 0..50 { + let pm = pm.clone(); + handles.push(tokio::spawn(async move { + let pid = 2000 + i as u32; + let info = make_proxy_info(&format!("px_{i}"), 7000 + i as u16, None); + pm.insert_active_proxy(pid, info); + })); + } + for h in handles.drain(..) { + h.await.unwrap(); + } + assert_eq!(pm.active_proxy_count(), 50); + + // Phase 2: concurrent removal of half the proxies + for i in (0..50).step_by(2) { + let pm = pm.clone(); + handles.push(tokio::spawn(async move { + let pid = 2000 + i as u32; + let mut proxies = pm.active_proxies.lock().unwrap(); + proxies.remove(&pid); + })); + } + for h in handles.drain(..) { + h.await.unwrap(); + } + assert_eq!(pm.active_proxy_count(), 25); + + // Phase 3: remaining proxies should all have odd indices + let proxies = pm.active_proxies.lock().unwrap(); + for (&pid, info) in proxies.iter() { + let idx = (pid - 2000) as usize; + assert!(idx % 2 == 1, "Only odd-index proxies should remain"); + assert_eq!(info.id, format!("px_{idx}")); + } + } + + #[test] + fn test_process_running_detection_with_child_lifecycle() { + use crate::proxy_storage::is_process_running; + + // Spawn a long-lived child so we can check while it runs + let mut child = std::process::Command::new(if cfg!(windows) { "timeout" } else { "sleep" }) + .args(if cfg!(windows) { + vec!["/T", "10"] + } else { + vec!["10"] + }) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn sleep"); + + let pid = child.id(); + + // Process should be alive + assert!( + is_process_running(pid), + "Child process must be detected as running (PID {pid})" + ); + + // Kill it + child.kill().expect("kill child"); + child.wait().expect("wait child"); + + // Process should now be dead + assert!( + !is_process_running(pid), + "Killed child must be detected as dead (PID {pid})" + ); + } + + #[tokio::test] + async fn test_cleanup_distinguishes_live_and_dead_proxy_configs() { + use crate::proxy_storage::{save_proxy_config, ProxyConfig}; + + // Spawn a live child process to use its PID + let mut live_child = + std::process::Command::new(if cfg!(windows) { "timeout" } else { "sleep" }) + .args(if cfg!(windows) { + vec!["/T", "30"] + } else { + vec!["30"] + }) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn live child"); + let live_pid = live_child.id(); + + // Spawn and kill a short-lived process to get a dead PID + let dead_child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" }) + .args(if cfg!(windows) { + vec!["/C", "exit"] + } else { + vec![] + }) + .spawn() + .expect("spawn dead child"); + let dead_pid = dead_child.id(); + let mut dead_child = dead_child; + dead_child.wait().expect("wait for dead child"); + + // Use an old timestamp so the configs aren't in the grace period + let old_ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + - 300; // 5 minutes ago + + // Save both proxy configs to disk + let live_id = format!("proxy_{old_ts}_11111"); + let dead_id = format!("proxy_{old_ts}_22222"); + + let live_config = ProxyConfig { + id: live_id.clone(), + upstream_url: "DIRECT".to_string(), + local_port: Some(19001), + ignore_proxy_certificate: None, + local_url: Some("http://127.0.0.1:19001".to_string()), + pid: Some(live_pid), + profile_id: None, + bypass_rules: Vec::new(), + }; + let dead_config = ProxyConfig { + id: dead_id.clone(), + upstream_url: "DIRECT".to_string(), + local_port: Some(19002), + ignore_proxy_certificate: None, + local_url: Some("http://127.0.0.1:19002".to_string()), + pid: Some(dead_pid), + profile_id: None, + bypass_rules: Vec::new(), + }; + + save_proxy_config(&live_config).unwrap(); + save_proxy_config(&dead_config).unwrap(); + + // Verify is_process_running differentiates them + assert!( + crate::proxy_storage::is_process_running(live_pid), + "Live PID should be detected" + ); + assert!( + !crate::proxy_storage::is_process_running(dead_pid), + "Dead PID should not be detected" + ); + + // Clean up + live_child.kill().expect("kill live child"); + live_child.wait().expect("wait live child"); + crate::proxy_storage::delete_proxy_config(&live_id); + crate::proxy_storage::delete_proxy_config(&dead_id); + } + + #[test] + fn test_proxy_config_persistence_roundtrip() { + use crate::proxy_storage::{ + delete_proxy_config, generate_proxy_id, get_proxy_config, save_proxy_config, ProxyConfig, + }; + + let id = generate_proxy_id(); + let config = ProxyConfig { + id: id.clone(), + upstream_url: "socks5://user:pass@10.0.0.1:1080".to_string(), + local_port: Some(18080), + ignore_proxy_certificate: Some(true), + local_url: Some("http://127.0.0.1:18080".to_string()), + pid: Some(12345), + profile_id: Some("prof_abc".to_string()), + bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()], + }; + + // Save + save_proxy_config(&config).unwrap(); + + // Load and compare + let loaded = get_proxy_config(&id).expect("Config should be loadable"); + assert_eq!(loaded.id, config.id); + assert_eq!(loaded.upstream_url, config.upstream_url); + assert_eq!(loaded.local_port, config.local_port); + assert_eq!( + loaded.ignore_proxy_certificate, + config.ignore_proxy_certificate + ); + assert_eq!(loaded.local_url, config.local_url); + assert_eq!(loaded.pid, config.pid); + assert_eq!(loaded.profile_id, config.profile_id); + assert_eq!(loaded.bypass_rules, config.bypass_rules); + + // Clean up + assert!(delete_proxy_config(&id)); + assert!(get_proxy_config(&id).is_none()); + } + + #[test] + fn test_proxy_config_update_preserves_fields() { + use crate::proxy_storage::{ + delete_proxy_config, get_proxy_config, save_proxy_config, update_proxy_config, ProxyConfig, + }; + + let id = format!("proxy_test_update_{}", rand::random::()); + let mut config = ProxyConfig::new(id.clone(), "DIRECT".to_string(), Some(17777)); + config.pid = Some(99999); + config.profile_id = Some("prof_up".to_string()); + config.bypass_rules = vec!["google.com".to_string()]; + + save_proxy_config(&config).unwrap(); + + // Update: change the local_url (simulates worker binding) + config.local_url = Some("http://127.0.0.1:17777".to_string()); + assert!(update_proxy_config(&config)); + + let reloaded = get_proxy_config(&id).unwrap(); + assert_eq!( + reloaded.local_url.as_deref(), + Some("http://127.0.0.1:17777") + ); + // Other fields should be preserved + assert_eq!(reloaded.pid, Some(99999)); + assert_eq!(reloaded.bypass_rules, vec!["google.com".to_string()]); + + delete_proxy_config(&id); + } + + #[test] + fn test_proxy_config_list_filters_json_only() { + use crate::proxy_storage::{ + delete_proxy_config, list_proxy_configs, save_proxy_config, ProxyConfig, + }; + + let id1 = format!("proxy_list_test_{}", rand::random::()); + let id2 = format!("proxy_list_test_{}", rand::random::()); + + let c1 = ProxyConfig::new(id1.clone(), "DIRECT".to_string(), Some(16001)); + let c2 = ProxyConfig::new(id2.clone(), "DIRECT".to_string(), Some(16002)); + + save_proxy_config(&c1).unwrap(); + save_proxy_config(&c2).unwrap(); + + let all = list_proxy_configs(); + let our_ids: Vec<_> = all.iter().filter(|c| c.id == id1 || c.id == id2).collect(); + assert_eq!(our_ids.len(), 2, "Both test configs should be listed"); + + delete_proxy_config(&id1); + delete_proxy_config(&id2); + } + + #[test] + fn test_proxy_id_uniqueness_and_format() { + use crate::proxy_storage::generate_proxy_id; + + let mut ids = std::collections::HashSet::new(); + for _ in 0..100 { + let id = generate_proxy_id(); + assert!(id.starts_with("proxy_"), "ID must start with proxy_"); + // Format: proxy_{timestamp}_{random} + let parts: Vec<&str> = id.split('_').collect(); + assert_eq!( + parts.len(), + 3, + "ID should have exactly 3 underscore-separated parts" + ); + assert!( + parts[1].parse::().is_ok(), + "Second part must be a unix timestamp" + ); + assert!( + parts[2].parse::().is_ok(), + "Third part must be a u32 random" + ); + ids.insert(id); + } + assert_eq!(ids.len(), 100, "All 100 generated IDs must be unique"); + } + + #[test] + fn test_multiple_profiles_share_proxy_independently() { + let pm = ProxyManager::new(); + + // Two profiles sharing the same upstream but with distinct proxy instances + let info_a = ProxyInfo { + id: "px_shared_a".to_string(), + local_url: "http://127.0.0.1:9201".to_string(), + upstream_host: "proxy.shared.com".to_string(), + upstream_port: 8080, + upstream_type: "http".to_string(), + local_port: 9201, + profile_id: Some("profile_alpha".to_string()), + }; + let info_b = ProxyInfo { + id: "px_shared_b".to_string(), + local_url: "http://127.0.0.1:9202".to_string(), + upstream_host: "proxy.shared.com".to_string(), + upstream_port: 8080, + upstream_type: "http".to_string(), + local_port: 9202, + profile_id: Some("profile_beta".to_string()), + }; + + pm.insert_active_proxy(3001, info_a); + pm.insert_active_proxy(3002, info_b); + pm.insert_profile_proxy_mapping("profile_alpha".to_string(), "px_shared_a".to_string()); + pm.insert_profile_proxy_mapping("profile_beta".to_string(), "px_shared_b".to_string()); + + // Remove alpha's browser → should NOT affect beta + { + let mut proxies = pm.active_proxies.lock().unwrap(); + proxies.remove(&3001); + } + { + let mut map = pm.profile_active_proxy_ids.lock().unwrap(); + map.remove("profile_alpha"); + } + + assert_eq!(pm.active_proxy_count(), 1); + assert_eq!(pm.profile_proxy_mapping_count(), 1); + let remaining = pm.get_active_proxy(3002).unwrap(); + assert_eq!(remaining.id, "px_shared_b"); + assert_eq!(remaining.profile_id.as_deref(), Some("profile_beta")); + } + + #[test] + fn test_proxy_url_construction() { + // Basic HTTP + let url = ProxyManager::build_proxy_url(&ProxySettings { + proxy_type: "http".to_string(), + host: "1.2.3.4".to_string(), + port: 8080, + username: None, + password: None, + }); + assert_eq!(url, "http://1.2.3.4:8080"); + + // With credentials + let url = ProxyManager::build_proxy_url(&ProxySettings { + proxy_type: "socks5".to_string(), + host: "proxy.example.com".to_string(), + port: 1080, + username: Some("user".to_string()), + password: Some("p@ss".to_string()), + }); + assert_eq!(url, "socks5://user:p%40ss@proxy.example.com:1080"); + + // Username-only (no password) + let url = ProxyManager::build_proxy_url(&ProxySettings { + proxy_type: "http".to_string(), + host: "host.io".to_string(), + port: 3128, + username: Some("justuser".to_string()), + password: None, + }); + assert_eq!(url, "http://justuser@host.io:3128"); + } + + #[test] + fn test_geo_username_construction() { + // Country only + let u = ProxyManager::build_geo_username("base_user", "US", &None, &None, &None); + assert_eq!(u, "base_user-country-US"); + + // Country + region + let u = ProxyManager::build_geo_username( + "base_user", + "US", + &Some("california".to_string()), + &None, + &None, + ); + assert_eq!(u, "base_user-country-US-region-california"); + + // All fields + let u = ProxyManager::build_geo_username( + "user", + "DE", + &Some("bavaria".to_string()), + &Some("munich".to_string()), + &Some("Telekom".to_string()), + ); + assert_eq!(u, "user-country-DE-region-bavaria-city-munich-isp-Telekom"); + } + + #[test] + fn test_sid_generation_determinism_and_format() { + let sid1 = ProxyManager::generate_sid_for_profile("my-profile-uuid"); + let sid2 = ProxyManager::generate_sid_for_profile("my-profile-uuid"); + assert_eq!(sid1, sid2, "Same input must produce same SID"); + assert_eq!(sid1.len(), 11, "SID must be exactly 11 characters"); + + // All chars should be alphanumeric lowercase + assert!( + sid1 + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()), + "SID chars must be [a-z0-9]" + ); + + // Different profiles produce different SIDs + let sid3 = ProxyManager::generate_sid_for_profile("another-profile"); + assert_ne!(sid1, sid3, "Different profiles must produce different SIDs"); + } + + #[test] + fn test_build_username_with_sid() { + let full = ProxyManager::build_username_with_sid("user-country-US", "profile-123"); + // Should contain the geo base, then -sid-{11chars}-ttl-1440m + assert!(full.starts_with("user-country-US-sid-")); + assert!(full.ends_with("-ttl-1440m")); + // SID portion + let after_sid = full.strip_prefix("user-country-US-sid-").unwrap(); + let sid = after_sid.strip_suffix("-ttl-1440m").unwrap(); + assert_eq!(sid.len(), 11); + } + + #[test] + fn test_stored_proxy_geo_field_migration() { + // Simulate legacy data with geo_state but no geo_region + let mut proxy = StoredProxy { + id: "test_migrate".to_string(), + name: "Test".to_string(), + proxy_settings: ProxySettings { + proxy_type: "http".to_string(), + host: "h.com".to_string(), + port: 80, + username: None, + password: None, + }, + sync_enabled: false, + last_sync: None, + is_cloud_managed: false, + is_cloud_derived: false, + geo_country: Some("US".to_string()), + geo_state: Some("california".to_string()), + geo_region: None, + geo_city: None, + geo_isp: None, + }; + + // Before migration + assert_eq!(proxy.effective_region().unwrap(), "california"); + assert!(proxy.geo_region.is_none()); + + // After migration + proxy.migrate_geo_fields(); + assert_eq!(proxy.geo_region.as_deref(), Some("california")); + assert!(proxy.geo_state.is_none(), "geo_state should be taken"); + assert_eq!(proxy.effective_region().unwrap(), "california"); + } + + #[test] + fn test_cleanup_skips_recently_created_configs() { + use crate::proxy_storage::{delete_proxy_config, save_proxy_config, ProxyConfig}; + + // Use current timestamp so it falls within the 120s grace period + let now_ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let recent_id = format!("proxy_{now_ts}_99999"); + + // Spawn and kill a child so the PID is dead + let dead_child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" }) + .args(if cfg!(windows) { + vec!["/C", "exit"] + } else { + vec![] + }) + .spawn() + .unwrap(); + let dead_pid = dead_child.id(); + let mut dead_child = dead_child; + dead_child.wait().unwrap(); + + let config = ProxyConfig { + id: recent_id.clone(), + upstream_url: "DIRECT".to_string(), + local_port: Some(19999), + ignore_proxy_certificate: None, + local_url: None, + pid: Some(dead_pid), + profile_id: None, + bypass_rules: Vec::new(), + }; + save_proxy_config(&config).unwrap(); + + // The cleanup logic inspects the timestamp in the proxy ID. + // Since we used the current timestamp, the proxy_age will be < 120 seconds, + // so it should be skipped despite the dead PID. + + // Verify the grace period logic directly: + let proxy_age = recent_id + .strip_prefix("proxy_") + .and_then(|s| s.split('_').next()) + .and_then(|s| s.parse::().ok()) + .map(|created_at| now_ts.saturating_sub(created_at)) + .unwrap_or(0); + + assert!( + proxy_age < 120, + "Recently created config should be in grace period" + ); + + // Clean up test config + delete_proxy_config(&recent_id); + } + + #[tokio::test] + async fn test_concurrent_config_operations() { + use crate::proxy_storage::{ + delete_proxy_config, get_proxy_config, save_proxy_config, ProxyConfig, + }; + use std::sync::Arc; + + let ids: Vec = (0..20) + .map(|i| format!("proxy_conc_test_{}_{}", i, rand::random::())) + .collect(); + let ids = Arc::new(ids); + + // Concurrent writes + let mut handles = vec![]; + for id in ids.iter() { + let id = id.clone(); + handles.push(tokio::spawn(async move { + let config = ProxyConfig::new(id.clone(), "DIRECT".to_string(), Some(15000)); + save_proxy_config(&config).unwrap(); + })); + } + for h in handles { + h.await.unwrap(); + } + + // Verify all were written + for id in ids.iter() { + assert!( + get_proxy_config(id).is_some(), + "Config {id} should be readable after concurrent write" + ); + } + + // Concurrent deletes + let mut handles = vec![]; + for id in ids.iter() { + let id = id.clone(); + handles.push(tokio::spawn(async move { + delete_proxy_config(&id); + })); + } + for h in handles { + h.await.unwrap(); + } + + // Verify all deleted + for id in ids.iter() { + assert!( + get_proxy_config(id).is_none(), + "Config {id} should be gone after concurrent delete" + ); + } + } + + #[test] + fn test_proxy_txt_parsing_various_formats() { + // URL format + let results = ProxyManager::parse_txt_proxies("http://user:pass@proxy.com:8080\n"); + assert_eq!(results.len(), 1); + match &results[0] { + ProxyParseResult::Parsed(p) => { + assert_eq!(p.proxy_type, "http"); + assert_eq!(p.host, "proxy.com"); + assert_eq!(p.port, 8080); + assert_eq!(p.username.as_deref(), Some("user")); + assert_eq!(p.password.as_deref(), Some("pass")); + } + _ => panic!("Expected Parsed result"), + } + + // host:port format + let results = ProxyManager::parse_txt_proxies("10.0.0.1:3128\n"); + match &results[0] { + ProxyParseResult::Parsed(p) => { + assert_eq!(p.host, "10.0.0.1"); + assert_eq!(p.port, 3128); + assert!(p.username.is_none()); + } + _ => panic!("Expected Parsed"), + } + + // host:port:user:pass format + let results = ProxyManager::parse_txt_proxies("myhost:9090:admin:secret\n"); + match &results[0] { + ProxyParseResult::Parsed(p) => { + assert_eq!(p.host, "myhost"); + assert_eq!(p.port, 9090); + assert_eq!(p.username.as_deref(), Some("admin")); + assert_eq!(p.password.as_deref(), Some("secret")); + } + _ => panic!("Expected Parsed"), + } + + // Comments and empty lines should be skipped + let results = ProxyManager::parse_txt_proxies("# comment\n\n \n1.2.3.4:80\n"); + assert_eq!(results.len(), 1); + + // SOCKS5 URL + let results = ProxyManager::parse_txt_proxies("socks5://u:p@1.2.3.4:1080\n"); + match &results[0] { + ProxyParseResult::Parsed(p) => { + assert_eq!(p.proxy_type, "socks5"); + assert_eq!(p.host, "1.2.3.4"); + assert_eq!(p.port, 1080); + } + _ => panic!("Expected Parsed"), + } + + // Ambiguous: both positions could be ports + let results = ProxyManager::parse_txt_proxies("1234:5678:9012:3456\n"); + match &results[0] { + ProxyParseResult::Ambiguous { + possible_formats, .. + } => { + assert_eq!(possible_formats.len(), 2); + } + _ => panic!("Expected Ambiguous"), + } + + // Invalid + let results = ProxyManager::parse_txt_proxies("notaproxy\n"); + match &results[0] { + ProxyParseResult::Invalid { .. } => {} + _ => panic!("Expected Invalid"), + } + } + + #[test] + fn test_multiple_proxy_types_coexist() { + let pm = ProxyManager::new(); + + // Different proxy types for different profiles + let types = [ + ("http", 3128), + ("https", 3129), + ("socks4", 1080), + ("socks5", 1081), + ]; + + for (i, (ptype, port)) in types.iter().enumerate() { + let info = ProxyInfo { + id: format!("px_type_{ptype}"), + local_url: format!("http://127.0.0.1:{}", 9300 + i as u16), + upstream_host: "upstream.test".to_string(), + upstream_port: *port, + upstream_type: ptype.to_string(), + local_port: 9300 + i as u16, + profile_id: Some(format!("profile_{ptype}")), + }; + pm.insert_active_proxy(4000 + i as u32, info); + } + + assert_eq!(pm.active_proxy_count(), 4); + + // Verify each type is stored correctly + let info = pm.get_active_proxy(4000).unwrap(); + assert_eq!(info.upstream_type, "http"); + let info = pm.get_active_proxy(4003).unwrap(); + assert_eq!(info.upstream_type, "socks5"); + assert_eq!(info.upstream_port, 1081); + } + + #[test] + fn test_overwrite_pid_mapping() { + let pm = ProxyManager::new(); + + // Register proxy for PID 5000 + pm.insert_active_proxy(5000, make_proxy_info("px_old", 9400, Some("prof_ow"))); + + // Overwrite the same PID with a new proxy (simulates browser reconnect with different proxy) + pm.insert_active_proxy(5000, make_proxy_info("px_new", 9401, Some("prof_ow"))); + + // Should only have 1 entry, with the new proxy + assert_eq!(pm.active_proxy_count(), 1); + let info = pm.get_active_proxy(5000).unwrap(); + assert_eq!(info.id, "px_new"); + assert_eq!(info.local_port, 9401); + } + + #[test] + fn test_proxy_config_with_bypass_rules_roundtrip() { + use crate::proxy_storage::{ + delete_proxy_config, get_proxy_config, save_proxy_config, ProxyConfig, + }; + + let id = format!("proxy_bypass_test_{}", rand::random::()); + let rules = vec![ + "*.google.com".to_string(), + "localhost".to_string(), + "192.168.0.*".to_string(), + "^.*\\.internal\\.corp$".to_string(), + ]; + + let config = ProxyConfig::new(id.clone(), "http://upstream:3128".to_string(), Some(18888)) + .with_profile_id(Some("prof_bypass".to_string())) + .with_bypass_rules(rules.clone()); + + save_proxy_config(&config).unwrap(); + + let loaded = get_proxy_config(&id).unwrap(); + assert_eq!(loaded.bypass_rules.len(), 4); + assert_eq!(loaded.bypass_rules, rules); + assert_eq!(loaded.profile_id.as_deref(), Some("prof_bypass")); + + delete_proxy_config(&id); + } } diff --git a/src-tauri/src/proxy_storage.rs b/src-tauri/src/proxy_storage.rs index a2b26a7..f85a8b4 100644 --- a/src-tauri/src/proxy_storage.rs +++ b/src-tauri/src/proxy_storage.rs @@ -42,7 +42,7 @@ impl ProxyConfig { } pub fn get_storage_dir() -> PathBuf { - crate::app_dirs::proxies_dir() + crate::app_dirs::proxy_workers_dir() } pub fn save_proxy_config(config: &ProxyConfig) -> Result<(), Box> { @@ -137,3 +137,52 @@ pub fn is_process_running(pid: u32) -> bool { ); system.process(sysinfo::Pid::from_u32(pid)).is_some() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_process_running_detects_current_process() { + let pid = std::process::id(); + assert!( + is_process_running(pid), + "is_process_running must detect the current process (PID {pid})" + ); + } + + #[test] + fn test_is_process_running_returns_false_for_dead_pid() { + // Spawn a short-lived child and wait for it to exit + let child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" }) + .args(if cfg!(windows) { + vec!["/C", "exit"] + } else { + vec![] + }) + .spawn() + .expect("failed to spawn child"); + let pid = child.id(); + let mut child = child; + child.wait().expect("child failed"); + + assert!( + !is_process_running(pid), + "is_process_running must return false for a dead process (PID {pid})" + ); + } + + #[test] + fn test_is_process_running_returns_false_for_nonexistent_pid() { + // PID 0 is not a valid user process on any supported platform + assert!( + !is_process_running(0), + "is_process_running must return false for PID 0" + ); + // Very high PID unlikely to exist + assert!( + !is_process_running(u32::MAX), + "is_process_running must return false for PID u32::MAX" + ); + } +} diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index 9eef72f..78e6c45 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -55,6 +55,8 @@ pub struct AppSettings { pub language: Option, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default #[serde(default)] pub window_resize_warning_dismissed: bool, + #[serde(default)] + pub disable_auto_updates: bool, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] @@ -89,6 +91,7 @@ impl Default for AppSettings { launch_on_login_declined: false, language: None, window_resize_warning_dismissed: false, + disable_auto_updates: false, } } } @@ -1020,6 +1023,7 @@ mod tests { launch_on_login_declined: false, language: None, window_resize_warning_dismissed: false, + disable_auto_updates: false, }; let save_result = manager.save_settings(&test_settings); diff --git a/src-tauri/src/sync/client.rs b/src-tauri/src/sync/client.rs index 2f6c111..bf330c6 100644 --- a/src-tauri/src/sync/client.rs +++ b/src-tauri/src/sync/client.rs @@ -210,63 +210,84 @@ impl SyncClient { &self, items: Vec<(String, Option)>, ) -> SyncResult { - let request = PresignUploadBatchRequest { - items: items - .into_iter() - .map(|(key, content_type)| PresignUploadBatchItem { key, content_type }) - .collect(), - expires_in: Some(3600), - }; + let chunk_size = 500; + let mut all_items = Vec::new(); - let response = self - .client - .post(self.url("presign-upload-batch")) - .header("Authorization", format!("Bearer {}", self.token)) - .json(&request) - .send() - .await - .map_err(|e| SyncError::NetworkError(e.to_string()))?; + for chunk in items.chunks(chunk_size) { + let request = PresignUploadBatchRequest { + items: chunk + .iter() + .map(|(key, content_type)| PresignUploadBatchItem { + key: key.clone(), + content_type: content_type.clone(), + }) + .collect(), + expires_in: Some(3600), + }; - if response.status().is_client_error() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(SyncError::AuthError(format!("({status}) {body}"))); + let response = self + .client + .post(self.url("presign-upload-batch")) + .header("Authorization", format!("Bearer {}", self.token)) + .json(&request) + .send() + .await + .map_err(|e| SyncError::NetworkError(e.to_string()))?; + + if response.status().is_client_error() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(SyncError::AuthError(format!("({status}) {body}"))); + } + + let batch_response: PresignUploadBatchResponse = response + .json() + .await + .map_err(|e| SyncError::SerializationError(e.to_string()))?; + + all_items.extend(batch_response.items); } - response - .json() - .await - .map_err(|e| SyncError::SerializationError(e.to_string())) + Ok(PresignUploadBatchResponse { items: all_items }) } pub async fn presign_download_batch( &self, keys: Vec, ) -> SyncResult { - let request = PresignDownloadBatchRequest { - keys, - expires_in: Some(3600), - }; + let chunk_size = 500; + let mut all_items = Vec::new(); - let response = self - .client - .post(self.url("presign-download-batch")) - .header("Authorization", format!("Bearer {}", self.token)) - .json(&request) - .send() - .await - .map_err(|e| SyncError::NetworkError(e.to_string()))?; + for chunk in keys.chunks(chunk_size) { + let request = PresignDownloadBatchRequest { + keys: chunk.to_vec(), + expires_in: Some(3600), + }; - if response.status().is_client_error() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(SyncError::AuthError(format!("({status}) {body}"))); + let response = self + .client + .post(self.url("presign-download-batch")) + .header("Authorization", format!("Bearer {}", self.token)) + .json(&request) + .send() + .await + .map_err(|e| SyncError::NetworkError(e.to_string()))?; + + if response.status().is_client_error() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(SyncError::AuthError(format!("({status}) {body}"))); + } + + let batch_response: PresignDownloadBatchResponse = response + .json() + .await + .map_err(|e| SyncError::SerializationError(e.to_string()))?; + + all_items.extend(batch_response.items); } - response - .json() - .await - .map_err(|e| SyncError::SerializationError(e.to_string())) + Ok(PresignDownloadBatchResponse { items: all_items }) } pub async fn delete_prefix( diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index c94ac52..286fad0 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -7,11 +7,188 @@ use crate::profile::types::{BrowserProfile, SyncMode}; use crate::profile::ProfileManager; use crate::settings_manager::SettingsManager; use chrono::{DateTime, Utc}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use tokio::sync::Semaphore; +use std::time::Instant; +use tokio::sync::{Mutex as TokioMutex, Semaphore}; + +/// Upload/download concurrency limit +const SYNC_CONCURRENCY: usize = 32; + +/// Max retries for individual file uploads/downloads +const MAX_FILE_RETRIES: u32 = 3; + +/// Critical file patterns — if any of these fail to upload/download, the sync is aborted. +const CRITICAL_FILE_PATTERNS: &[&str] = &[ + "Cookies", + "Login Data", + "Local Storage", + "Local State", + "Preferences", + "Secure Preferences", + "Web Data", + "Extension Cookies", + // Firefox/Camoufox equivalents + "cookies.sqlite", + "key4.db", + "logins.json", + "cert9.db", + "places.sqlite", + "formhistory.sqlite", + "permissions.sqlite", + "prefs.js", + "storage.sqlite", +]; + +fn is_critical_file(path: &str) -> bool { + CRITICAL_FILE_PATTERNS + .iter() + .any(|pattern| path.contains(pattern)) +} + +/// Resume state persisted to disk so interrupted syncs can continue +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct SyncResumeState { + profile_id: String, + direction: String, + started_at: String, + completed_files: HashSet, +} + +impl SyncResumeState { + fn path(profile_dir: &Path) -> std::path::PathBuf { + profile_dir.join(".donut-sync").join("resume-state.json") + } + + fn load(profile_dir: &Path) -> Option { + let path = Self::path(profile_dir); + let content = fs::read_to_string(&path).ok()?; + let state: Self = serde_json::from_str(&content).ok()?; + // Discard if older than 12 hours (presigned URLs expire in 1h but files may still be there) + if let Ok(started) = DateTime::parse_from_rfc3339(&state.started_at) { + let age = Utc::now() - started.with_timezone(&Utc); + if age.num_hours() > 12 { + let _ = fs::remove_file(&path); + return None; + } + } + Some(state) + } + + fn save(&self, profile_dir: &Path) -> SyncResult<()> { + let path = Self::path(profile_dir); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| SyncError::IoError(format!("Failed to create resume state dir: {e}")))?; + } + let json = serde_json::to_string(self).map_err(|e| { + SyncError::SerializationError(format!("Failed to serialize resume state: {e}")) + })?; + fs::write(&path, json) + .map_err(|e| SyncError::IoError(format!("Failed to write resume state: {e}")))?; + Ok(()) + } + + fn delete(profile_dir: &Path) { + let path = Self::path(profile_dir); + let _ = fs::remove_file(&path); + } +} + +/// Tracks live sync progress and emits throttled events to the frontend +struct SyncProgressTracker { + profile_id: String, + profile_name: String, + phase: String, + total_files: u64, + total_bytes: u64, + completed_files: AtomicU64, + completed_bytes: AtomicU64, + failed_count: AtomicU64, + start_time: Instant, + last_emit: TokioMutex, +} + +impl SyncProgressTracker { + fn new( + profile_id: String, + profile_name: String, + phase: &str, + total_files: u64, + total_bytes: u64, + ) -> Self { + Self { + profile_id, + profile_name, + phase: phase.to_string(), + total_files, + total_bytes, + completed_files: AtomicU64::new(0), + completed_bytes: AtomicU64::new(0), + failed_count: AtomicU64::new(0), + start_time: Instant::now(), + last_emit: TokioMutex::new(Instant::now() - std::time::Duration::from_secs(1)), + } + } + + fn record_success(&self, bytes: u64) { + self.completed_files.fetch_add(1, Ordering::Relaxed); + self.completed_bytes.fetch_add(bytes, Ordering::Relaxed); + self.maybe_emit(); + } + + fn record_failure(&self) { + self.completed_files.fetch_add(1, Ordering::Relaxed); + self.failed_count.fetch_add(1, Ordering::Relaxed); + self.maybe_emit(); + } + + fn maybe_emit(&self) { + let Ok(mut last) = self.last_emit.try_lock() else { + return; + }; + if last.elapsed().as_millis() < 250 { + return; + } + *last = Instant::now(); + self.emit_progress(); + } + + fn emit_final(&self) { + self.emit_progress(); + } + + fn emit_progress(&self) { + let completed_bytes = self.completed_bytes.load(Ordering::Relaxed); + let elapsed = self.start_time.elapsed().as_secs_f64().max(0.1); + let speed = (completed_bytes as f64 / elapsed) as u64; + let remaining_bytes = self.total_bytes.saturating_sub(completed_bytes); + let eta = if speed > 0 { + remaining_bytes / speed + } else { + 0 + }; + + let _ = events::emit( + "profile-sync-progress", + serde_json::json!({ + "profile_id": self.profile_id, + "profile_name": self.profile_name, + "phase": self.phase, + "completed_files": self.completed_files.load(Ordering::Relaxed), + "total_files": self.total_files, + "completed_bytes": completed_bytes, + "total_bytes": self.total_bytes, + "speed_bytes_per_sec": speed, + "eta_seconds": eta, + "failed_count": self.failed_count.load(Ordering::Relaxed), + }), + ); + } +} /// Check if sync is configured (cloud or self-hosted) pub fn is_sync_configured() -> bool { @@ -108,6 +285,29 @@ impl SyncEngine { return Ok(()); } + // Skip if profile is currently running locally + if profile.process_id.is_some() { + log::info!( + "Skipping sync for running profile: {} ({})", + profile.name, + profile.id + ); + return Ok(()); + } + + // Skip if profile is locked by another team member + if crate::team_lock::TEAM_LOCK + .is_locked_by_another(&profile.id.to_string()) + .await + { + log::info!( + "Skipping sync for profile locked by another team member: {} ({})", + profile.name, + profile.id + ); + return Ok(()); + } + // Derive encryption key if encrypted sync let encryption_key = if profile.is_encrypted_sync() { let password = encryption::load_e2e_password() @@ -149,6 +349,7 @@ impl SyncEngine { "profile-sync-status", serde_json::json!({ "profile_id": profile_id, + "profile_name": profile.name, "status": "syncing" }), ); @@ -202,6 +403,7 @@ impl SyncEngine { "profile-sync-status", serde_json::json!({ "profile_id": profile_id, + "profile_name": profile.name, "status": "synced" }), ); @@ -228,6 +430,7 @@ impl SyncEngine { "profile-sync-progress", serde_json::json!({ "profile_id": profile_id, + "profile_name": profile.name, "phase": "started", "total_files": total_files, "total_bytes": upload_bytes + download_bytes @@ -240,6 +443,7 @@ impl SyncEngine { .upload_profile_files( app_handle, &profile_id, + &profile.name, &profile_dir, &diff.files_to_upload, encryption_key.as_ref(), @@ -254,6 +458,7 @@ impl SyncEngine { .download_profile_files( app_handle, &profile_id, + &profile.name, &profile_dir, &diff.files_to_download, encryption_key.as_ref(), @@ -290,6 +495,9 @@ impl SyncEngine { .upload_manifest(&profile_id, &final_manifest, &key_prefix) .await?; + // Sync completed successfully — clean up resume state + SyncResumeState::delete(&profile_dir); + // Sync associated proxy, group, and VPN if let Some(proxy_id) = &profile.proxy_id { let _ = self.sync_proxy(proxy_id, Some(app_handle)).await; @@ -316,6 +524,7 @@ impl SyncEngine { "profile-sync-status", serde_json::json!({ "profile_id": profile_id, + "profile_name": profile.name, "status": "synced" }), ); @@ -389,10 +598,12 @@ impl SyncEngine { Ok(()) } + #[allow(clippy::too_many_arguments)] async fn upload_profile_files( &self, _app_handle: &tauri::AppHandle, profile_id: &str, + profile_name: &str, profile_dir: &Path, files: &[super::manifest::ManifestFileEntry], encryption_key: Option<&[u8; 32]>, @@ -402,10 +613,53 @@ impl SyncEngine { return Ok(()); } - log::info!("Uploading {} files for profile {}", files.len(), profile_id); + // Load resume state to skip already-uploaded files + let mut resume_state = SyncResumeState::load(profile_dir) + .filter(|s| s.profile_id == profile_id && s.direction == "upload"); + + let already_done: HashSet = resume_state + .as_ref() + .map(|s| s.completed_files.clone()) + .unwrap_or_default(); + + let files_to_process: Vec<_> = files + .iter() + .filter(|f| !already_done.contains(&f.path)) + .collect(); + let skipped = files.len() - files_to_process.len(); + + if skipped > 0 { + log::info!( + "Resume: skipping {} already-uploaded files, processing {} remaining for profile {}", + skipped, + files_to_process.len(), + profile_id + ); + } + + log::info!( + "Uploading {} files for profile {}", + files_to_process.len(), + profile_id + ); + + if files_to_process.is_empty() { + return Ok(()); + } + + // Initialize resume state if not resuming + if resume_state.is_none() { + resume_state = Some(SyncResumeState { + profile_id: profile_id.to_string(), + direction: "upload".to_string(), + started_at: Utc::now().to_rfc3339(), + completed_files: HashSet::new(), + }); + } + let resume_state = Arc::new(TokioMutex::new(resume_state.unwrap())); // Get batch presigned URLs - let items: Vec<(String, Option)> = files + let items: Vec<(String, Option)> = files_to_process .iter() .map(|f| { let key = format!("{}profiles/{}/files/{}", key_prefix, profile_id, f.path); @@ -425,28 +679,70 @@ impl SyncEngine { .map(|item| (item.key, item.url)) .collect(); - // Upload with bounded concurrency - let semaphore = Arc::new(Semaphore::new(8)); + let total_bytes: u64 = files.iter().map(|f| f.size).sum(); + let already_bytes: u64 = files + .iter() + .filter(|f| already_done.contains(&f.path)) + .map(|f| f.size) + .sum(); + + let tracker = Arc::new(SyncProgressTracker::new( + profile_id.to_string(), + profile_name.to_string(), + "uploading", + files.len() as u64, + total_bytes, + )); + // Pre-populate tracker with resumed progress + tracker + .completed_files + .store(skipped as u64, Ordering::Relaxed); + tracker + .completed_bytes + .store(already_bytes, Ordering::Relaxed); + tracker.emit_final(); + + let semaphore = Arc::new(Semaphore::new(SYNC_CONCURRENCY)); let client = self.client.clone(); let profile_dir = profile_dir.to_path_buf(); - let profile_id = profile_id.to_string(); + let profile_id_owned = profile_id.to_string(); let enc_key = encryption_key.copied(); - let mut handles = Vec::new(); + type FileResult = Result; + let mut handles: Vec> = Vec::new(); - for file in files { + // Counter for batching resume state saves + let save_counter = Arc::new(AtomicU64::new(0)); + + for file in &files_to_process { let sem = semaphore.clone(); let file_path = profile_dir.join(&file.path); - let remote_key = format!("{}profiles/{}/files/{}", key_prefix, profile_id, file.path); + let relative_path = file.path.clone(); + let file_size = file.size; + let remote_key = format!( + "{}profiles/{}/files/{}", + key_prefix, profile_id_owned, file.path + ); let url = url_map.get(&remote_key).cloned(); + let critical = is_critical_file(&file.path); if url.is_none() { log::warn!("No presigned URL for {}", remote_key); + if critical { + return Err(SyncError::NetworkError(format!( + "No presigned URL for critical file: {}", + file.path + ))); + } continue; } let url = url.unwrap(); let client = client.clone(); + let tracker = tracker.clone(); + let resume_state = resume_state.clone(); + let save_counter = save_counter.clone(); + let profile_dir_clone = profile_dir.clone(); let content_type = mime_guess::from_path(&file.path) .first() .map(|m| m.to_string()); @@ -456,9 +752,16 @@ impl SyncEngine { let data = match fs::read(&file_path) { Ok(d) => d, + Err(e) if e.kind() == std::io::ErrorKind::NotFound && !critical => { + log::debug!("File disappeared, skipping: {}", file_path.display()); + tracker.record_success(0); + return Ok(relative_path); + } Err(e) => { - log::warn!("Failed to read {}: {}", file_path.display(), e); - return; + let msg = format!("Failed to read {}: {}", file_path.display(), e); + log::warn!("{}", msg); + tracker.record_failure(); + return Err((relative_path, msg, critical)); } }; @@ -466,44 +769,113 @@ impl SyncEngine { match encryption::encrypt_bytes(key, &data) { Ok(encrypted) => encrypted, Err(e) => { - log::warn!("Failed to encrypt {}: {}", file_path.display(), e); - return; + let msg = format!("Failed to encrypt {}: {}", file_path.display(), e); + log::warn!("{}", msg); + tracker.record_failure(); + return Err((relative_path, msg, critical)); } } } else { data }; - if let Err(e) = client - .upload_bytes(&url, &upload_data, content_type.as_deref()) - .await - { - log::warn!("Failed to upload {}: {}", file_path.display(), e); + // Retry loop for network uploads + let mut last_err = String::new(); + for attempt in 0..MAX_FILE_RETRIES { + match client + .upload_bytes(&url, &upload_data, content_type.as_deref()) + .await + { + Ok(()) => { + tracker.record_success(file_size); + + // Record in resume state, save periodically + { + let mut state = resume_state.lock().await; + state.completed_files.insert(relative_path.clone()); + let count = save_counter.fetch_add(1, Ordering::Relaxed); + if count.is_multiple_of(50) { + let _ = state.save(&profile_dir_clone); + } + } + + return Ok(relative_path); + } + Err(e) => { + last_err = format!("{}", e); + if attempt < MAX_FILE_RETRIES - 1 { + log::debug!( + "Retry {}/{} for {}: {}", + attempt + 1, + MAX_FILE_RETRIES, + relative_path, + last_err + ); + tokio::time::sleep(std::time::Duration::from_millis(500 * (attempt as u64 + 1))) + .await; + } + } + } } + + let msg = format!( + "Failed to upload {} after {} retries: {}", + relative_path, MAX_FILE_RETRIES, last_err + ); + log::warn!("{}", msg); + tracker.record_failure(); + Err((relative_path, msg, critical)) })); } + // Collect results + let mut critical_failures = Vec::new(); + let mut non_critical_failures = Vec::new(); + for handle in handles { - let _ = handle.await; + match handle.await { + Ok(Ok(_)) => {} + Ok(Err((path, msg, true))) => critical_failures.push((path, msg)), + Ok(Err((path, msg, false))) => non_critical_failures.push((path, msg)), + Err(e) => { + log::warn!("Upload task panicked: {}", e); + } + } } - let _ = events::emit( - "profile-sync-progress", - serde_json::json!({ - "profile_id": profile_id, - "phase": "upload", - "done": files.len(), - "total": files.len() - }), - ); + // Final resume state save + { + let state = resume_state.lock().await; + let _ = state.save(&profile_dir); + } + + tracker.emit_final(); + + if !non_critical_failures.is_empty() { + log::warn!( + "Upload completed with {} non-critical failures for profile {}", + non_critical_failures.len(), + profile_id_owned + ); + } + + if !critical_failures.is_empty() { + let file_list: Vec<&str> = critical_failures.iter().map(|(p, _)| p.as_str()).collect(); + return Err(SyncError::IoError(format!( + "Critical files failed to upload: {}. Sync aborted to prevent data loss.", + file_list.join(", ") + ))); + } Ok(()) } + #[allow(clippy::too_many_arguments)] async fn download_profile_files( &self, _app_handle: &tauri::AppHandle, profile_id: &str, + profile_name: &str, profile_dir: &Path, files: &[super::manifest::ManifestFileEntry], encryption_key: Option<&[u8; 32]>, @@ -513,14 +885,53 @@ impl SyncEngine { return Ok(()); } + // Load resume state to skip already-downloaded files + let mut resume_state = SyncResumeState::load(profile_dir) + .filter(|s| s.profile_id == profile_id && s.direction == "download"); + + let already_done: HashSet = resume_state + .as_ref() + .map(|s| s.completed_files.clone()) + .unwrap_or_default(); + + let files_to_process: Vec<_> = files + .iter() + .filter(|f| !already_done.contains(&f.path)) + .collect(); + let skipped = files.len() - files_to_process.len(); + + if skipped > 0 { + log::info!( + "Resume: skipping {} already-downloaded files, processing {} remaining for profile {}", + skipped, + files_to_process.len(), + profile_id + ); + } + log::info!( "Downloading {} files for profile {}", - files.len(), + files_to_process.len(), profile_id ); + if files_to_process.is_empty() { + return Ok(()); + } + + // Initialize resume state if not resuming + if resume_state.is_none() { + resume_state = Some(SyncResumeState { + profile_id: profile_id.to_string(), + direction: "download".to_string(), + started_at: Utc::now().to_rfc3339(), + completed_files: HashSet::new(), + }); + } + let resume_state = Arc::new(TokioMutex::new(resume_state.unwrap())); + // Get batch presigned URLs - let keys: Vec = files + let keys: Vec = files_to_process .iter() .map(|f| format!("{}profiles/{}/files/{}", key_prefix, profile_id, f.path)) .collect(); @@ -534,73 +945,178 @@ impl SyncEngine { .map(|item| (item.key, item.url)) .collect(); - // Download with bounded concurrency - let semaphore = Arc::new(Semaphore::new(8)); + let total_bytes: u64 = files.iter().map(|f| f.size).sum(); + let already_bytes: u64 = files + .iter() + .filter(|f| already_done.contains(&f.path)) + .map(|f| f.size) + .sum(); + + let tracker = Arc::new(SyncProgressTracker::new( + profile_id.to_string(), + profile_name.to_string(), + "downloading", + files.len() as u64, + total_bytes, + )); + tracker + .completed_files + .store(skipped as u64, Ordering::Relaxed); + tracker + .completed_bytes + .store(already_bytes, Ordering::Relaxed); + tracker.emit_final(); + + let semaphore = Arc::new(Semaphore::new(SYNC_CONCURRENCY)); let client = self.client.clone(); let profile_dir = profile_dir.to_path_buf(); - let profile_id = profile_id.to_string(); + let profile_id_owned = profile_id.to_string(); let enc_key = encryption_key.copied(); - let mut handles = Vec::new(); + type FileResult = Result; + let mut handles: Vec> = Vec::new(); - for file in files { + let save_counter = Arc::new(AtomicU64::new(0)); + + for file in &files_to_process { let sem = semaphore.clone(); let file_path = profile_dir.join(&file.path); - let remote_key = format!("{}profiles/{}/files/{}", key_prefix, profile_id, file.path); + let relative_path = file.path.clone(); + let file_size = file.size; + let remote_key = format!( + "{}profiles/{}/files/{}", + key_prefix, profile_id_owned, file.path + ); let url = url_map.get(&remote_key).cloned(); + let critical = is_critical_file(&file.path); if url.is_none() { log::warn!("No presigned URL for {}", remote_key); + if critical { + return Err(SyncError::NetworkError(format!( + "No presigned URL for critical file: {}", + file.path + ))); + } continue; } let url = url.unwrap(); let client = client.clone(); + let tracker = tracker.clone(); + let resume_state = resume_state.clone(); + let save_counter = save_counter.clone(); + let profile_dir_clone = profile_dir.clone(); handles.push(tokio::spawn(async move { let _permit = sem.acquire().await.unwrap(); - match client.download_bytes(&url).await { - Ok(data) => { - let write_data = if let Some(ref key) = enc_key { - match encryption::decrypt_bytes(key, &data) { - Ok(decrypted) => decrypted, - Err(e) => { - log::warn!("Failed to decrypt {}, skipping: {}", remote_key, e); - return; + // Retry loop for network downloads + let mut last_err = String::new(); + for attempt in 0..MAX_FILE_RETRIES { + match client.download_bytes(&url).await { + Ok(data) => { + let write_data = if let Some(ref key) = enc_key { + match encryption::decrypt_bytes(key, &data) { + Ok(decrypted) => decrypted, + Err(e) => { + let msg = format!("Failed to decrypt {}: {}", relative_path, e); + log::warn!("{}", msg); + tracker.record_failure(); + return Err((relative_path, msg, critical)); + } + } + } else { + data + }; + + if let Some(parent) = file_path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Err(e) = fs::write(&file_path, &write_data) { + let msg = format!("Failed to write {}: {}", file_path.display(), e); + log::warn!("{}", msg); + tracker.record_failure(); + return Err((relative_path, msg, critical)); + } + + tracker.record_success(file_size); + + { + let mut state = resume_state.lock().await; + state.completed_files.insert(relative_path.clone()); + let count = save_counter.fetch_add(1, Ordering::Relaxed); + if count.is_multiple_of(50) { + let _ = state.save(&profile_dir_clone); } } - } else { - data - }; - if let Some(parent) = file_path.parent() { - let _ = fs::create_dir_all(parent); + return Ok(relative_path); } - if let Err(e) = fs::write(&file_path, &write_data) { - log::warn!("Failed to write {}: {}", file_path.display(), e); + Err(e) => { + last_err = format!("{}", e); + if attempt < MAX_FILE_RETRIES - 1 { + log::debug!( + "Retry {}/{} for {}: {}", + attempt + 1, + MAX_FILE_RETRIES, + relative_path, + last_err + ); + tokio::time::sleep(std::time::Duration::from_millis(500 * (attempt as u64 + 1))) + .await; + } } } - Err(e) => { - log::warn!("Failed to download {}: {}", remote_key, e); - } } + + let msg = format!( + "Failed to download {} after {} retries: {}", + relative_path, MAX_FILE_RETRIES, last_err + ); + log::warn!("{}", msg); + tracker.record_failure(); + Err((relative_path, msg, critical)) })); } + let mut critical_failures = Vec::new(); + let mut non_critical_failures = Vec::new(); + for handle in handles { - let _ = handle.await; + match handle.await { + Ok(Ok(_)) => {} + Ok(Err((path, msg, true))) => critical_failures.push((path, msg)), + Ok(Err((path, msg, false))) => non_critical_failures.push((path, msg)), + Err(e) => { + log::warn!("Download task panicked: {}", e); + } + } } - let _ = events::emit( - "profile-sync-progress", - serde_json::json!({ - "profile_id": profile_id, - "phase": "download", - "done": files.len(), - "total": files.len() - }), - ); + // Final resume state save + { + let state = resume_state.lock().await; + let _ = state.save(&profile_dir); + } + + tracker.emit_final(); + + if !non_critical_failures.is_empty() { + log::warn!( + "Download completed with {} non-critical failures for profile {}", + non_critical_failures.len(), + profile_id_owned + ); + } + + if !critical_failures.is_empty() { + let file_list: Vec<&str> = critical_failures.iter().map(|(p, _)| p.as_str()).collect(); + return Err(SyncError::IoError(format!( + "Critical files failed to download: {}. Sync aborted to prevent data loss.", + file_list.join(", ") + ))); + } Ok(()) } @@ -1531,6 +2047,7 @@ impl SyncEngine { "profile-sync-status", serde_json::json!({ "profile_id": profile_id, + "profile_name": profile.name, "status": "synced" }), ); @@ -1599,6 +2116,7 @@ impl SyncEngine { .download_profile_files( app_handle, profile_id, + &profile.name, &profile_dir, &manifest.files, encryption_key.as_ref(), @@ -1631,6 +2149,7 @@ impl SyncEngine { "profile-sync-status", serde_json::json!({ "profile_id": profile_id, + "profile_name": profile.name, "status": "synced" }), ); @@ -2063,6 +2582,7 @@ pub async fn set_profile_sync_mode( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, + "profile_name": profile.name, "status": "error", "error": "Sync server not configured. Please configure sync settings first." }), @@ -2078,6 +2598,7 @@ pub async fn set_profile_sync_mode( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, + "profile_name": profile.name, "status": "error", "error": "Sync token not configured. Please configure sync settings first." }), @@ -2135,6 +2656,7 @@ pub async fn set_profile_sync_mode( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, + "profile_name": profile.name, "status": if is_running { "waiting" } else { "syncing" } }), ); @@ -2197,6 +2719,7 @@ pub async fn set_profile_sync_mode( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, + "profile_name": profile.name, "status": "disabled" }), ); @@ -2250,6 +2773,7 @@ pub async fn request_profile_sync( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, + "profile_name": profile.name, "status": if is_running { "waiting" } else { "syncing" } }), ); @@ -2624,7 +3148,9 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies(); for proxy in &proxies { if !proxy.sync_enabled && !proxy.is_cloud_managed { - set_proxy_sync_enabled(app_handle.clone(), proxy.id.clone(), true).await?; + if let Err(e) = set_proxy_sync_enabled(app_handle.clone(), proxy.id.clone(), true).await { + log::warn!("Failed to enable sync for proxy {}: {e}", proxy.id); + } } } } @@ -2638,7 +3164,9 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul }; for group in &groups { if !group.sync_enabled { - set_group_sync_enabled(app_handle.clone(), group.id.clone(), true).await?; + if let Err(e) = set_group_sync_enabled(app_handle.clone(), group.id.clone(), true).await { + log::warn!("Failed to enable sync for group {}: {e}", group.id); + } } } } @@ -2653,7 +3181,9 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul }; for config in &configs { if !config.sync_enabled { - set_vpn_sync_enabled(app_handle.clone(), config.id.clone(), true).await?; + if let Err(e) = set_vpn_sync_enabled(app_handle.clone(), config.id.clone(), true).await { + log::warn!("Failed to enable sync for VPN {}: {e}", config.id); + } } } } @@ -2667,7 +3197,9 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul }; for ext in &exts { if !ext.sync_enabled { - set_extension_sync_enabled(app_handle.clone(), ext.id.clone(), true).await?; + if let Err(e) = set_extension_sync_enabled(app_handle.clone(), ext.id.clone(), true).await { + log::warn!("Failed to enable sync for extension {}: {e}", ext.id); + } } } } @@ -2681,7 +3213,14 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul }; for group in &groups { if !group.sync_enabled { - set_extension_group_sync_enabled(app_handle.clone(), group.id.clone(), true).await?; + if let Err(e) = + set_extension_group_sync_enabled(app_handle.clone(), group.id.clone(), true).await + { + log::warn!( + "Failed to enable sync for extension group {}: {e}", + group.id + ); + } } } } diff --git a/src-tauri/src/sync/manifest.rs b/src-tauri/src/sync/manifest.rs index 43ffe16..251fb46 100644 --- a/src-tauri/src/sync/manifest.rs +++ b/src-tauri/src/sync/manifest.rs @@ -9,24 +9,44 @@ use std::time::SystemTime; use super::types::{SyncError, SyncResult}; -/// Default exclude patterns for volatile Chromium profile files +/// Default exclude patterns for volatile browser profile files. +/// Patterns use `**/` prefix to match at any directory depth, since the sync +/// engine scans from `profiles/{uuid}/` which contains `profile/Default/...`. pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[ - "Cache/**", - "Code Cache/**", - "GPUCache/**", - "GrShaderCache/**", - "ShaderCache/**", - "Service Worker/CacheStorage/**", - "Crashpad/**", - "Crash Reports/**", - "BrowserMetrics/**", - "blob_storage/**", + // Chromium caches (re-downloadable / re-generated) + "**/Cache/**", + "**/Code Cache/**", + "**/GPUCache/**", + "**/GrShaderCache/**", + "**/ShaderCache/**", + "**/DawnCache/**", + "**/DawnGraphiteCache/**", + "**/Service Worker/CacheStorage/**", + "**/Service Worker/ScriptCache/**", + // Chromium transient / volatile data + "**/Session Storage/**", + "**/blob_storage/**", + "**/Crashpad/**", + "**/Crash Reports/**", + "**/BrowserMetrics/**", + "**/optimization_guide_model_store/**", + "**/Safe Browsing/**", + "**/component_crx_cache/**", + // Firefox/Camoufox caches (re-downloadable / re-generated) + "**/cache2/**", + "**/startupCache/**", + "**/safebrowsing/**", + "**/storage/temporary/**", + "**/crashes/**", + "**/minidumps/**", + // Common volatile files "*.log", "*.tmp", "**/LOG", "**/LOG.old", "**/LOCK", "**/*-journal", + "**/*-wal", ".donut-sync/**", ]; @@ -528,6 +548,66 @@ mod tests { assert_eq!(manifest.files[0].path, "file1.txt"); } + #[test] + fn test_generate_manifest_excludes_nested_caches() { + let temp_dir = TempDir::new().unwrap(); + let profile_dir = temp_dir.path().join("profile_root"); + fs::create_dir_all(&profile_dir).unwrap(); + + // Simulate real Chromium structure: profile/Default/Cache/... + let default_dir = profile_dir.join("profile/Default"); + fs::create_dir_all(&default_dir).unwrap(); + fs::write(default_dir.join("Cookies"), "keep").unwrap(); + fs::create_dir_all(default_dir.join("Cache")).unwrap(); + fs::write(default_dir.join("Cache/data_0"), "exclude").unwrap(); + fs::create_dir_all(default_dir.join("Code Cache/js")).unwrap(); + fs::write(default_dir.join("Code Cache/js/abc"), "exclude").unwrap(); + fs::create_dir_all(default_dir.join("GPUCache")).unwrap(); + fs::write(default_dir.join("GPUCache/data_0"), "exclude").unwrap(); + fs::create_dir_all(default_dir.join("Session Storage")).unwrap(); + fs::write(default_dir.join("Session Storage/000003.log"), "exclude").unwrap(); + fs::create_dir_all(default_dir.join("Local Storage/leveldb")).unwrap(); + fs::write(default_dir.join("Local Storage/leveldb/000001.ldb"), "keep").unwrap(); + + // Caches at user-data-dir level + fs::create_dir_all(profile_dir.join("profile/ShaderCache")).unwrap(); + fs::write(profile_dir.join("profile/ShaderCache/data"), "exclude").unwrap(); + fs::create_dir_all(profile_dir.join("profile/Crashpad")).unwrap(); + fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap(); + + // metadata.json at root + fs::write(profile_dir.join("metadata.json"), "keep").unwrap(); + + let mut cache = HashCache::default(); + let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap(); + + let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect(); + assert!( + paths.contains(&"metadata.json"), + "metadata.json should be synced" + ); + assert!( + paths.contains(&"profile/Default/Cookies"), + "Cookies should be synced" + ); + assert!( + paths.contains(&"profile/Default/Local Storage/leveldb/000001.ldb"), + "Local Storage should be synced" + ); + assert!( + !paths.iter().any(|p| p.contains("Cache")), + "Cache directories should be excluded: {paths:?}" + ); + assert!( + !paths.iter().any(|p| p.contains("Session Storage")), + "Session Storage should be excluded: {paths:?}" + ); + assert!( + !paths.iter().any(|p| p.contains("Crashpad")), + "Crashpad should be excluded: {paths:?}" + ); + } + #[test] fn test_compute_diff_upload_all_when_no_remote() { let local = SyncManifest { diff --git a/src-tauri/src/sync/scheduler.rs b/src-tauri/src/sync/scheduler.rs index 155f9d4..4b06319 100644 --- a/src-tauri/src/sync/scheduler.rs +++ b/src-tauri/src/sync/scheduler.rs @@ -164,10 +164,24 @@ impl SyncScheduler { let profile_manager = ProfileManager::instance(); if let Ok(profiles) = profile_manager.list_profiles() { if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) { - return profile.process_id.is_some(); + if profile.process_id.is_some() { + return true; + } } } + // Check if locked by another team member (profile in use remotely) + if crate::team_lock::TEAM_LOCK + .is_locked_by_another(profile_id) + .await + { + log::debug!( + "Profile {} is locked by another team member, treating as running", + profile_id + ); + return true; + } + false } @@ -276,17 +290,38 @@ impl SyncScheduler { for profile in sync_enabled_profiles { let profile_id = profile.id.to_string(); let is_running = profile.process_id.is_some(); + let is_team_locked = crate::team_lock::TEAM_LOCK + .is_locked_by_another(&profile_id) + .await; + let should_wait = is_running || is_team_locked; + + // Track running state in the scheduler + if is_running { + self.mark_profile_running(&profile_id).await; + } + + if should_wait { + log::info!( + "Profile '{}' is {} — will sync after it becomes available", + profile.name, + if is_running { + "running locally" + } else { + "locked by a team member" + } + ); + } // Emit initial status let _ = events::emit( "profile-sync-status", serde_json::json!({ "profile_id": profile_id, - "status": if is_running { "waiting" } else { "syncing" } + "status": if should_wait { "waiting" } else { "syncing" } }), ); - // Queue for immediate sync (or wait if running) + // Queue for sync — running profiles will be deferred by the scheduler self.queue_profile_sync_immediate(profile_id).await; } } @@ -497,7 +532,8 @@ impl SyncScheduler { "proxy-sync-status", serde_json::json!({ "id": proxy_id, - "status": "error" + "status": "error", + "error": e.to_string() }), ); } @@ -563,7 +599,8 @@ impl SyncScheduler { "group-sync-status", serde_json::json!({ "id": group_id, - "status": "error" + "status": "error", + "error": e.to_string() }), ); } @@ -626,7 +663,8 @@ impl SyncScheduler { "vpn-sync-status", serde_json::json!({ "id": vpn_id, - "status": "error" + "status": "error", + "error": e.to_string() }), ); } diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index 66f843b..79831f7 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -311,12 +311,18 @@ impl WayfernManager { "windows" }); + // Include wayfern token if available (enables cross-OS fingerprinting for paid users) + let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; + let mut refresh_params = json!({ "operatingSystem": os }); + if let Some(ref token) = wayfern_token { + refresh_params + .as_object_mut() + .unwrap() + .insert("wayfernToken".to_string(), json!(token)); + } + let refresh_result = self - .send_cdp_command( - &ws_url, - "Wayfern.refreshFingerprint", - json!({ "operatingSystem": os }), - ) + .send_cdp_command(&ws_url, "Wayfern.refreshFingerprint", refresh_params) .await; if let Err(e) = refresh_result { @@ -397,6 +403,7 @@ impl WayfernManager { proxy_url: Option<&str>, ephemeral: bool, extension_paths: &[String], + remote_debugging_port: Option, ) -> Result> { let executable_path = if let Some(path) = &config.executable_path { let p = PathBuf::from(path); @@ -414,7 +421,10 @@ impl WayfernManager { .map_err(|e| format!("Failed to get Wayfern executable path: {e}"))? }; - let port = Self::find_free_port().await?; + let port = match remote_debugging_port { + Some(p) => p, + None => Self::find_free_port().await?, + }; log::info!("Launching Wayfern on CDP port {port}"); let mut args = vec![ @@ -528,16 +538,21 @@ impl WayfernManager { ); } + // Include wayfern token if available (enables cross-OS fingerprinting for paid users) + let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; + let mut fingerprint_params = fingerprint_for_cdp.clone(); + if let Some(ref token) = wayfern_token { + if let Some(obj) = fingerprint_params.as_object_mut() { + obj.insert("wayfernToken".to_string(), json!(token)); + } + } + for target in &page_targets { if let Some(ws_url) = &target.websocket_debugger_url { log::info!("Applying fingerprint to target via WebSocket: {}", ws_url); // Wayfern.setFingerprint expects the fingerprint object directly, NOT wrapped match self - .send_cdp_command( - ws_url, - "Wayfern.setFingerprint", - fingerprint_for_cdp.clone(), - ) + .send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone()) .await { Ok(result) => log::info!( @@ -840,6 +855,7 @@ impl WayfernManager { proxy_url, profile.ephemeral, &[], + None, ) .await } diff --git a/src-tauri/tests/donut_proxy_integration.rs b/src-tauri/tests/donut_proxy_integration.rs index d2197ae..63176a4 100644 --- a/src-tauri/tests/donut_proxy_integration.rs +++ b/src-tauri/tests/donut_proxy_integration.rs @@ -914,7 +914,7 @@ async fn test_bypass_rules_in_config() -> Result<(), Box("profile-sync-status", (event) => { - const { profile_id, status, error } = event.payload; + const { profile_id, status, error, profile_name } = event.payload; const toastId = `sync-${profile_id}`; const profile = profiles.find((p) => p.id === profile_id); - const name = profile?.name ?? "Unknown"; + const name = profile_name || profile?.name || "Unknown"; if (status === "syncing") { showToast({ @@ -845,17 +846,38 @@ export default function Home() { phase: string; total_files?: number; total_bytes?: number; + completed_files?: number; + completed_bytes?: number; + speed_bytes_per_sec?: number; + eta_seconds?: number; + failed_count?: number; + profile_name?: string; }>("profile-sync-progress", (event) => { - const { profile_id, phase, total_files, total_bytes } = event.payload; - if (phase !== "started") return; + const payload = event.payload; + const toastId = `sync-${payload.profile_id}`; + const profile = profiles.find((p) => p.id === payload.profile_id); + const name = payload.profile_name || profile?.name || "Unknown"; - const toastId = `sync-${profile_id}`; - const profile = profiles.find((p) => p.id === profile_id); - const name = profile?.name ?? "Unknown"; - - showSyncProgressToast(name, total_files ?? 0, total_bytes ?? 0, { - id: toastId, - }); + if ( + payload.phase === "started" || + payload.phase === "uploading" || + payload.phase === "downloading" + ) { + showSyncProgressToast( + name, + { + completed_files: payload.completed_files ?? 0, + total_files: payload.total_files ?? 0, + completed_bytes: payload.completed_bytes ?? 0, + total_bytes: payload.total_bytes ?? 0, + speed_bytes_per_sec: payload.speed_bytes_per_sec ?? 0, + eta_seconds: payload.eta_seconds ?? 0, + failed_count: payload.failed_count ?? 0, + phase: payload.phase, + }, + { id: toastId }, + ); + } }); } catch (error) { console.error("Failed to listen for sync events:", error); diff --git a/src/components/camoufox-config-dialog.tsx b/src/components/camoufox-config-dialog.tsx index 2b5be26..5dfa5b8 100644 --- a/src/components/camoufox-config-dialog.tsx +++ b/src/components/camoufox-config-dialog.tsx @@ -164,6 +164,8 @@ export function CamoufoxConfigDialog({ readOnly={isRunning} crossOsUnlocked={crossOsUnlocked} limitedMode={!crossOsUnlocked} + profileVersion={profile.version} + profileBrowser="wayfern" /> ) : ( )} diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index 39e3e28..a9b707a 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -4,11 +4,22 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { GoPlus } from "react-icons/go"; +import { LuCheck, LuChevronsUpDown } from "react-icons/lu"; import { LoadingButton } from "@/components/loading-button"; import { ProxyFormDialog } from "@/components/proxy-form-dialog"; import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { Dialog, DialogContent, @@ -18,13 +29,16 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, - SelectGroup, SelectItem, - SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; @@ -34,6 +48,7 @@ import { useBrowserDownload } from "@/hooks/use-browser-download"; import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useVpnEvents } from "@/hooks/use-vpn-events"; import { getBrowserIcon } from "@/lib/browser-utils"; +import { cn } from "@/lib/utils"; import type { BrowserReleaseTypes, CamoufoxConfig, @@ -127,6 +142,7 @@ export function CreateProfileDialog({ const [selectedBrowser, setSelectedBrowser] = useState(null); const [selectedProxyId, setSelectedProxyId] = useState(); + const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false); // Camoufox anti-detect states const [camoufoxConfig, setCamoufoxConfig] = useState(() => ({ @@ -557,8 +573,13 @@ export function CreateProfileDialog({ {currentStep === "browser-selection" - ? "Create New Profile" - : "Configure Profile"} + ? t("createProfile.title") + : t("createProfile.configureTitle", { + browser: + selectedBrowser === "wayfern" + ? t("createProfile.chromiumLabel") + : t("createProfile.firefoxLabel"), + })} @@ -576,62 +597,54 @@ export function CreateProfileDialog({ <> {/* Anti-Detect Browser Selection */} -
-
-

- Anti-Detect Browser -

-

- Choose a browser with anti-detection capabilities -

-
+
+ {/* Wayfern (Chromium) - First */} + -
- {/* Wayfern (Chromium) - First */} - - - {/* Camoufox (Firefox) - Second */} - -
+
+
@@ -823,6 +836,10 @@ export function CreateProfileDialog({ isCreating crossOsUnlocked={crossOsUnlocked} limitedMode={!crossOsUnlocked} + profileVersion={ + getBestAvailableVersion("wayfern")?.version + } + profileBrowser="wayfern" /> ) : selectedBrowser === "camoufox" ? ( @@ -915,6 +932,14 @@ export function CreateProfileDialog({ )} + {crossOsUnlocked && ( + + + {t("createProfile.camoufoxWarning")} + + + )} + ) : ( @@ -1039,52 +1068,125 @@ export function CreateProfileDialog({ {storedProxies.length > 0 || vpnConfigs.length > 0 ? ( - + + None + + {storedProxies.map((proxy) => ( + { + setSelectedProxyId(proxy.id); + setProxyPopoverOpen(false); + }} + > + + {proxy.name} + + ))} + + {vpnConfigs.length > 0 && ( + + {vpnConfigs.map((vpn) => ( + { + setSelectedProxyId( + `vpn-${vpn.id}`, + ); + setProxyPopoverOpen(false); + }} + > + + + {vpn.vpn_type === "WireGuard" + ? "WG" + : "OVPN"} + + {vpn.name} + + ))} + + )} + + + + ) : (
No proxies or VPNs available. Add one to route @@ -1257,52 +1359,125 @@ export function CreateProfileDialog({
{storedProxies.length > 0 || vpnConfigs.length > 0 ? ( - + + None + + {storedProxies.map((proxy) => ( + { + setSelectedProxyId(proxy.id); + setProxyPopoverOpen(false); + }} + > + + {proxy.name} + + ))} + + {vpnConfigs.length > 0 && ( + + {vpnConfigs.map((vpn) => ( + { + setSelectedProxyId( + `vpn-${vpn.id}`, + ); + setProxyPopoverOpen(false); + }} + > + + + {vpn.vpn_type === "WireGuard" + ? "WG" + : "OVPN"} + + {vpn.name} + + ))} + + )} + + + + ) : (
No proxies or VPNs available. Add one to route diff --git a/src/components/custom-toast.tsx b/src/components/custom-toast.tsx index 17d434e..d0c10ad 100644 --- a/src/components/custom-toast.tsx +++ b/src/components/custom-toast.tsx @@ -116,6 +116,20 @@ interface TwilightUpdateToastProps extends BaseToastProps { hasUpdate?: boolean; } +interface SyncProgressToastProps extends BaseToastProps { + type: "sync-progress"; + progress?: { + completed_files: number; + total_files: number; + completed_bytes: number; + total_bytes: number; + speed_bytes_per_sec: number; + eta_seconds: number; + failed_count: number; + phase: string; + }; +} + type ToastProps = | LoadingToastProps | SuccessToastProps @@ -123,7 +137,38 @@ type ToastProps = | DownloadToastProps | VersionUpdateToastProps | FetchingToastProps - | TwilightUpdateToastProps; + | TwilightUpdateToastProps + | SyncProgressToastProps; + +function formatBytesCompact(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const i = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ); + const value = bytes / 1024 ** i; + return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`; +} + +function formatSpeedCompact(bytesPerSec: number): string { + if (bytesPerSec >= 1024 * 1024) { + return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`; + } + return `${(bytesPerSec / 1024).toFixed(0)} KB/s`; +} + +function formatEtaCompact(seconds: number): string { + if (seconds >= 3600) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return `${h}h ${m}m`; + } + if (seconds >= 60) { + return `${Math.floor(seconds / 60)} min`; + } + return `${Math.round(seconds)}s`; +} function getToastIcon(type: ToastProps["type"], stage?: string) { switch (type) { @@ -153,6 +198,10 @@ function getToastIcon(type: ToastProps["type"], stage?: string) { return ( ); + case "sync-progress": + return ( + + ); case "loading": return (
@@ -237,6 +286,39 @@ export function UnifiedToast(props: ToastProps) {
)} + {/* Sync progress */} + {type === "sync-progress" && + progress && + "completed_files" in progress && ( +
+

+ {progress.phase === "uploading" ? "Uploading" : "Downloading"}{" "} + {progress.completed_files}/{progress.total_files} files + {" \u2022 "} + {formatBytesCompact(progress.completed_bytes)} /{" "} + {formatBytesCompact(progress.total_bytes)} + {progress.speed_bytes_per_sec > 0 && ( + <> + {" \u2022 "} + {formatSpeedCompact(progress.speed_bytes_per_sec)} + + )} + {progress.eta_seconds > 0 && + progress.completed_files < progress.total_files && ( + <> + {" \u2022 ~"} + {formatEtaCompact(progress.eta_seconds)} remaining + + )} +

+ {progress.failed_count > 0 && ( +

+ {progress.failed_count} file(s) failed +

+ )} +
+ )} + {/* Twilight update progress */} {type === "twilight-update" && (
diff --git a/src/components/extension-management-dialog.tsx b/src/components/extension-management-dialog.tsx index 248f96b..6a2e188 100644 --- a/src/components/extension-management-dialog.tsx +++ b/src/components/extension-management-dialog.tsx @@ -1,12 +1,21 @@ "use client"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { FaChrome, FaFirefox } from "react-icons/fa"; import { GoPlus } from "react-icons/go"; -import { LuPencil, LuPuzzle, LuTrash2, LuUpload } from "react-icons/lu"; +import { + LuExternalLink, + LuPencil, + LuPuzzle, + LuTrash2, + LuUpload, +} from "react-icons/lu"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -26,14 +35,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { Tooltip, TooltipContent, @@ -44,6 +45,42 @@ import type { Extension, ExtensionGroup } from "@/types"; import { DeleteConfirmationDialog } from "./delete-confirmation-dialog"; import { RippleButton } from "./ui/ripple"; +type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting"; + +function getSyncStatusDot( + item: { sync_enabled?: boolean; last_sync?: number }, + liveStatus: SyncStatus | undefined, +): { color: string; tooltip: string; animate: boolean } { + const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled"); + + switch (status) { + case "syncing": + return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true }; + case "synced": + return { + color: "bg-green-500", + tooltip: item.last_sync + ? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}` + : "Synced", + animate: false, + }; + case "waiting": + return { + color: "bg-yellow-500", + tooltip: "Waiting to sync", + animate: false, + }; + case "error": + return { + color: "bg-red-500", + tooltip: "Sync error", + animate: false, + }; + default: + return { color: "bg-gray-400", tooltip: "Not synced", animate: false }; + } +} + interface ExtensionManagementDialogProps { isOpen: boolean; onClose: () => void; @@ -74,6 +111,9 @@ export function ExtensionManagementDialog({ const [newGroupName, setNewGroupName] = useState(""); const [editingGroup, setEditingGroup] = useState(null); const [editGroupName, setEditGroupName] = useState(""); + const [editGroupExtensionIds, setEditGroupExtensionIds] = useState( + [], + ); // Delete state const [extensionToDelete, setExtensionToDelete] = useState( @@ -84,6 +124,32 @@ export function ExtensionManagementDialog({ ); const [isDeleting, setIsDeleting] = useState(false); + // Edit extension state + const [editingExtension, setEditingExtension] = useState( + null, + ); + const [editExtensionName, setEditExtensionName] = useState(""); + const [pendingUpdateFile, setPendingUpdateFile] = useState<{ + name: string; + data: number[]; + } | null>(null); + + // Extension icons + const [extensionIcons, setExtensionIcons] = useState>( + {}, + ); + + // Sync state + const [extSyncStatus, setExtSyncStatus] = useState< + Record + >({}); + const [isTogglingExtSync, setIsTogglingExtSync] = useState< + Record + >({}); + const [isTogglingGroupSync, setIsTogglingGroupSync] = useState< + Record + >({}); + // Tab const [activeTab, setActiveTab] = useState<"extensions" | "groups">( "extensions", @@ -108,12 +174,151 @@ export function ExtensionManagementDialog({ } }, [limitedMode]); + const loadIcons = useCallback(async (exts: Extension[]) => { + const icons: Record = {}; + for (const ext of exts) { + try { + const icon = await invoke("get_extension_icon", { + extensionId: ext.id, + }); + if (icon) { + icons[ext.id] = icon; + } + } catch { + // Icon not available + } + } + setExtensionIcons(icons); + }, []); + useEffect(() => { if (isOpen) { - void loadData(); + void loadData().then(() => { + // Icons will be loaded after extensions are set + }); } }, [isOpen, loadData]); + useEffect(() => { + if (extensions.length > 0) { + void loadIcons(extensions); + } + }, [extensions, loadIcons]); + + // Listen for extension sync status events + useEffect(() => { + let unlisten: (() => void) | undefined; + + const setupListener = async () => { + unlisten = await listen<{ id: string; status: string }>( + "extension-sync-status", + (event) => { + const { id, status } = event.payload; + setExtSyncStatus((prev) => ({ + ...prev, + [id]: status as SyncStatus, + })); + }, + ); + }; + + void setupListener(); + return () => { + unlisten?.(); + }; + }, []); + + const handleToggleExtSync = useCallback( + async (ext: Extension) => { + setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: true })); + try { + await invoke("set_extension_sync_enabled", { + extensionId: ext.id, + enabled: !ext.sync_enabled, + }); + showSuccessToast( + ext.sync_enabled + ? t("extensions.syncDisabled") + : t("extensions.syncEnabled"), + ); + void loadData(); + } catch (err) { + showErrorToast(err instanceof Error ? err.message : String(err)); + } finally { + setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false })); + } + }, + [loadData, t], + ); + + const handleToggleGroupSync = useCallback( + async (group: ExtensionGroup) => { + setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: true })); + try { + await invoke("set_extension_group_sync_enabled", { + extensionGroupId: group.id, + enabled: !group.sync_enabled, + }); + showSuccessToast( + group.sync_enabled + ? t("extensions.syncDisabled") + : t("extensions.syncEnabled"), + ); + void loadData(); + } catch (err) { + showErrorToast(err instanceof Error ? err.message : String(err)); + } finally { + setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false })); + } + }, + [loadData, t], + ); + + const handleUpdateExtension = useCallback(async () => { + if (!editingExtension || !editExtensionName.trim()) return; + try { + await invoke("update_extension", { + extensionId: editingExtension.id, + name: editExtensionName.trim(), + fileName: pendingUpdateFile?.name ?? null, + fileData: pendingUpdateFile?.data ?? null, + }); + showSuccessToast(t("extensions.updateSuccess")); + setEditingExtension(null); + setEditExtensionName(""); + setPendingUpdateFile(null); + void loadData(); + } catch (err) { + showErrorToast(err instanceof Error ? err.message : String(err)); + } + }, [editingExtension, editExtensionName, pendingUpdateFile, loadData, t]); + + const handleEditFileSelect = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const validExtensions = [".xpi", ".crx", ".zip"]; + const isValid = validExtensions.some((ext) => + file.name.toLowerCase().endsWith(ext), + ); + if (!isValid) { + showErrorToast(t("extensions.invalidFileType")); + return; + } + + const reader = new FileReader(); + reader.onload = (event) => { + const arrayBuffer = event.target?.result as ArrayBuffer; + const data = Array.from(new Uint8Array(arrayBuffer)); + setPendingUpdateFile({ name: file.name, data }); + }; + reader.readAsArrayBuffer(file); + e.target.value = ""; + }, + [t], + ); + const handleFileSelect = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -199,21 +404,48 @@ export function ExtensionManagementDialog({ } }, [newGroupName, loadData, t]); - const handleUpdateGroup = useCallback(async () => { + const handleSaveGroupEdits = useCallback(async () => { if (!editingGroup || !editGroupName.trim()) return; try { + // Update group name await invoke("update_extension_group", { groupId: editingGroup.id, name: editGroupName.trim(), }); + + // Compute diff of extensions + const originalIds = new Set(editingGroup.extension_ids); + const newIds = new Set(editGroupExtensionIds); + + // Add new extensions + for (const extId of editGroupExtensionIds) { + if (!originalIds.has(extId)) { + await invoke("add_extension_to_group", { + groupId: editingGroup.id, + extensionId: extId, + }); + } + } + + // Remove removed extensions + for (const extId of editingGroup.extension_ids) { + if (!newIds.has(extId)) { + await invoke("remove_extension_from_group", { + groupId: editingGroup.id, + extensionId: extId, + }); + } + } + showSuccessToast(t("extensions.groupUpdateSuccess")); setEditingGroup(null); setEditGroupName(""); + setEditGroupExtensionIds([]); void loadData(); } catch (err) { showErrorToast(err instanceof Error ? err.message : String(err)); } - }, [editingGroup, editGroupName, loadData, t]); + }, [editingGroup, editGroupName, editGroupExtensionIds, loadData, t]); const handleDeleteGroup = useCallback(async () => { if (!groupToDelete) return; @@ -230,57 +462,63 @@ export function ExtensionManagementDialog({ } }, [groupToDelete, loadData, t]); - const handleAddToGroup = useCallback( - async (groupId: string, extensionId: string) => { - try { - await invoke("add_extension_to_group", { groupId, extensionId }); - void loadData(); - } catch (err) { - showErrorToast(err instanceof Error ? err.message : String(err)); - } - }, - [loadData], - ); - - const handleRemoveFromGroup = useCallback( - async (groupId: string, extensionId: string) => { - try { - await invoke("remove_extension_from_group", { groupId, extensionId }); - void loadData(); - } catch (err) { - showErrorToast(err instanceof Error ? err.message : String(err)); - } - }, - [loadData], - ); - - const getCompatibilityBadge = (compat: string[]) => { - if (compat.includes("chromium") && compat.includes("firefox")) { - return ( - {t("extensions.compatibility.both")} - ); - } - if (compat.includes("chromium")) { - return ( - - {t("extensions.compatibility.chromium")} - - ); - } - if (compat.includes("firefox")) { - return ( - - {t("extensions.compatibility.firefox")} - - ); - } - return null; + const renderCompatIcons = (compat: string[]) => { + const hasChromium = compat.includes("chromium"); + const hasFirefox = compat.includes("firefox"); + if (!hasChromium && !hasFirefox) return null; + return ( +
+ {hasChromium && ( + + + + + + + + {t("extensions.compatibility.chromium")} + + + )} + {hasFirefox && ( + + + + + + + + {t("extensions.compatibility.firefox")} + + + )} +
+ ); }; + const renderExtensionIcon = (ext: Extension, size: "sm" | "md" = "md") => { + const sizeClass = size === "sm" ? "w-4 h-4" : "w-5 h-5"; + if (extensionIcons[ext.id]) { + return ( + // biome-ignore lint/performance/noImgElement: base64 data URI icons cannot use next/image + + ); + } + return ( + + ); + }; + + const MAX_VISIBLE_ICONS = 3; + return ( <> - + @@ -290,399 +528,717 @@ export function ExtensionManagementDialog({ {t("extensions.description")} -
- {limitedMode && ( - <> -
-
-
-
-
-
-
- - - {t("extensions.proRequired")} - -
-
- - )} - -
- {/* Tab selector */} -
- - -
- - {/* Notice */} -
- {t("extensions.managedNotice")} -
- - {activeTab === "extensions" && ( -
-
- -
- - + +
+ {limitedMode && ( + <> +
+
+
+
+
+
+
+ + + {t("extensions.proRequired")} +
+ + )} - {/* Upload form */} - {showUploadForm && pendingFile && ( -
-
- {t("extensions.selectedFile")}:{" "} - - {pendingFile.name} - +
+ {/* Tab selector */} +
+ + +
+ + {/* Notice */} +
+ {t("extensions.managedNotice")} +
+ + {activeTab === "extensions" && ( +
+
+ +
+ +
-
+
+ + {/* Upload form */} + {showUploadForm && pendingFile && ( +
+
+ {t("extensions.selectedFile")}:{" "} + + {pendingFile.name} + +
+
+ setExtensionName(e.target.value)} + placeholder={t("extensions.namePlaceholder")} + className="flex-1" + /> + + {isUploading + ? t("common.buttons.loading") + : t("common.buttons.add")} + + +
+
+ )} + + {/* Extensions list */} + {isLoading ? ( +
+ {t("common.buttons.loading")} +
+ ) : extensions.length === 0 ? ( +
+ {t("extensions.empty")} +
+ ) : ( +
+ {extensions.map((ext) => { + const syncDot = getSyncStatusDot( + ext, + extSyncStatus[ext.id], + ); + return ( +
+ + +
+ + +

{syncDot.tooltip}

+
+ + {renderExtensionIcon(ext, "sm")} + + {ext.name} + + + .{ext.file_type} + + {renderCompatIcons(ext.browser_compatibility)} + + +
+ + handleToggleExtSync(ext) + } + disabled={isTogglingExtSync[ext.id]} + /> +
+
+ +

+ {ext.sync_enabled + ? t("extensions.syncDisableTooltip") + : t("extensions.syncEnableTooltip")} +

+
+
+
+ + + + + + {t("extensions.editExtension")} + + + + + + + + {t("extensions.delete")} + + +
+
+ ); + })} +
+ )} +
+ )} + + {activeTab === "groups" && ( +
+
+ + setShowCreateGroup(true)} + className="flex gap-2 items-center" + disabled={limitedMode} + > + + {t("extensions.createGroup")} + +
+ + {/* Create group form */} + {showCreateGroup && ( +
setExtensionName(e.target.value)} - placeholder={t("extensions.namePlaceholder")} + value={newGroupName} + onChange={(e) => setNewGroupName(e.target.value)} + placeholder={t("extensions.groupNamePlaceholder")} className="flex-1" + onKeyDown={(e) => { + if (e.key === "Enter") void handleCreateGroup(); + }} /> - {isUploading - ? t("common.buttons.loading") - : t("common.buttons.add")} + {t("common.buttons.create")}
-
- )} + )} - {/* Extensions list */} - {isLoading ? ( -
- {t("common.buttons.loading")} -
- ) : extensions.length === 0 ? ( -
- {t("extensions.empty")} -
- ) : ( -
- - - - - {t("common.labels.name")} - - {t("common.labels.type")} - - - {t("extensions.compatibility.label")} - - - {t("common.labels.actions")} - - - - - {extensions.map((ext) => ( - - - {ext.name} - - - - .{ext.file_type} - - - - {getCompatibilityBadge( - ext.browser_compatibility, - )} - - - - - - - - {t("extensions.delete")} - - - - - ))} - -
-
-
- )} -
- )} + {/* Groups list */} + {extensionGroups.length === 0 ? ( +
+ {t("extensions.noGroups")} +
+ ) : ( +
+ {extensionGroups.map((group) => { + const groupExts = group.extension_ids + .map((id) => extensions.find((e) => e.id === id)) + .filter(Boolean) as Extension[]; + const visibleExts = groupExts.slice( + 0, + MAX_VISIBLE_ICONS, + ); + const overflowCount = + groupExts.length - MAX_VISIBLE_ICONS; + const groupSyncDot = getSyncStatusDot( + group, + extSyncStatus[group.id], + ); - {activeTab === "groups" && ( -
-
- - setShowCreateGroup(true)} - className="flex gap-2 items-center" - disabled={limitedMode} - > - - {t("extensions.createGroup")} - -
- - {/* Create group form */} - {showCreateGroup && ( -
- setNewGroupName(e.target.value)} - placeholder={t("extensions.groupNamePlaceholder")} - className="flex-1" - onKeyDown={(e) => { - if (e.key === "Enter") void handleCreateGroup(); - }} - /> - - {t("common.buttons.create")} - - -
- )} - - {/* Groups list */} - {extensionGroups.length === 0 ? ( -
- {t("extensions.noGroups")} -
- ) : ( -
- {extensionGroups.map((group) => ( -
-
- {editingGroup?.id === group.id ? ( -
- - setEditGroupName(e.target.value) - } - className="flex-1" - onKeyDown={(e) => { - if (e.key === "Enter") - void handleUpdateGroup(); - }} - /> - - {t("common.buttons.save")} - - -
- ) : ( - <> - - {group.name} - -
- - - - - - {t("common.buttons.edit")} - - - - - - - - {t("extensions.deleteGroup")} - - -
- - )} -
- - {/* Extension assignment */} -
- {group.extension_ids.length > 0 && ( -
- {group.extension_ids.map((extId) => { - const ext = extensions.find( - (e) => e.id === extId, - ); - if (!ext) return null; - return ( - - {ext.name} - - - ); - })} -
- )} - {extensions.filter( - (e) => !group.extension_ids.includes(e.id), - ).length > 0 && ( - - )} + + +

{groupSyncDot.tooltip}

+
+ + + {group.name} + + +
+ {visibleExts.map((ext) => ( + + + + {renderExtensionIcon(ext, "sm")} + + + {ext.name} + + ))} + {overflowCount > 0 && ( + + + + +{overflowCount} + + + +
+ {groupExts + .slice(MAX_VISIBLE_ICONS) + .map((ext) => ( +

+ {ext.name} +

+ ))} +
+
+
+ )} + {groupExts.length === 0 && ( + + {t("extensions.noExtensionsInGroup")} + + )} +
+ + + +
+ + handleToggleGroupSync(group) + } + disabled={isTogglingGroupSync[group.id]} + /> +
+
+ +

+ {group.sync_enabled + ? t("extensions.syncDisableTooltip") + : t("extensions.syncEnableTooltip")} +

+
+
+ +
+ + + + + + {t("common.buttons.edit")} + + + + + + + + {t("extensions.deleteGroup")} + + +
+
+ ); + })} +
+ )} +
+ )} +
+
+ + + + + {t("common.buttons.close")} + + + +
+ + {/* Group editing dialog */} + { + if (!open) { + setEditingGroup(null); + setEditGroupName(""); + setEditGroupExtensionIds([]); + } + }} + > + + + {t("extensions.editGroup")} + + {t("extensions.editGroupDescription")} + + + +
+
+ + setEditGroupName(e.target.value)} + placeholder={t("extensions.groupNamePlaceholder")} + /> +
+ + {extensions.filter((e) => !editGroupExtensionIds.includes(e.id)) + .length > 0 && ( +
+ + +
+ )} + +
+ + {editGroupExtensionIds.length === 0 ? ( +
+ {t("extensions.noExtensionsInGroup")} +
+ ) : ( +
+ {editGroupExtensionIds.map((extId) => { + const ext = extensions.find((e) => e.id === extId); + if (!ext) return null; + return ( +
+ {renderExtensionIcon(ext, "sm")} + + {ext.name} + + {renderCompatIcons(ext.browser_compatibility)} + +
+ ); + })}
)}
- - {t("common.buttons.close")} + + + {t("common.buttons.save")} + + +
+
+ + {/* Extension editing dialog */} + { + if (!open) { + setEditingExtension(null); + setEditExtensionName(""); + setPendingUpdateFile(null); + } + }} + > + + + {t("extensions.editExtension")} + + {t("extensions.editExtensionDescription")} + + + + {editingExtension && ( +
+
+ + setEditExtensionName(e.target.value)} + placeholder={t("extensions.namePlaceholder")} + onKeyDown={(e) => { + if (e.key === "Enter") void handleUpdateExtension(); + }} + /> +
+ + {/* Metadata from manifest.json */} +
+ +
+ {editingExtension.version && ( + <> + + {t("extensions.version")} + + {editingExtension.version} + + )} + {editingExtension.author && ( + <> + + {t("extensions.author")} + + {editingExtension.author} + + )} + {editingExtension.description && ( + <> + + {t("common.labels.description")} + + + {editingExtension.description} + + + )} + + {t("extensions.compatibility.label")} + +
+ {renderCompatIcons(editingExtension.browser_compatibility)} +
+ + {t("common.labels.type")} + + .{editingExtension.file_type} + {editingExtension.homepage_url && ( + <> + + {t("extensions.homepage")} + + + + {editingExtension.homepage_url} + + + + + )} + {!editingExtension.version && + !editingExtension.author && + !editingExtension.description && + !editingExtension.homepage_url && ( + + {t("extensions.noMetadata")} + + )} +
+
+ + {/* Re-upload */} +
+ +
+ + document.getElementById("ext-edit-file-input")?.click() + } + > + + {t("extensions.selectFile")} + + + {pendingUpdateFile && ( + + {pendingUpdateFile.name} + + )} +
+
+
+ )} + + + + + {t("common.buttons.save")}
diff --git a/src/components/group-management-dialog.tsx b/src/components/group-management-dialog.tsx index 4fca988..2aa0ebe 100644 --- a/src/components/group-management-dialog.tsx +++ b/src/components/group-management-dialog.tsx @@ -43,6 +43,7 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting"; function getSyncStatusDot( group: GroupWithCount, liveStatus: SyncStatus | undefined, + errorMessage?: string, ): { color: string; tooltip: string; animate: boolean } { const status = liveStatus ?? (group.sync_enabled ? "synced" : "disabled"); @@ -64,7 +65,11 @@ function getSyncStatusDot( animate: false, }; case "error": - return { color: "bg-red-500", tooltip: "Sync error", animate: false }; + return { + color: "bg-red-500", + tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error", + animate: false, + }; default: return { color: "bg-gray-400", tooltip: "Not synced", animate: false }; } @@ -95,6 +100,9 @@ export function GroupManagementDialog({ const [groupSyncStatus, setGroupSyncStatus] = useState< Record >({}); + const [groupSyncErrors, setGroupSyncErrors] = useState< + Record + >({}); const [groupInUse, setGroupInUse] = useState>({}); const [isTogglingSync, setIsTogglingSync] = useState>( {}, @@ -105,14 +113,17 @@ export function GroupManagementDialog({ let unlisten: (() => void) | undefined; const setupListener = async () => { - unlisten = await listen<{ id: string; status: string }>( + unlisten = await listen<{ id: string; status: string; error?: string }>( "group-sync-status", (event) => { - const { id, status } = event.payload; + const { id, status, error } = event.payload; setGroupSyncStatus((prev) => ({ ...prev, [id]: status as SyncStatus, })); + if (error) { + setGroupSyncErrors((prev) => ({ ...prev, [id]: error })); + } }, ); }; @@ -216,7 +227,7 @@ export function GroupManagementDialog({ return ( <> - + Manage Profile Groups @@ -225,149 +236,152 @@ export function GroupManagementDialog({ -
- {/* Create new group button */} -
- - setCreateDialogOpen(true)} - className="flex gap-2 items-center" - > - - Create - + +
+ {/* Create new group button */} +
+ + setCreateDialogOpen(true)} + className="flex gap-2 items-center" + > + + Create + +
+ + {error && ( +
+ {error} +
+ )} + + {/* Groups list */} + {isLoading ? ( +
+ Loading groups... +
+ ) : groups.length === 0 ? ( +
+ No groups created yet. Create your first group using the + button above. +
+ ) : ( +
+ + + + + Name + Profiles + Sync + Actions + + + + {groups.map((group) => { + const syncDot = getSyncStatusDot( + group, + groupSyncStatus[group.id], + groupSyncErrors[group.id], + ); + return ( + + +
+ + +
+ + +

{syncDot.tooltip}

+
+ + {group.name} +
+ + + {group.count} + + + + +
+ + handleToggleSync(group) + } + disabled={ + isTogglingSync[group.id] || + groupInUse[group.id] + } + /> +
+
+ + {groupInUse[group.id] ? ( +

+ Sync cannot be disabled while this group + is used by synced profiles +

+ ) : ( +

+ {group.sync_enabled + ? "Disable sync" + : "Enable sync"} +

+ )} +
+
+
+ +
+ + + + + +

Edit group

+
+
+ + + + + +

Delete group

+
+
+
+
+ + ); + })} + +
+
+
+ )}
- - {error && ( -
- {error} -
- )} - - {/* Groups list */} - {isLoading ? ( -
- Loading groups... -
- ) : groups.length === 0 ? ( -
- No groups created yet. Create your first group using the button - above. -
- ) : ( -
- - - - - Name - Profiles - Sync - Actions - - - - {groups.map((group) => { - const syncDot = getSyncStatusDot( - group, - groupSyncStatus[group.id], - ); - return ( - - -
- - -
- - -

{syncDot.tooltip}

-
- - {group.name} -
- - - {group.count} - - - - -
- - handleToggleSync(group) - } - disabled={ - isTogglingSync[group.id] || - groupInUse[group.id] - } - /> -
-
- - {groupInUse[group.id] ? ( -

- Sync cannot be disabled while this group - is used by synced profiles -

- ) : ( -

- {group.sync_enabled - ? "Disable sync" - : "Enable sync"} -

- )} -
-
-
- -
- - - - - -

Edit group

-
-
- - - - - -

Delete group

-
-
-
-
- - ); - })} - -
-
-
- )} -
+ diff --git a/src/components/location-proxy-dialog.tsx b/src/components/location-proxy-dialog.tsx index efb6a96..822691a 100644 --- a/src/components/location-proxy-dialog.tsx +++ b/src/components/location-proxy-dialog.tsx @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; +import { Loader2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -29,26 +30,31 @@ export function LocationProxyDialog({ onClose, }: LocationProxyDialogProps) { const [countries, setCountries] = useState([]); - const [states, setStates] = useState([]); + const [regions, setRegions] = useState([]); const [cities, setCities] = useState([]); + const [isps, setIsps] = useState([]); const [selectedCountry, setSelectedCountry] = useState(""); - const [selectedState, setSelectedState] = useState(""); + const [selectedRegion, setSelectedRegion] = useState(""); const [selectedCity, setSelectedCity] = useState(""); + const [selectedIsp, setSelectedIsp] = useState(""); const [proxyName, setProxyName] = useState(""); const [isLoadingCountries, setIsLoadingCountries] = useState(false); - const [isLoadingStates, setIsLoadingStates] = useState(false); + const [isLoadingRegions, setIsLoadingRegions] = useState(false); const [isLoadingCities, setIsLoadingCities] = useState(false); + const [isLoadingIsps, setIsLoadingIsps] = useState(false); const [isCreating, setIsCreating] = useState(false); const handleClose = useCallback(() => { setSelectedCountry(""); - setSelectedState(""); + setSelectedRegion(""); setSelectedCity(""); + setSelectedIsp(""); setProxyName(""); - setStates([]); + setRegions([]); setCities([]); + setIsps([]); onClose(); }, [onClose]); @@ -65,52 +71,87 @@ export function LocationProxyDialog({ .finally(() => setIsLoadingCountries(false)); }, [isOpen]); - // Fetch states when country changes + // Fetch regions when country changes useEffect(() => { if (!selectedCountry) { - setStates([]); + setRegions([]); return; } - setIsLoadingStates(true); - setSelectedState(""); + setIsLoadingRegions(true); + setSelectedRegion(""); setSelectedCity(""); + setSelectedIsp(""); setCities([]); - invoke("cloud_get_states", { country: selectedCountry }) - .then((data) => setStates(data)) - .catch((err) => console.error("Failed to fetch states:", err)) - .finally(() => setIsLoadingStates(false)); + setIsps([]); + invoke("cloud_get_regions", { country: selectedCountry }) + .then((data) => setRegions(data)) + .catch((err) => console.error("Failed to fetch regions:", err)) + .finally(() => setIsLoadingRegions(false)); }, [selectedCountry]); - // Fetch cities when state changes + // Fetch cities when country or region changes (cities can be loaded without region) useEffect(() => { - if (!selectedCountry || !selectedState) { + if (!selectedCountry) { setCities([]); return; } setIsLoadingCities(true); setSelectedCity(""); - invoke("cloud_get_cities", { + const args: { country: string; region?: string } = { country: selectedCountry, - state: selectedState, - }) + }; + if (selectedRegion) { + args.region = selectedRegion; + } + invoke("cloud_get_cities", args) .then((data) => setCities(data)) .catch((err) => console.error("Failed to fetch cities:", err)) .finally(() => setIsLoadingCities(false)); - }, [selectedCountry, selectedState]); + }, [selectedCountry, selectedRegion]); + + // Fetch ISPs when country/region/city changes + useEffect(() => { + if (!selectedCountry) { + setIsps([]); + return; + } + setIsLoadingIsps(true); + setSelectedIsp(""); + const args: { country: string; region?: string; city?: string } = { + country: selectedCountry, + }; + if (selectedRegion) args.region = selectedRegion; + if (selectedCity) args.city = selectedCity; + invoke("cloud_get_isps", args) + .then((data) => setIsps(data)) + .catch((err) => console.error("Failed to fetch ISPs:", err)) + .finally(() => setIsLoadingIsps(false)); + }, [selectedCountry, selectedRegion, selectedCity]); // Auto-generate name from selections useEffect(() => { const parts: string[] = []; const countryItem = countries.find((c) => c.code === selectedCountry); if (countryItem) parts.push(countryItem.name); - const stateItem = states.find((s) => s.code === selectedState); - if (stateItem) parts.push(stateItem.name); + const regionItem = regions.find((s) => s.code === selectedRegion); + if (regionItem) parts.push(regionItem.name); const cityItem = cities.find((c) => c.code === selectedCity); if (cityItem) parts.push(cityItem.name); + const ispItem = isps.find((i) => i.code === selectedIsp); + if (ispItem) parts.push(ispItem.name); if (parts.length > 0) { setProxyName(parts.join(" - ")); } - }, [selectedCountry, selectedState, selectedCity, countries, states, cities]); + }, [ + selectedCountry, + selectedRegion, + selectedCity, + selectedIsp, + countries, + regions, + cities, + isps, + ]); const handleCreate = useCallback(async () => { if (!selectedCountry || !proxyName.trim()) return; @@ -119,8 +160,9 @@ export function LocationProxyDialog({ await invoke("create_cloud_location_proxy", { name: proxyName.trim(), country: selectedCountry, - state: selectedState || null, + region: selectedRegion || null, city: selectedCity || null, + isp: selectedIsp || null, }); toast.success("Location proxy created"); await emit("stored-proxies-changed"); @@ -133,14 +175,26 @@ export function LocationProxyDialog({ } finally { setIsCreating(false); } - }, [selectedCountry, selectedState, selectedCity, proxyName, handleClose]); + }, [ + selectedCountry, + selectedRegion, + selectedCity, + selectedIsp, + proxyName, + handleClose, + ]); const countryOptions = countries.map((c) => ({ value: c.code, label: c.name, })); - const stateOptions = states.map((s) => ({ value: s.code, label: s.name })); + const regionOptions = regions.map((s) => ({ value: s.code, label: s.name })); const cityOptions = cities.map((c) => ({ value: c.code, label: c.name })); + const ispOptions = isps.map((i) => ({ value: i.code, label: i.name })); + + const LoadingSpinner = () => ( + + ); return ( @@ -148,48 +202,102 @@ export function LocationProxyDialog({ Create Location Proxy - Create a geo-targeted proxy from your cloud credentials + Create a geo-targeted proxy with a 24-hour sticky session
+ {/* Country - always visible */}
- +
- {selectedCountry && stateOptions.length > 0 && ( -
- - -
- )} + {/* Region - always visible, disabled until country is selected */} +
+ + +
- {selectedState && cityOptions.length > 0 && ( -
- - -
- )} + {/* City - always visible, disabled until country is selected */} +
+ + +
+ {/* ISP - always visible, disabled until country is selected */} +
+ + +
+ + {/* Name */}
handleAction(() => onAssignProfilesToGroup?.([profile.id])), disabled: isDisabled, + runningBadge: isRunning, }, { icon: , label: t("profiles.actions.changeFingerprint"), onClick: () => handleAction(() => onConfigureCamoufox?.(profile)), disabled: isDisabled, + runningBadge: isRunning, hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox, }, { @@ -254,6 +257,7 @@ export function ProfileInfoDialog({ onClick: () => handleAction(() => onCopyCookiesToProfile?.(profile)), disabled: isDisabled || !crossOsUnlocked, proBadge: !crossOsUnlocked, + runningBadge: isRunning && crossOsUnlocked, hidden: !isCamoufoxOrWayfern || profile.ephemeral === true || @@ -265,6 +269,7 @@ export function ProfileInfoDialog({ onClick: () => handleAction(() => onOpenCookieManagement?.(profile)), disabled: isDisabled || !crossOsUnlocked, proBadge: !crossOsUnlocked, + runningBadge: isRunning && crossOsUnlocked, hidden: !isCamoufoxOrWayfern || profile.ephemeral === true || @@ -275,6 +280,7 @@ export function ProfileInfoDialog({ label: t("profiles.actions.clone"), onClick: () => handleAction(() => onCloneProfile?.(profile)), disabled: isDisabled, + runningBadge: isRunning, hidden: profile.ephemeral === true, }, { @@ -283,6 +289,7 @@ export function ProfileInfoDialog({ onClick: () => handleAction(() => onAssignExtensionGroup?.([profile.id])), disabled: isDisabled || !crossOsUnlocked, proBadge: !crossOsUnlocked, + runningBadge: isRunning && crossOsUnlocked, hidden: profile.ephemeral === true, }, { @@ -488,7 +495,12 @@ export function ProfileInfoDialog({ {action.icon} {action.label} - {action.proBadge && } + {action.runningBadge && ( + + {t("common.status.running")} + + )} + {action.proBadge && !action.runningBadge && } diff --git a/src/components/profile-sync-dialog.tsx b/src/components/profile-sync-dialog.tsx index 864f7d5..3c351b2 100644 --- a/src/components/profile-sync-dialog.tsx +++ b/src/components/profile-sync-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { LoadingButton } from "@/components/loading-button"; import { Badge } from "@/components/ui/badge"; @@ -53,6 +53,7 @@ export function ProfileSyncDialog({ const [hasSelfHostedConfig, setHasSelfHostedConfig] = useState(false); const [hasE2ePassword, setHasE2ePassword] = useState(false); const [isCheckingConfig, setIsCheckingConfig] = useState(false); + const [userChangedMode, setUserChangedMode] = useState(false); const hasConfig = isCloudSyncEligible || hasSelfHostedConfig; @@ -72,17 +73,21 @@ export function ProfileSyncDialog({ } }, []); + useEffect(() => { + if (isOpen && profile) { + setSyncMode(profile.sync_mode ?? "Disabled"); + setUserChangedMode(false); + void checkSyncConfig(); + } + }, [isOpen, profile, checkSyncConfig]); + const handleOpenChange = useCallback( (open: boolean) => { - if (open && profile) { - setSyncMode(profile.sync_mode ?? "Disabled"); - void checkSyncConfig(); - } if (!open) { onClose(); } }, - [profile, onClose, checkSyncConfig], + [onClose], ); const handleModeChange = useCallback( @@ -113,6 +118,7 @@ export function ProfileSyncDialog({ syncMode: newMode, }); setSyncMode(newMode as SyncMode); + setUserChangedMode(true); showSuccessToast( newMode !== "Disabled" ? t("sync.mode.enabledToast") @@ -273,14 +279,16 @@ export function ProfileSyncDialog({
- {syncMode === "Encrypted" && !hasE2ePassword && ( -
- {t( - "sync.mode.noPasswordWarning", - "E2E password not set. Please set a password in Settings.", - )} -
- )} + {syncMode === "Encrypted" && + !hasE2ePassword && + userChangedMode && ( +
+ {t( + "sync.mode.noPasswordWarning", + "E2E password not set. Please set a password in Settings.", + )} +
+ )}
diff --git a/src/components/proxy-assignment-dialog.tsx b/src/components/proxy-assignment-dialog.tsx index b4e27df..4841790 100644 --- a/src/components/proxy-assignment-dialog.tsx +++ b/src/components/proxy-assignment-dialog.tsx @@ -3,9 +3,19 @@ import { invoke } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; import { useCallback, useEffect, useState } from "react"; +import { LuCheck, LuChevronsUpDown } from "react-icons/lu"; import { toast } from "sonner"; import { LoadingButton } from "@/components/loading-button"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { Dialog, DialogContent, @@ -16,14 +26,11 @@ import { } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; import type { BrowserProfile, StoredProxy, VpnConfig } from "@/types"; import { RippleButton } from "./ui/ripple"; @@ -51,6 +58,7 @@ export function ProxyAssignmentDialog({ "none", ); const [isAssigning, setIsAssigning] = useState(false); + const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false); const [error, setError] = useState(null); const handleValueChange = useCallback((value: string) => { @@ -126,13 +134,6 @@ export function ProxyAssignmentDialog({ } }, [isOpen]); - const selectValue = - selectionType === "none" - ? "none" - : selectionType === "vpn" - ? `vpn-${selectedId}` - : (selectedId ?? "none"); - return ( @@ -166,43 +167,112 @@ export function ProxyAssignmentDialog({
- + + + {vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} + + {vpn.name} + + ))} + + )} + + + +
{error && ( diff --git a/src/components/proxy-management-dialog.tsx b/src/components/proxy-management-dialog.tsx index 8f0b09c..b75b0ec 100644 --- a/src/components/proxy-management-dialog.tsx +++ b/src/components/proxy-management-dialog.tsx @@ -53,6 +53,7 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting"; function getSyncStatusDot( item: { sync_enabled?: boolean; last_sync?: number }, liveStatus: SyncStatus | undefined, + errorMessage?: string, ): { color: string; tooltip: string; animate: boolean } { const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled"); @@ -74,7 +75,11 @@ function getSyncStatusDot( animate: false, }; case "error": - return { color: "bg-red-500", tooltip: "Sync error", animate: false }; + return { + color: "bg-red-500", + tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error", + animate: false, + }; default: return { color: "bg-gray-400", tooltip: "Not synced", animate: false }; } @@ -104,6 +109,9 @@ export function ProxyManagementDialog({ const [proxySyncStatus, setProxySyncStatus] = useState< Record >({}); + const [proxySyncErrors, setProxySyncErrors] = useState< + Record + >({}); const [proxyInUse, setProxyInUse] = useState>({}); const [isTogglingSync, setIsTogglingSync] = useState>( {}, @@ -119,6 +127,9 @@ export function ProxyManagementDialog({ const [vpnSyncStatus, setVpnSyncStatus] = useState< Record >({}); + const [vpnSyncErrors, setVpnSyncErrors] = useState>( + {}, + ); const [vpnInUse, setVpnInUse] = useState>({}); const [isTogglingVpnSync, setIsTogglingVpnSync] = useState< Record @@ -126,50 +137,30 @@ export function ProxyManagementDialog({ const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents(); const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents(); - const [cloudProxyUsage, setCloudProxyUsage] = useState<{ - used_mb: number; - limit_mb: number; - } | null>(null); - // Sort cloud-managed proxies first - const storedProxies = [...rawProxies].sort((a, b) => { - if (a.is_cloud_managed && !b.is_cloud_managed) return -1; - if (!a.is_cloud_managed && b.is_cloud_managed) return 1; - return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); - }); - - // Fetch cloud proxy usage - useEffect(() => { - const fetchUsage = async () => { - try { - const usage = await invoke<{ - used_mb: number; - limit_mb: number; - remaining_mb: number; - } | null>("cloud_get_proxy_usage"); - setCloudProxyUsage(usage); - } catch { - // ignore - } - }; - if (isOpen) { - void fetchUsage(); - } - }, [isOpen]); + // Filter out the base cloud-managed proxy (it's an internal indicator, not user-facing) + // Keep cloud-derived location proxies + const storedProxies = rawProxies + .filter((p) => !p.is_cloud_managed) + .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + const hasCloudProxy = rawProxies.some((p) => p.is_cloud_managed); // Listen for proxy sync status events useEffect(() => { let unlisten: (() => void) | undefined; const setupListener = async () => { - unlisten = await listen<{ id: string; status: string }>( + unlisten = await listen<{ id: string; status: string; error?: string }>( "proxy-sync-status", (event) => { - const { id, status } = event.payload; + const { id, status, error } = event.payload; setProxySyncStatus((prev) => ({ ...prev, [id]: status as SyncStatus, })); + if (error) { + setProxySyncErrors((prev) => ({ ...prev, [id]: error })); + } }, ); }; @@ -185,14 +176,17 @@ export function ProxyManagementDialog({ let unlisten: (() => void) | undefined; const setupListener = async () => { - unlisten = await listen<{ id: string; status: string }>( + unlisten = await listen<{ id: string; status: string; error?: string }>( "vpn-sync-status", (event) => { - const { id, status } = event.payload; + const { id, status, error } = event.payload; setVpnSyncStatus((prev) => ({ ...prev, [id]: status as SyncStatus, })); + if (error) { + setVpnSyncErrors((prev) => ({ ...prev, [id]: error })); + } }, ); }; @@ -370,7 +364,7 @@ export function ProxyManagementDialog({ return ( <> - + Proxies & VPNs @@ -378,96 +372,96 @@ export function ProxyManagementDialog({ - - - - Proxies - - - VPNs - - + + + + + Proxies + + + VPNs + + - -
-
-
- setShowImportDialog(true)} - className="flex gap-2 items-center" - > - - Import - - setShowExportDialog(true)} - className="flex gap-2 items-center" - disabled={storedProxies.length === 0} - > - - Export - -
-
- {storedProxies.some((p) => p.is_cloud_managed) && ( + +
+
+
setShowLocationDialog(true)} + onClick={() => setShowImportDialog(true)} className="flex gap-2 items-center" > - - Location + + Import - )} - - - Create - + setShowExportDialog(true)} + className="flex gap-2 items-center" + disabled={storedProxies.length === 0} + > + + Export + +
+
+ {hasCloudProxy && ( + setShowLocationDialog(true)} + className="flex gap-2 items-center" + > + + Location + + )} + + + Create + +
-
- {isLoading ? ( -
- Loading proxies... -
- ) : storedProxies.length === 0 ? ( -
- No proxies created yet. Create your first proxy using the - button above. -
- ) : ( -
- - - - - Name - Usage - Sync - Actions - - - - {storedProxies.map((proxy) => { - const isCloud = proxy.is_cloud_managed === true; - const syncDot = getSyncStatusDot( - proxy, - proxySyncStatus[proxy.id], - ); - const isDerived = proxy.is_cloud_derived === true; - return ( - - -
+ {isLoading ? ( +
+ Loading proxies... +
+ ) : storedProxies.length === 0 ? ( +
+ No proxies created yet. Create your first proxy using the + button above. +
+ ) : ( +
+ +
+ + + Name + Usage + Sync + Actions + + + + {storedProxies.map((proxy) => { + const syncDot = getSyncStatusDot( + proxy, + proxySyncStatus[proxy.id], + proxySyncErrors[proxy.id], + ); + const isDerived = proxy.is_cloud_derived === true; + return ( + +
{isDerived && proxy.geo_country && ( )} - {!isCloud && !isDerived && ( + {!isDerived && (
- {isCloud && cloudProxyUsage && ( - - {cloudProxyUsage.used_mb} /{" "} - {cloudProxyUsage.limit_mb} MB used - - )} -
- - - - {proxyUsage[proxy.id] ?? 0} - - - - {isCloud ? ( - Cloud - ) : ( + + + + {proxyUsage[proxy.id] ?? 0} + + +
@@ -540,48 +524,50 @@ export function ProxyManagementDialog({ )} - )} - - -
- { - setProxyCheckResults((prev) => ({ - ...prev, - [proxy.id]: result, - })); - }} - onCheckFailed={(result) => { - setProxyCheckResults((prev) => ({ - ...prev, - [proxy.id]: result, - })); - }} - /> - {!isCloud && !isDerived && ( - - - - - -

Edit proxy

-
-
- )} - {!isCloud && ( + + +
+ { + setProxyCheckResults((prev) => ({ + ...prev, + [proxy.id]: result, + })); + }} + onCheckFailed={(result) => { + setProxyCheckResults((prev) => ({ + ...prev, + [proxy.id]: result, + })); + }} + /> + {!isDerived && ( + + + + + +

Edit proxy

+
+
+ )} @@ -613,199 +599,202 @@ export function ProxyManagementDialog({ )} - )} -
-
- - ); - })} - -
-
-
- )} -
- +
+ + + ); + })} + + + +
+ )} +
+ - -
-
-
+ +
+
+
+ setShowVpnImportDialog(true)} + className="flex gap-2 items-center" + > + + Import + +
setShowVpnImportDialog(true)} + onClick={handleCreateVpn} className="flex gap-2 items-center" > - - Import + + Create
- - - Create - -
- {isLoadingVpns ? ( -
- Loading VPNs... -
- ) : vpnConfigs.length === 0 ? ( -
- No VPN configs created yet. Import or create one using the - buttons above. -
- ) : ( -
- - - - - Name - Type - Usage - Sync - Actions - - - - {vpnConfigs.map((vpn) => { - const syncDot = getSyncStatusDot( - vpn, - vpnSyncStatus[vpn.id], - ); - return ( - - -
+ {isLoadingVpns ? ( +
+ Loading VPNs... +
+ ) : vpnConfigs.length === 0 ? ( +
+ No VPN configs created yet. Import or create one using the + buttons above. +
+ ) : ( +
+ +
+ + + Name + Type + Usage + Sync + Actions + + + + {vpnConfigs.map((vpn) => { + const syncDot = getSyncStatusDot( + vpn, + vpnSyncStatus[vpn.id], + vpnSyncErrors[vpn.id], + ); + return ( + + +
+ + +
+ + +

{syncDot.tooltip}

+
+ + {vpn.name} +
+ + + + {vpn.vpn_type === "WireGuard" + ? "WG" + : "OVPN"} + + + + + {vpnUsage[vpn.id] ?? 0} + + + -
- - -

{syncDot.tooltip}

-
- - {vpn.name} -
-
- - - {vpn.vpn_type === "WireGuard" - ? "WG" - : "OVPN"} - - - - - {vpnUsage[vpn.id] ?? 0} - - - - - -
- - handleToggleVpnSync(vpn) - } - disabled={ - isTogglingVpnSync[vpn.id] || - vpnInUse[vpn.id] - } - /> -
-
- - {vpnInUse[vpn.id] ? ( -

- Sync cannot be disabled while this VPN - is used by synced profiles -

- ) : ( -

- {vpn.sync_enabled - ? "Disable sync" - : "Enable sync"} -

- )} -
-
-
- -
- - - - - - -

Edit VPN

-
-
- - - - - + disabled={ + isTogglingVpnSync[vpn.id] || + vpnInUse[vpn.id] + } + /> +
- {(vpnUsage[vpn.id] ?? 0) > 0 ? ( + {vpnInUse[vpn.id] ? (

- Cannot delete: in use by{" "} - {vpnUsage[vpn.id]} profile - {vpnUsage[vpn.id] > 1 ? "s" : ""} + Sync cannot be disabled while this + VPN is used by synced profiles

) : ( -

Delete VPN

+

+ {vpn.sync_enabled + ? "Disable sync" + : "Enable sync"} +

)}
-
-
-
- ); - })} -
-
-
-
- )} -
- - + + +
+ + + + + + +

Edit VPN

+
+
+ + + + + + + + {(vpnUsage[vpn.id] ?? 0) > 0 ? ( +

+ Cannot delete: in use by{" "} + {vpnUsage[vpn.id]} profile + {vpnUsage[vpn.id] > 1 ? "s" : ""} +

+ ) : ( +

Delete VPN

+ )} +
+
+
+
+ + ); + })} + + + +
+ )} +
+
+ + diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 06e2dda..14a2ecc 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -9,6 +9,7 @@ import { BsCamera, BsMic } from "react-icons/bs"; import { LoadingButton } from "@/components/loading-button"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { ColorPicker, ColorPickerAlpha, @@ -60,6 +61,7 @@ interface AppSettings { api_enabled: boolean; api_port: number; api_token?: string; + disable_auto_updates?: boolean; } interface CustomThemeState { @@ -116,6 +118,7 @@ export function SettingsDialog({ const [requestingPermission, setRequestingPermission] = useState(null); const [isMacOS, setIsMacOS] = useState(false); + const [isLinux, setIsLinux] = useState(false); const [hasE2ePassword, setHasE2ePassword] = useState(false); const [e2ePassword, setE2ePassword] = useState(""); const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState(""); @@ -486,6 +489,8 @@ export function SettingsDialog({ const userAgent = navigator.userAgent; const isMac = userAgent.includes("Mac"); setIsMacOS(isMac); + const isLin = !userAgent.includes("Mac") && !userAgent.includes("Win"); + setIsLinux(isLin); if (isMac) { loadPermissions().catch(console.error); @@ -547,7 +552,8 @@ export function SettingsDialog({ JSON.stringify(originalSettings.custom_theme ?? {})) || (settings.theme !== "custom" && JSON.stringify(settings.custom_theme ?? {}) !== - JSON.stringify(originalSettings.custom_theme ?? {})); + JSON.stringify(originalSettings.custom_theme ?? {})) || + settings.disable_auto_updates !== originalSettings.disable_auto_updates; return ( @@ -1028,6 +1034,29 @@ export function SettingsDialog({
+ {!isLinux && ( +
+ + updateSetting("disable_auto_updates", checked as boolean) + } + /> +
+ +

+ {t("settings.disableAutoUpdatesDescription")} +

+
+
+ )} + { diff --git a/src/components/shared-camoufox-config-form.tsx b/src/components/shared-camoufox-config-form.tsx index a140e2b..8f87136 100644 --- a/src/components/shared-camoufox-config-form.tsx +++ b/src/components/shared-camoufox-config-form.tsx @@ -1,7 +1,9 @@ "use client"; +import { invoke } from "@tauri-apps/api/core"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { LoadingButton } from "@/components/loading-button"; import MultipleSelector, { type Option } from "@/components/multiple-selector"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Checkbox } from "@/components/ui/checkbox"; @@ -33,6 +35,8 @@ interface SharedCamoufoxConfigFormProps { browserType?: "camoufox" | "wayfern"; // Browser type to customize form options crossOsUnlocked?: boolean; // Allow selecting non-current OS (paid feature) limitedMode?: boolean; // Blur and disable advanced fields while keeping basic options accessible + profileVersion?: string; + profileBrowser?: string; } // Determine if fingerprint editing should be disabled @@ -124,6 +128,8 @@ export function SharedCamoufoxConfigForm({ browserType = "camoufox", crossOsUnlocked = false, limitedMode = false, + profileVersion, + profileBrowser, }: SharedCamoufoxConfigFormProps) { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState( @@ -132,6 +138,26 @@ export function SharedCamoufoxConfigForm({ const [fingerprintConfig, setFingerprintConfig] = useState({}); const [currentOS] = useState(getCurrentOS); + const [isGeneratingFingerprint, setIsGeneratingFingerprint] = useState(false); + + const handleGenerateFingerprint = async () => { + if (!profileVersion) return; + const browser = profileBrowser || browserType || "camoufox"; + setIsGeneratingFingerprint(true); + try { + const configJson = JSON.stringify(config); + const result = await invoke("generate_sample_fingerprint", { + browser, + version: profileVersion, + configJson, + }); + onConfigChange("fingerprint", result); + } catch (error) { + console.error("Failed to generate fingerprint:", error); + } finally { + setIsGeneratingFingerprint(false); + } + }; // Get selected OS (defaults to current OS) const selectedOS = config.os || currentOS; @@ -223,7 +249,22 @@ export function SharedCamoufoxConfigForm({
{/* Operating System Selection */}
- +
+ + {profileVersion && (!isCreating || crossOsUnlocked) && ( + + {isCreating + ? t("fingerprint.generateFingerprint") + : t("fingerprint.refreshFingerprint")} + + )} +
onConfigChange("os", value)} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c66887a..1da5972 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -134,7 +134,9 @@ "title": "Advanced", "clearCache": "Clear All Version Cache", "clearCacheDescription": "Clear all cached browser version data and refresh all browser versions from their sources. This will force a fresh download of version information for all browsers." - } + }, + "disableAutoUpdates": "Disable Auto Updates", + "disableAutoUpdatesDescription": "Only notify when browser updates are available, without downloading automatically." }, "header": { "searchPlaceholder": "Search profiles...", @@ -189,7 +191,7 @@ }, "createProfile": { "title": "Create New Profile", - "configureTitle": "Configure Profile", + "configureTitle": "Create New {{browser}} Profile", "antiDetect": { "title": "Anti-Detect Browser", "description": "Choose a browser with anti-detection capabilities", @@ -219,7 +221,12 @@ "latestNeedsDownload": "Latest version ({{version}}) needs to be downloaded", "latestAvailable": "Latest version ({{version}}) is available", "latestDownloading": "Downloading version ({{version}})..." - } + }, + "chromiumLabel": "Chromium", + "chromiumSubtitle": "Powered by Wayfern", + "firefoxLabel": "Firefox", + "firefoxSubtitle": "Powered by Camoufox", + "camoufoxWarning": "Firefox (Camoufox) is maintained by a third-party organization. For production use, please use Chromium." }, "deleteDialog": { "title": "Delete Profile", @@ -640,7 +647,9 @@ "productSub": "Product Sub", "brand": "Brand", "brandVersion": "Brand Version", - "proFeature": "This is a Pro feature" + "proFeature": "This is a Pro feature", + "generateFingerprint": "Generate Fingerprint", + "refreshFingerprint": "Refresh Fingerprint" }, "warnings": { "windowResizeTitle": "Custom Window Dimensions", @@ -779,7 +788,25 @@ "assignTitle": "Assign Extension Group", "assignDescription": "Assign {{count}} selected profile(s) to an extension group.", "noGroup": "None (No Extension Group)", - "assignSuccess": "Extension group assigned successfully" + "assignSuccess": "Extension group assigned successfully", + "editExtension": "Edit extension", + "updateSuccess": "Extension updated successfully", + "reupload": "Re-upload", + "version": "Version", + "author": "Author", + "homepage": "Homepage", + "editGroup": "Edit Group", + "editGroupDescription": "Update the group name and manage which extensions are included.", + "groupExtensions": "Extensions in this group", + "noExtensionsInGroup": "No extensions added yet", + "editExtensionDescription": "Update extension name, view metadata, or re-upload the extension file.", + "metadata": "Metadata", + "noMetadata": "No metadata available from manifest.", + "selectFile": "Choose File", + "syncEnabled": "Sync enabled", + "syncDisabled": "Sync disabled", + "syncEnableTooltip": "Enable sync", + "syncDisableTooltip": "Disable sync" }, "pro": { "badge": "PRO", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ced0191..9a3d1c1 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -134,7 +134,9 @@ "title": "Avanzado", "clearCache": "Limpiar Toda la Caché de Versiones", "clearCacheDescription": "Limpia todos los datos de versiones de navegadores en caché y actualiza todas las versiones desde sus fuentes. Esto forzará una descarga nueva de información de versiones para todos los navegadores." - } + }, + "disableAutoUpdates": "Desactivar Actualizaciones Automáticas", + "disableAutoUpdatesDescription": "Evita que la aplicación busque e instale actualizaciones automáticamente. Deberá actualizar la aplicación manualmente." }, "header": { "searchPlaceholder": "Buscar perfiles...", @@ -189,7 +191,7 @@ }, "createProfile": { "title": "Crear Nuevo Perfil", - "configureTitle": "Configurar Perfil", + "configureTitle": "Crear Nuevo Perfil de {{browser}}", "antiDetect": { "title": "Navegador Anti-Detección", "description": "Elige un navegador con capacidades anti-detección", @@ -219,7 +221,12 @@ "latestNeedsDownload": "La última versión ({{version}}) necesita ser descargada", "latestAvailable": "La última versión ({{version}}) está disponible", "latestDownloading": "Descargando versión ({{version}})..." - } + }, + "chromiumLabel": "Chromium", + "chromiumSubtitle": "Impulsado por Wayfern", + "firefoxLabel": "Firefox", + "firefoxSubtitle": "Impulsado por Camoufox", + "camoufoxWarning": "Firefox (Camoufox) está mantenido por una organización de terceros. Para uso en producción, utilice Chromium." }, "deleteDialog": { "title": "Eliminar Perfil", @@ -640,7 +647,9 @@ "productSub": "Product Sub", "brand": "Marca", "brandVersion": "Versión de marca", - "proFeature": "Esta es una función Pro" + "proFeature": "Esta es una función Pro", + "generateFingerprint": "Generar Huella Digital", + "refreshFingerprint": "Actualizar Huella Digital" }, "warnings": { "windowResizeTitle": "Dimensiones de ventana personalizadas", @@ -779,7 +788,25 @@ "assignTitle": "Asignar Grupo de Extensiones", "assignDescription": "Asignar {{count}} perfil(es) seleccionado(s) a un grupo de extensiones.", "noGroup": "Ninguno (Sin Grupo de Extensiones)", - "assignSuccess": "Grupo de extensiones asignado exitosamente" + "assignSuccess": "Grupo de extensiones asignado exitosamente", + "editExtension": "Editar extensión", + "updateSuccess": "Extensión actualizada exitosamente", + "reupload": "Re-subir", + "version": "Versión", + "author": "Autor", + "homepage": "Página de inicio", + "editGroup": "Editar grupo", + "editGroupDescription": "Actualiza el nombre del grupo y gestiona qué extensiones están incluidas.", + "groupExtensions": "Extensiones en este grupo", + "noExtensionsInGroup": "Aún no se han añadido extensiones", + "editExtensionDescription": "Actualizar el nombre de la extensión, ver metadatos o volver a cargar el archivo de extensión.", + "metadata": "Metadatos", + "noMetadata": "No hay metadatos disponibles del manifiesto.", + "selectFile": "Elegir archivo", + "syncEnabled": "Sincronización habilitada", + "syncDisabled": "Sincronización deshabilitada", + "syncEnableTooltip": "Habilitar sincronización", + "syncDisableTooltip": "Deshabilitar sincronización" }, "pro": { "badge": "PRO", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2396f38..5067c53 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -134,7 +134,9 @@ "title": "Avancé", "clearCache": "Effacer tout le cache des versions", "clearCacheDescription": "Efface toutes les données de versions de navigateurs en cache et actualise toutes les versions depuis leurs sources. Cela forcera un nouveau téléchargement des informations de version pour tous les navigateurs." - } + }, + "disableAutoUpdates": "Désactiver les mises à jour automatiques", + "disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour. Vous devrez mettre à jour l'application manuellement." }, "header": { "searchPlaceholder": "Rechercher des profils...", @@ -189,7 +191,7 @@ }, "createProfile": { "title": "Créer un nouveau profil", - "configureTitle": "Configurer le profil", + "configureTitle": "Créer un nouveau profil {{browser}}", "antiDetect": { "title": "Navigateur anti-détection", "description": "Choisissez un navigateur avec des capacités anti-détection", @@ -219,7 +221,12 @@ "latestNeedsDownload": "La dernière version ({{version}}) doit être téléchargée", "latestAvailable": "La dernière version ({{version}}) est disponible", "latestDownloading": "Téléchargement de la version ({{version}})..." - } + }, + "chromiumLabel": "Chromium", + "chromiumSubtitle": "Propulsé par Wayfern", + "firefoxLabel": "Firefox", + "firefoxSubtitle": "Propulsé par Camoufox", + "camoufoxWarning": "Firefox (Camoufox) est maintenu par une organisation tierce. Pour une utilisation en production, veuillez utiliser Chromium." }, "deleteDialog": { "title": "Supprimer le profil", @@ -640,7 +647,9 @@ "productSub": "Product Sub", "brand": "Marque", "brandVersion": "Version de la marque", - "proFeature": "Ceci est une fonctionnalité Pro" + "proFeature": "Ceci est une fonctionnalité Pro", + "generateFingerprint": "Générer l'empreinte", + "refreshFingerprint": "Actualiser l'empreinte" }, "warnings": { "windowResizeTitle": "Dimensions de fenêtre personnalisées", @@ -779,7 +788,25 @@ "assignTitle": "Assigner un Groupe d'Extensions", "assignDescription": "Assigner {{count}} profil(s) sélectionné(s) à un groupe d'extensions.", "noGroup": "Aucun (Pas de Groupe d'Extensions)", - "assignSuccess": "Groupe d'extensions assigné avec succès" + "assignSuccess": "Groupe d'extensions assigné avec succès", + "editExtension": "Modifier l'extension", + "updateSuccess": "Extension mise à jour avec succès", + "reupload": "Re-télécharger", + "version": "Version", + "author": "Auteur", + "homepage": "Page d'accueil", + "editGroup": "Modifier le groupe", + "editGroupDescription": "Mettez à jour le nom du groupe et gérez les extensions incluses.", + "groupExtensions": "Extensions dans ce groupe", + "noExtensionsInGroup": "Aucune extension ajoutée", + "editExtensionDescription": "Modifier le nom de l'extension, voir les métadonnées ou re-télécharger le fichier d'extension.", + "metadata": "Métadonnées", + "noMetadata": "Aucune métadonnée disponible depuis le manifeste.", + "selectFile": "Choisir un fichier", + "syncEnabled": "Synchronisation activée", + "syncDisabled": "Synchronisation désactivée", + "syncEnableTooltip": "Activer la synchronisation", + "syncDisableTooltip": "Désactiver la synchronisation" }, "pro": { "badge": "PRO", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index eae6c82..94f270b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -134,7 +134,9 @@ "title": "詳細設定", "clearCache": "すべてのバージョンキャッシュをクリア", "clearCacheDescription": "キャッシュされたすべてのブラウザバージョンデータをクリアし、すべてのブラウザバージョンをソースから更新します。これにより、すべてのブラウザのバージョン情報が強制的に再ダウンロードされます。" - } + }, + "disableAutoUpdates": "自動更新を無効にする", + "disableAutoUpdatesDescription": "アプリケーションが自動的に更新を確認・インストールすることを防ぎます。手動でアプリケーションを更新する必要があります。" }, "header": { "searchPlaceholder": "プロファイルを検索...", @@ -189,7 +191,7 @@ }, "createProfile": { "title": "新しいプロファイルを作成", - "configureTitle": "プロファイルを設定", + "configureTitle": "新しい{{browser}}プロファイルを作成", "antiDetect": { "title": "アンチ検出ブラウザ", "description": "アンチ検出機能を持つブラウザを選択", @@ -219,7 +221,12 @@ "latestNeedsDownload": "最新バージョン ({{version}}) をダウンロードする必要があります", "latestAvailable": "最新バージョン ({{version}}) は利用可能です", "latestDownloading": "バージョン ({{version}}) をダウンロード中..." - } + }, + "chromiumLabel": "Chromium", + "chromiumSubtitle": "Wayfern搭載", + "firefoxLabel": "Firefox", + "firefoxSubtitle": "Camoufox搭載", + "camoufoxWarning": "Firefox(Camoufox)はサードパーティの組織によって管理されています。本番環境での使用にはChromiumをご利用ください。" }, "deleteDialog": { "title": "プロファイルを削除", @@ -640,7 +647,9 @@ "productSub": "Product Sub", "brand": "ブランド", "brandVersion": "ブランドバージョン", - "proFeature": "これはPro機能です" + "proFeature": "これはPro機能です", + "generateFingerprint": "フィンガープリントを生成", + "refreshFingerprint": "フィンガープリントを更新" }, "warnings": { "windowResizeTitle": "カスタムウィンドウサイズ", @@ -779,7 +788,25 @@ "assignTitle": "拡張機能グループの割り当て", "assignDescription": "選択した{{count}}件のプロファイルを拡張機能グループに割り当てます。", "noGroup": "なし(拡張機能グループなし)", - "assignSuccess": "拡張機能グループが正常に割り当てられました" + "assignSuccess": "拡張機能グループが正常に割り当てられました", + "editExtension": "拡張機能を編集", + "updateSuccess": "拡張機能が正常に更新されました", + "reupload": "再アップロード", + "version": "バージョン", + "author": "作者", + "homepage": "ホームページ", + "editGroup": "グループを編集", + "editGroupDescription": "グループ名を更新し、含まれる拡張機能を管理します。", + "groupExtensions": "このグループの拡張機能", + "noExtensionsInGroup": "拡張機能がまだ追加されていません", + "editExtensionDescription": "拡張機能の名前を更新、メタデータを表示、またはファイルを再アップロードします。", + "metadata": "メタデータ", + "noMetadata": "マニフェストからのメタデータはありません。", + "selectFile": "ファイルを選択", + "syncEnabled": "同期が有効", + "syncDisabled": "同期が無効", + "syncEnableTooltip": "同期を有効にする", + "syncDisableTooltip": "同期を無効にする" }, "pro": { "badge": "PRO", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index bfc7b85..2646221 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -134,7 +134,9 @@ "title": "Avançado", "clearCache": "Limpar Todo o Cache de Versões", "clearCacheDescription": "Limpa todos os dados de versões de navegadores em cache e atualiza todas as versões de suas fontes. Isso forçará um novo download das informações de versão para todos os navegadores." - } + }, + "disableAutoUpdates": "Desativar Atualizações Automáticas", + "disableAutoUpdatesDescription": "Impede que o aplicativo verifique e instale atualizações automaticamente. Você precisará atualizar o aplicativo manualmente." }, "header": { "searchPlaceholder": "Pesquisar perfis...", @@ -189,7 +191,7 @@ }, "createProfile": { "title": "Criar Novo Perfil", - "configureTitle": "Configurar Perfil", + "configureTitle": "Criar Novo Perfil de {{browser}}", "antiDetect": { "title": "Navegador Anti-Detecção", "description": "Escolha um navegador com capacidades anti-detecção", @@ -219,7 +221,12 @@ "latestNeedsDownload": "A versão mais recente ({{version}}) precisa ser baixada", "latestAvailable": "A versão mais recente ({{version}}) está disponível", "latestDownloading": "Baixando versão ({{version}})..." - } + }, + "chromiumLabel": "Chromium", + "chromiumSubtitle": "Desenvolvido com Wayfern", + "firefoxLabel": "Firefox", + "firefoxSubtitle": "Desenvolvido com Camoufox", + "camoufoxWarning": "O Firefox (Camoufox) é mantido por uma organização terceira. Para uso em produção, utilize o Chromium." }, "deleteDialog": { "title": "Excluir Perfil", @@ -640,7 +647,9 @@ "productSub": "Product Sub", "brand": "Marca", "brandVersion": "Versão da Marca", - "proFeature": "Este é um recurso Pro" + "proFeature": "Este é um recurso Pro", + "generateFingerprint": "Gerar Impressão Digital", + "refreshFingerprint": "Atualizar Impressão Digital" }, "warnings": { "windowResizeTitle": "Dimensões de janela personalizadas", @@ -779,7 +788,25 @@ "assignTitle": "Atribuir Grupo de Extensões", "assignDescription": "Atribuir {{count}} perfil(is) selecionado(s) a um grupo de extensões.", "noGroup": "Nenhum (Sem Grupo de Extensões)", - "assignSuccess": "Grupo de extensões atribuído com sucesso" + "assignSuccess": "Grupo de extensões atribuído com sucesso", + "editExtension": "Editar extensão", + "updateSuccess": "Extensão atualizada com sucesso", + "reupload": "Re-enviar", + "version": "Versão", + "author": "Autor", + "homepage": "Página inicial", + "editGroup": "Editar grupo", + "editGroupDescription": "Atualize o nome do grupo e gerencie quais extensões estão incluídas.", + "groupExtensions": "Extensões neste grupo", + "noExtensionsInGroup": "Nenhuma extensão adicionada ainda", + "editExtensionDescription": "Atualizar o nome da extensão, ver metadados ou reenviar o arquivo da extensão.", + "metadata": "Metadados", + "noMetadata": "Nenhum metadado disponível do manifesto.", + "selectFile": "Escolher arquivo", + "syncEnabled": "Sincronização ativada", + "syncDisabled": "Sincronização desativada", + "syncEnableTooltip": "Ativar sincronização", + "syncDisableTooltip": "Desativar sincronização" }, "pro": { "badge": "PRO", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 733fa08..7b7b78c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -134,7 +134,9 @@ "title": "Дополнительно", "clearCache": "Очистить весь кэш версий", "clearCacheDescription": "Очищает все кэшированные данные версий браузеров и обновляет все версии из источников. Это принудительно загрузит информацию о версиях для всех браузеров." - } + }, + "disableAutoUpdates": "Отключить автоматические обновления", + "disableAutoUpdatesDescription": "Запретить приложению автоматически проверять и устанавливать обновления. Вам нужно будет обновлять приложение вручную." }, "header": { "searchPlaceholder": "Поиск профилей...", @@ -189,7 +191,7 @@ }, "createProfile": { "title": "Создать новый профиль", - "configureTitle": "Настроить профиль", + "configureTitle": "Создать новый профиль {{browser}}", "antiDetect": { "title": "Антидетект браузер", "description": "Выберите браузер с возможностями защиты от обнаружения", @@ -219,7 +221,12 @@ "latestNeedsDownload": "Последнюю версию ({{version}}) необходимо скачать", "latestAvailable": "Последняя версия ({{version}}) доступна", "latestDownloading": "Загрузка версии ({{version}})..." - } + }, + "chromiumLabel": "Chromium", + "chromiumSubtitle": "На базе Wayfern", + "firefoxLabel": "Firefox", + "firefoxSubtitle": "На базе Camoufox", + "camoufoxWarning": "Firefox (Camoufox) поддерживается сторонней организацией. Для промышленного использования используйте Chromium." }, "deleteDialog": { "title": "Удалить профиль", @@ -640,7 +647,9 @@ "productSub": "Product Sub", "brand": "Бренд", "brandVersion": "Версия бренда", - "proFeature": "Это функция Pro" + "proFeature": "Это функция Pro", + "generateFingerprint": "Сгенерировать отпечаток", + "refreshFingerprint": "Обновить отпечаток" }, "warnings": { "windowResizeTitle": "Пользовательские размеры окна", @@ -779,7 +788,25 @@ "assignTitle": "Назначить группу расширений", "assignDescription": "Назначить {{count}} выбранных профилей в группу расширений.", "noGroup": "Нет (Без группы расширений)", - "assignSuccess": "Группа расширений успешно назначена" + "assignSuccess": "Группа расширений успешно назначена", + "editExtension": "Редактировать расширение", + "updateSuccess": "Расширение успешно обновлено", + "reupload": "Загрузить заново", + "version": "Версия", + "author": "Автор", + "homepage": "Домашняя страница", + "editGroup": "Редактировать группу", + "editGroupDescription": "Обновите название группы и управляйте включёнными расширениями.", + "groupExtensions": "Расширения в этой группе", + "noExtensionsInGroup": "Расширения ещё не добавлены", + "editExtensionDescription": "Обновите имя расширения, просмотрите метаданные или загрузите файл расширения повторно.", + "metadata": "Метаданные", + "noMetadata": "Метаданные из манифеста недоступны.", + "selectFile": "Выбрать файл", + "syncEnabled": "Синхронизация включена", + "syncDisabled": "Синхронизация отключена", + "syncEnableTooltip": "Включить синхронизацию", + "syncDisableTooltip": "Отключить синхронизацию" }, "pro": { "badge": "PRO", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 47e2f9e..0e67627 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -134,7 +134,9 @@ "title": "高级", "clearCache": "清除所有版本缓存", "clearCacheDescription": "清除所有缓存的浏览器版本数据并从源刷新所有浏览器版本。这将强制重新下载所有浏览器的版本信息。" - } + }, + "disableAutoUpdates": "禁用自动更新", + "disableAutoUpdatesDescription": "阻止应用程序自动检查和安装更新。您需要手动更新应用程序。" }, "header": { "searchPlaceholder": "搜索配置文件...", @@ -189,7 +191,7 @@ }, "createProfile": { "title": "创建新配置文件", - "configureTitle": "配置配置文件", + "configureTitle": "创建新的 {{browser}} 配置文件", "antiDetect": { "title": "防检测浏览器", "description": "选择具有防检测功能的浏览器", @@ -219,7 +221,12 @@ "latestNeedsDownload": "最新版本 ({{version}}) 需要下载", "latestAvailable": "最新版本 ({{version}}) 可用", "latestDownloading": "正在下载版本 ({{version}})..." - } + }, + "chromiumLabel": "Chromium", + "chromiumSubtitle": "由 Wayfern 驱动", + "firefoxLabel": "Firefox", + "firefoxSubtitle": "由 Camoufox 驱动", + "camoufoxWarning": "Firefox(Camoufox)由第三方组织维护。在生产环境中,请使用 Chromium。" }, "deleteDialog": { "title": "删除配置文件", @@ -640,7 +647,9 @@ "productSub": "Product Sub", "brand": "品牌", "brandVersion": "品牌版本", - "proFeature": "这是 Pro 功能" + "proFeature": "这是 Pro 功能", + "generateFingerprint": "生成指纹", + "refreshFingerprint": "刷新指纹" }, "warnings": { "windowResizeTitle": "自定义窗口尺寸", @@ -779,7 +788,25 @@ "assignTitle": "分配扩展程序组", "assignDescription": "将 {{count}} 个选定的配置文件分配到扩展程序组。", "noGroup": "无(不使用扩展程序组)", - "assignSuccess": "扩展程序组分配成功" + "assignSuccess": "扩展程序组分配成功", + "editExtension": "编辑扩展", + "updateSuccess": "扩展更新成功", + "reupload": "重新上传", + "version": "版本", + "author": "作者", + "homepage": "主页", + "editGroup": "编辑分组", + "editGroupDescription": "更新分组名称并管理包含的扩展。", + "groupExtensions": "此分组中的扩展", + "noExtensionsInGroup": "尚未添加扩展", + "editExtensionDescription": "更新扩展名称、查看元数据或重新上传扩展文件。", + "metadata": "元数据", + "noMetadata": "清单中没有可用的元数据。", + "selectFile": "选择文件", + "syncEnabled": "同步已启用", + "syncDisabled": "同步已禁用", + "syncEnableTooltip": "启用同步", + "syncDisableTooltip": "禁用同步" }, "pro": { "badge": "PRO", diff --git a/src/lib/toast-utils.ts b/src/lib/toast-utils.ts index a45ee83..cbd4bc1 100644 --- a/src/lib/toast-utils.ts +++ b/src/lib/toast-utils.ts @@ -48,12 +48,27 @@ interface VersionUpdateToastProps extends BaseToastProps { }; } +interface SyncProgressToastProps extends BaseToastProps { + type: "sync-progress"; + progress?: { + completed_files: number; + total_files: number; + completed_bytes: number; + total_bytes: number; + speed_bytes_per_sec: number; + eta_seconds: number; + failed_count: number; + phase: string; + }; +} + type ToastProps = | SuccessToastProps | ErrorToastProps | DownloadToastProps | LoadingToastProps - | VersionUpdateToastProps; + | VersionUpdateToastProps + | SyncProgressToastProps; export function showToast(props: ToastProps & { id?: string }) { const toastId = props.id ?? `toast-${props.type}-${Date.now()}`; @@ -85,6 +100,9 @@ export function showToast(props: ToastProps & { id?: string }) { case "version-update": duration = 15000; break; + case "sync-progress": + duration = Number.POSITIVE_INFINITY; + break; default: duration = 5000; } @@ -232,28 +250,24 @@ export function dismissToast(id: string) { sonnerToast.dismiss(id); } -function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; - const units = ["B", "KB", "MB", "GB"]; - const i = Math.min( - Math.floor(Math.log(bytes) / Math.log(1024)), - units.length - 1, - ); - const value = bytes / 1024 ** i; - return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`; -} - export function showSyncProgressToast( profileName: string, - totalFiles: number, - totalBytes: number, + progress: { + completed_files: number; + total_files: number; + completed_bytes: number; + total_bytes: number; + speed_bytes_per_sec: number; + eta_seconds: number; + failed_count: number; + phase: string; + }, options?: { id?: string }, ) { - const description = `${totalFiles} files (${formatBytes(totalBytes)})`; return showToast({ - type: "loading", + type: "sync-progress", title: `Syncing profile '${profileName}'...`, - description, + progress, id: options?.id, duration: Number.POSITIVE_INFINITY, onCancel: () => { diff --git a/src/types.ts b/src/types.ts index 9005a5d..c0b6ef9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,6 +47,10 @@ export interface Extension { updated_at: number; sync_enabled?: boolean; last_sync?: number; + version?: string; + description?: string; + author?: string; + homepage_url?: string; } export interface ExtensionGroup { @@ -127,7 +131,9 @@ export interface StoredProxy { is_cloud_derived?: boolean; geo_country?: string; geo_state?: string; + geo_region?: string; geo_city?: string; + geo_isp?: string; } export interface LocationItem {