From 5bea6a32e06ab3b23476c9df9dffbed44a2f87b0 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:00:04 +0400 Subject: [PATCH] feat: synchronizer --- .vscode/settings.json | 7 + src-tauri/Cargo.lock | 39 +- src-tauri/Cargo.toml | 5 +- src-tauri/src/api_server.rs | 86 +- src-tauri/src/browser_runner.rs | 32 +- src-tauri/src/camoufox_manager.rs | 74 -- src-tauri/src/cloud_auth.rs | 6 +- src-tauri/src/cookie_manager.rs | 211 +++- src-tauri/src/downloader.rs | 173 ---- src-tauri/src/lib.rs | 75 +- src-tauri/src/mcp_server.rs | 458 +++++++-- src-tauri/src/proxy_manager.rs | 279 ++++++ src-tauri/src/sync/client.rs | 31 +- src-tauri/src/sync/engine.rs | 232 ++++- src-tauri/src/sync/manifest.rs | 59 ++ src-tauri/src/sync/scheduler.rs | 187 ++-- src-tauri/src/synchronizer.rs | 1017 ++++++++++++++++++++ src-tauri/src/wayfern_manager.rs | 28 +- src/app/page.tsx | 35 +- src/components/group-management-dialog.tsx | 274 +++--- src/components/home-header.tsx | 193 +++- src/components/profile-data-table.tsx | 156 ++- src/components/profile-info-dialog.tsx | 11 + src/components/proxy-assignment-dialog.tsx | 52 +- src/components/proxy-check-button.tsx | 4 +- src/components/proxy-form-dialog.tsx | 486 +++++++--- src/components/proxy-management-dialog.tsx | 106 +- src/components/sync-follower-dialog.tsx | 217 +++++ src/hooks/use-sync-session.ts | 89 ++ src/i18n/locales/en.json | 36 + src/i18n/locales/es.json | 36 + src/i18n/locales/fr.json | 36 + src/i18n/locales/ja.json | 36 + src/i18n/locales/pt.json | 36 + src/i18n/locales/ru.json | 36 + src/i18n/locales/zh.json | 36 + src/types.ts | 16 + tailwind.config.js | 10 + 38 files changed, 3943 insertions(+), 957 deletions(-) create mode 100644 src-tauri/src/synchronizer.rs create mode 100644 src/components/sync-follower-dialog.tsx create mode 100644 src/hooks/use-sync-session.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 17f15a4..8c1e8f1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,6 +13,7 @@ "autoconfig", "autologin", "biomejs", + "boringtun", "breezedark", "browserforge", "busctl", @@ -42,6 +43,7 @@ "DBAPI", "dconf", "debuginfo", + "desynced", "devedition", "direnv", "distro", @@ -170,9 +172,11 @@ "repogen", "reportingpolicy", "reqwest", + "resvg", "ridedott", "rlib", "rsplit", + "rusqlite", "rustc", "rwxr", "SARIF", @@ -192,6 +196,7 @@ "signon", "signum", "sklearn", + "smoltcp", "SMTO", "sonner", "splitn", @@ -212,6 +217,7 @@ "TERX", "testpass", "testuser", + "thiserror", "timedatectl", "titlebar", "tkinter", @@ -219,6 +225,7 @@ "tqdm", "trackingprotection", "trailhead", + "tungstenite", "turbopack", "turtledemo", "typer", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7036934..ecbf6f9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -652,6 +652,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -932,6 +941,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.57" @@ -1690,6 +1708,7 @@ dependencies = [ name = "donutbrowser" version = "0.16.1" dependencies = [ + "aes", "aes-gcm", "argon2", "async-socks5", @@ -1699,6 +1718,7 @@ dependencies = [ "blake3", "boringtun", "bzip2 0.6.1", + "cbc", "chrono", "chrono-tz", "clap", @@ -1717,7 +1737,6 @@ dependencies = [ "lazy_static", "libc", "log", - "lz4_flex", "lzma-rs", "maxminddb", "mime_guess", @@ -1727,6 +1746,7 @@ dependencies = [ "objc2", "objc2-app-kit", "once_cell", + "pbkdf2", "playwright", "quick-xml 0.39.2", "rand 0.9.2", @@ -1738,6 +1758,7 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", + "sha1", "smoltcp", "sys-locale", "sysinfo", @@ -3167,6 +3188,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -3571,15 +3593,6 @@ dependencies = [ "imgref", ] -[[package]] -name = "lz4_flex" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" -dependencies = [ - "twox-hash", -] - [[package]] name = "lzma-rs" version = "0.3.0" @@ -7420,12 +7433,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "twox-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" - [[package]] name = "typed-path" version = "0.12.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d91cc51..a5d1145 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -81,6 +81,10 @@ utoipa = { version = "5", features = ["axum_extras", "chrono"] } utoipa-axum = "0.2" argon2 = "0.5" aes-gcm = "0.10" +aes = "0.8" +cbc = "0.1" +pbkdf2 = "0.12" +sha1 = "0.10" hyper = { version = "1.8", features = ["full"] } hyper-util = { version = "0.1", features = ["full"] } http-body-util = "0.1" @@ -101,7 +105,6 @@ maxminddb = "0.27" quick-xml = { version = "0.39", features = ["serialize"] } # VPN support -lz4_flex = "0.11" boringtun = "0.7" smoltcp = { version = "0.11", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] } diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 6958c80..f9ac822 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -111,13 +111,17 @@ struct ApiProxyResponse { name: String, #[schema(value_type = Object)] proxy_settings: ProxySettings, + dynamic_proxy_url: Option, + dynamic_proxy_format: Option, } #[derive(Debug, Deserialize, ToSchema)] struct CreateProxyRequest { name: String, #[schema(value_type = Object)] - proxy_settings: ProxySettings, + proxy_settings: Option, + dynamic_proxy_url: Option, + dynamic_proxy_format: Option, } #[derive(Debug, Deserialize, ToSchema)] @@ -125,6 +129,8 @@ struct UpdateProxyRequest { name: Option, #[schema(value_type = Object)] proxy_settings: Option, + dynamic_proxy_url: Option, + dynamic_proxy_format: Option, } #[derive(Debug, Deserialize, ToSchema)] @@ -1028,6 +1034,8 @@ async fn get_proxies( .map(|p| ApiProxyResponse { id: p.id, name: p.name, + dynamic_proxy_url: p.dynamic_proxy_url, + dynamic_proxy_format: p.dynamic_proxy_format, proxy_settings: p.proxy_settings, }) .collect(), @@ -1061,6 +1069,8 @@ async fn get_proxy( id: proxy.id, name: proxy.name, proxy_settings: proxy.proxy_settings, + dynamic_proxy_url: proxy.dynamic_proxy_url, + dynamic_proxy_format: proxy.dynamic_proxy_format, })) } else { Err(StatusCode::NOT_FOUND) @@ -1086,14 +1096,27 @@ async fn create_proxy( State(state): State, Json(request): Json, ) -> Result, StatusCode> { - match PROXY_MANAGER.create_stored_proxy( - &state.app_handle, - request.name.clone(), - request.proxy_settings, - ) { + let result = if let (Some(url), Some(format)) = + (&request.dynamic_proxy_url, &request.dynamic_proxy_format) + { + PROXY_MANAGER.create_dynamic_proxy( + &state.app_handle, + request.name.clone(), + url.clone(), + format.clone(), + ) + } else if let Some(settings) = request.proxy_settings { + PROXY_MANAGER.create_stored_proxy(&state.app_handle, request.name.clone(), settings) + } else { + return Err(StatusCode::BAD_REQUEST); + }; + + match result { Ok(proxy) => Ok(Json(ApiProxyResponse { id: proxy.id, name: proxy.name, + dynamic_proxy_url: proxy.dynamic_proxy_url, + dynamic_proxy_format: proxy.dynamic_proxy_format, proxy_settings: proxy.proxy_settings, })), Err(_) => Err(StatusCode::BAD_REQUEST), @@ -1124,28 +1147,29 @@ async fn update_proxy( State(state): State, Json(request): Json, ) -> Result, StatusCode> { - let proxies = PROXY_MANAGER.get_stored_proxies(); - if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) { - let new_name = request.name.unwrap_or(proxy.name.clone()); - let new_proxy_settings = request - .proxy_settings - .unwrap_or(proxy.proxy_settings.clone()); + let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(&id) || request.dynamic_proxy_url.is_some(); - match PROXY_MANAGER.update_stored_proxy( + let result = if is_dynamic { + PROXY_MANAGER.update_dynamic_proxy( &state.app_handle, &id, - Some(new_name.clone()), - Some(new_proxy_settings.clone()), - ) { - Ok(_) => Ok(Json(ApiProxyResponse { - id, - name: new_name, - proxy_settings: new_proxy_settings, - })), - Err(_) => Err(StatusCode::BAD_REQUEST), - } + request.name, + request.dynamic_proxy_url, + request.dynamic_proxy_format, + ) } else { - Err(StatusCode::NOT_FOUND) + PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings) + }; + + match result { + Ok(proxy) => Ok(Json(ApiProxyResponse { + id: proxy.id, + name: proxy.name, + dynamic_proxy_url: proxy.dynamic_proxy_url, + dynamic_proxy_format: proxy.dynamic_proxy_format, + proxy_settings: proxy.proxy_settings, + })), + Err(_) => Err(StatusCode::NOT_FOUND), } } @@ -1289,6 +1313,13 @@ async fn run_profile( State(state): State, Json(request): Json, ) -> Result, StatusCode> { + if !crate::cloud_auth::CLOUD_AUTH + .has_active_paid_subscription() + .await + { + return Err(StatusCode::PAYMENT_REQUIRED); + } + let headless = request.headless.unwrap_or(false); let url = request.url; @@ -1357,6 +1388,13 @@ async fn open_url_in_profile( State(state): State, Json(request): Json, ) -> Result { + if !crate::cloud_auth::CLOUD_AUTH + .has_active_paid_subscription() + .await + { + return Err(StatusCode::PAYMENT_REQUIRED); + } + let browser_runner = crate::browser_runner::BrowserRunner::instance(); browser_runner diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 1e8b5b8..5426c64 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -40,12 +40,25 @@ impl BrowserRunner { /// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy, /// then resolve the proxy settings with profile-specific sid for sticky sessions. + /// Resolve proxy settings for a profile, returning an error for dynamic proxy failures. + /// Returns Ok(None) when no proxy is configured, Ok(Some) on success, Err on dynamic fetch failure. async fn resolve_proxy_with_refresh( &self, proxy_id: Option<&String>, profile_id: Option<&str>, - ) -> Option { - let proxy_id = proxy_id?; + ) -> Result, String> { + let proxy_id = match proxy_id { + Some(id) => id, + None => return Ok(None), + }; + + // Handle dynamic proxies: fetch from URL at launch time + if PROXY_MANAGER.is_dynamic_proxy(proxy_id) { + log::info!("Fetching dynamic proxy settings for proxy {proxy_id}"); + let settings = PROXY_MANAGER.resolve_dynamic_proxy(proxy_id).await?; + return Ok(Some(settings)); + } + if PROXY_MANAGER.is_cloud_or_derived(proxy_id) { log::info!("Refreshing cloud proxy credentials before launch for proxy {proxy_id}"); CLOUD_AUTH.sync_cloud_proxy().await; @@ -53,10 +66,10 @@ impl BrowserRunner { // 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); + return Ok(PROXY_MANAGER.resolve_proxy_for_profile(proxy_id, pid)); } } - PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) + Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id)) } /// Get the executable path for a browser profile @@ -117,7 +130,8 @@ impl BrowserRunner { // Refresh cloud proxy credentials if needed before resolving let mut upstream_proxy = self .resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string())) - .await; + .await + .map_err(|e| -> Box { e.into() })?; // If profile has a VPN instead of proxy, start VPN worker and use it as upstream if upstream_proxy.is_none() { @@ -375,7 +389,8 @@ impl BrowserRunner { // Refresh cloud proxy credentials if needed before resolving let mut upstream_proxy = self .resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string())) - .await; + .await + .map_err(|e| -> Box { e.into() })?; // If profile has a VPN instead of proxy, start VPN worker and use it as upstream if upstream_proxy.is_none() { @@ -728,7 +743,8 @@ impl BrowserRunner { // 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; + .await + .map_err(|e| -> Box { e.into() })?; // Use a temporary PID (1) to start the proxy, we'll update it after browser launch let temp_pid = 1u32; @@ -2231,7 +2247,7 @@ pub async fn launch_browser_profile( profile_for_launch.proxy_id.as_ref(), Some(&profile_for_launch.id.to_string()), ) - .await; + .await?; // If profile has a VPN instead of proxy, start VPN worker and use it as upstream if upstream_proxy.is_none() { diff --git a/src-tauri/src/camoufox_manager.rs b/src-tauri/src/camoufox_manager.rs index 4109710..c64032f 100644 --- a/src-tauri/src/camoufox_manager.rs +++ b/src-tauri/src/camoufox_manager.rs @@ -685,9 +685,6 @@ impl CamoufoxManager { } } - // Write search.json.mozlz4 with default search engines (DuckDuckGo + Google) - write_default_search_config(&profile_path); - self .launch_camoufox( &app_handle, @@ -701,77 +698,6 @@ impl CamoufoxManager { } } -fn write_default_search_config(profile_path: &std::path::Path) { - let search_file = profile_path.join("search.json.mozlz4"); - if search_file.exists() { - return; - } - - let json = serde_json::json!({ - "version": 6, - "engines": [ - { - "_name": "DuckDuckGo", - "_isAppProvided": false, - "_metaData": { "order": 1 }, - "_urls": [ - { - "template": "https://duckduckgo.com/?q={searchTerms}", - "type": "text/html", - "params": [] - }, - { - "template": "https://duckduckgo.com/ac/?q={searchTerms}&type=list", - "type": "application/x-suggestions+json", - "params": [] - } - ], - "_iconURL": "https://duckduckgo.com/favicon.ico" - }, - { - "_name": "Google", - "_isAppProvided": false, - "_metaData": { "order": 2 }, - "_urls": [ - { - "template": "https://www.google.com/search?q={searchTerms}", - "type": "text/html", - "params": [] - }, - { - "template": "https://www.google.com/complete/search?client=firefox&q={searchTerms}", - "type": "application/x-suggestions+json", - "params": [] - } - ], - "_iconURL": "https://www.google.com/favicon.ico" - } - ], - "metaData": { - "useSavedOrder": false, - "defaultEngineId": "DuckDuckGo" - } - }); - - let json_bytes = match serde_json::to_vec(&json) { - Ok(bytes) => bytes, - Err(e) => { - log::warn!("Failed to serialize search config: {e}"); - return; - } - }; - - let magic = b"mozLz40\0"; - let compressed = lz4_flex::block::compress_prepend_size(&json_bytes); - let mut output = Vec::with_capacity(magic.len() + compressed.len()); - output.extend_from_slice(magic); - output.extend_from_slice(&compressed); - - if let Err(e) = std::fs::write(&search_file, &output) { - log::warn!("Failed to write search.json.mozlz4: {e}"); - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index 8567697..98ee65b 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -1080,8 +1080,8 @@ 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 { + // Refresh wayfern token every 10 hours (60 iterations of 10-minute loop) + if wayfern_refresh_counter >= 60 { wayfern_refresh_counter = 0; if CLOUD_AUTH.has_active_paid_subscription().await { if let Err(e) = CLOUD_AUTH.request_wayfern_token().await { @@ -1390,7 +1390,7 @@ pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), St } } Err(e) => { - log::debug!("Sync not configured, skipping missing profile check: {}", e); + log::warn!("Sync not configured, skipping missing profile check: {}", e); } } diff --git a/src-tauri/src/cookie_manager.rs b/src-tauri/src/cookie_manager.rs index 085e816..fe28eaa 100644 --- a/src-tauri/src/cookie_manager.rs +++ b/src-tauri/src/cookie_manager.rs @@ -7,6 +7,112 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use tauri::AppHandle; +/// Chromium cookie encryption/decryption support. +/// On macOS: uses "Chromium Safe Storage" key from Keychain with PBKDF2 + AES-128-CBC. +/// On Linux: uses os_crypt_key file from profile directory with PBKDF2 + AES-128-CBC. +mod chrome_decrypt { + use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; + use std::path::Path; + + type Aes128CbcDec = cbc::Decryptor; + type Aes128CbcEnc = cbc::Encryptor; + + const PBKDF2_ITERATIONS: u32 = 1; + const KEY_LEN: usize = 16; // AES-128 + const SALT: &[u8] = b"saltysalt"; + const IV: [u8; 16] = [b' '; 16]; // 16 spaces + + fn derive_key(password: &[u8]) -> [u8; KEY_LEN] { + let mut key = [0u8; KEY_LEN]; + pbkdf2::pbkdf2_hmac::(password, SALT, PBKDF2_ITERATIONS, &mut key); + key + } + + /// Get the encryption key for Chrome cookies. + /// Wayfern stores os_crypt_key as a file inside the profile's user-data-dir on all platforms. + /// On macOS/Linux the key is a base64 string used as PBKDF2 password. + /// On Windows the key is raw bytes (32 bytes) used directly. + pub fn get_encryption_key(profile_data_path: &Path) -> Option<[u8; KEY_LEN]> { + let key_file = profile_data_path.join("os_crypt_key"); + if let Ok(contents) = std::fs::read_to_string(&key_file) { + let contents = contents.trim(); + if !contents.is_empty() { + return Some(derive_key(contents.as_bytes())); + } + } + + // Fallback for macOS: try system Keychain (for profiles created before file-based keys) + #[cfg(target_os = "macos")] + { + let output = std::process::Command::new("security") + .args([ + "find-generic-password", + "-w", + "-s", + "Chromium Safe Storage", + "-a", + "Chromium", + ]) + .output() + .ok()?; + if output.status.success() { + let password = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !password.is_empty() { + return Some(derive_key(password.as_bytes())); + } + } + } + + None + } + + /// Decrypt a Chrome encrypted cookie value. + /// Chromium prefixes encrypted values with "v10" (macOS) or "v11" (Linux). + pub fn decrypt(encrypted: &[u8], key: &[u8; KEY_LEN]) -> Option { + if encrypted.len() < 3 { + return None; + } + // Check for v10/v11 prefix + let prefix = &encrypted[..3]; + if prefix != b"v10" && prefix != b"v11" { + return None; + } + let ciphertext = &encrypted[3..]; + if ciphertext.is_empty() { + return Some(String::new()); + } + + let mut buf = ciphertext.to_vec(); + let decrypted = Aes128CbcDec::new(key.into(), &IV.into()) + .decrypt_padded_mut::(&mut buf) + .ok()?; + + String::from_utf8(decrypted.to_vec()).ok() + } + + /// Encrypt a cookie value in Chrome format (v10/v11 prefix + AES-128-CBC). + pub fn encrypt(plaintext: &str, key: &[u8; KEY_LEN]) -> Vec { + let pt = plaintext.as_bytes(); + let block_size = 16usize; + // Allocate buffer with space for PKCS7 padding (up to one extra block) + let padded_len = pt.len() + (block_size - pt.len() % block_size); + let mut buf = vec![0u8; padded_len]; + buf[..pt.len()].copy_from_slice(pt); + + let encrypted = Aes128CbcEnc::new(key.into(), &IV.into()) + .encrypt_padded_mut::(&mut buf, pt.len()) + .expect("encryption buffer too small"); + + let mut result = Vec::with_capacity(3 + encrypted.len()); + #[cfg(target_os = "macos")] + result.extend_from_slice(b"v10"); + #[cfg(not(target_os = "macos"))] + result.extend_from_slice(b"v11"); + result.extend_from_slice(encrypted); + result + } +} + /// Unified cookie representation that works across both browser types #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UnifiedCookie { @@ -77,6 +183,12 @@ impl CookieManager { /// Windows epoch offset: seconds between 1601-01-01 and 1970-01-01 const WINDOWS_EPOCH_DIFF: i64 = 11644473600; + /// Get the Chrome cookie encryption key for a Wayfern profile + fn get_chrome_encryption_key(profile: &BrowserProfile, profiles_dir: &Path) -> Option<[u8; 16]> { + let profile_data_path = profile.get_profile_data_path(profiles_dir); + chrome_decrypt::get_encryption_key(&profile_data_path) + } + /// Get the cookie database path for a profile fn get_cookie_db_path(profile: &BrowserProfile, profiles_dir: &Path) -> Result { let profile_data_path = profile.get_profile_data_path(profiles_dir); @@ -155,31 +267,58 @@ impl CookieManager { Ok(cookies) } - /// Read cookies from a Chrome/Wayfern profile - fn read_chrome_cookies(db_path: &Path) -> Result, String> { + /// Read cookies from a Chrome/Wayfern profile. + /// Handles encrypted cookies by decrypting encrypted_value using the profile's encryption key. + fn read_chrome_cookies( + db_path: &Path, + encryption_key: Option<&[u8; 16]>, + ) -> Result, String> { let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?; let mut stmt = conn .prepare( "SELECT name, value, host_key, path, expires_utc, is_secure, - is_httponly, samesite, creation_utc, last_access_utc - FROM cookies", + is_httponly, samesite, creation_utc, last_access_utc, encrypted_value + FROM cookies", ) .map_err(|e| format!("Failed to prepare statement: {e}"))?; let cookies = stmt .query_map([], |row| { + let name: String = row.get(0)?; + let plaintext_value: String = row.get(1)?; + let domain: String = row.get(2)?; + let path: String = row.get(3)?; + let expires_utc: i64 = row.get(4)?; + let is_secure: i32 = row.get(5)?; + let is_httponly: i32 = row.get(6)?; + let samesite: i32 = row.get(7)?; + let creation_utc: i64 = row.get(8)?; + let last_access_utc: i64 = row.get(9)?; + let encrypted_value: Vec = row.get(10)?; + + // Use plaintext value if available, otherwise decrypt encrypted_value + let value = if !plaintext_value.is_empty() { + plaintext_value + } else if !encrypted_value.is_empty() { + encryption_key + .and_then(|key| chrome_decrypt::decrypt(&encrypted_value, key)) + .unwrap_or_default() + } else { + String::new() + }; + Ok(UnifiedCookie { - name: row.get(0)?, - value: row.get(1)?, - domain: row.get(2)?, - path: row.get(3)?, - expires: Self::chrome_time_to_unix(row.get(4)?), - is_secure: row.get::<_, i32>(5)? != 0, - is_http_only: row.get::<_, i32>(6)? != 0, - same_site: row.get(7)?, - creation_time: Self::chrome_time_to_unix(row.get(8)?), - last_accessed: Self::chrome_time_to_unix(row.get(9)?), + name, + value, + domain, + path, + expires: Self::chrome_time_to_unix(expires_utc), + is_secure: is_secure != 0, + is_http_only: is_httponly != 0, + same_site: samesite, + creation_time: Self::chrome_time_to_unix(creation_utc), + last_accessed: Self::chrome_time_to_unix(last_access_utc), }) }) .map_err(|e| format!("Failed to query cookies: {e}"))? @@ -256,10 +395,12 @@ impl CookieManager { Ok((copied, replaced)) } - /// Write cookies to a Chrome/Wayfern profile + /// Write cookies to a Chrome/Wayfern profile. + /// If an encryption key is available, stores cookies encrypted in encrypted_value. fn write_chrome_cookies( db_path: &Path, cookies: &[UnifiedCookie], + encryption_key: Option<&[u8; 16]>, ) -> Result<(usize, usize), String> { let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?; @@ -272,6 +413,12 @@ impl CookieManager { .as_secs() as i64; for cookie in cookies { + // Prepare value/encrypted_value based on whether we have an encryption key + let (value_str, encrypted_bytes): (&str, Vec) = match encryption_key { + Some(key) => ("", chrome_decrypt::encrypt(&cookie.value, key)), + None => (cookie.value.as_str(), Vec::new()), + }; + let existing: Option = conn .query_row( "SELECT rowid FROM cookies WHERE host_key = ?1 AND name = ?2 AND path = ?3", @@ -283,11 +430,12 @@ impl CookieManager { if existing.is_some() { conn .execute( - "UPDATE cookies SET value = ?1, expires_utc = ?2, is_secure = ?3, - is_httponly = ?4, samesite = ?5, last_access_utc = ?6, last_update_utc = ?7 - WHERE host_key = ?8 AND name = ?9 AND path = ?10", + "UPDATE cookies SET value = ?1, encrypted_value = ?2, expires_utc = ?3, is_secure = ?4, + is_httponly = ?5, samesite = ?6, last_access_utc = ?7, last_update_utc = ?8 + WHERE host_key = ?9 AND name = ?10 AND path = ?11", params![ - &cookie.value, + value_str, + encrypted_bytes, Self::unix_to_chrome_time(cookie.expires), cookie.is_secure as i32, cookie.is_http_only as i32, @@ -308,12 +456,13 @@ impl CookieManager { path, expires_utc, is_secure, is_httponly, last_access_utc, has_expires, is_persistent, priority, samesite, source_scheme, source_port, source_type, has_cross_site_ancestor, last_update_utc) - VALUES (?1, ?2, '', ?3, ?4, X'', ?5, ?6, ?7, ?8, ?9, 1, 1, 1, ?10, 2, -1, 0, 0, ?11)", + VALUES (?1, ?2, '', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 1, 1, 1, ?11, 2, -1, 0, 0, ?12)", params![ Self::unix_to_chrome_time(cookie.creation_time), &cookie.domain, &cookie.name, - &cookie.value, + value_str, + encrypted_bytes, &cookie.path, Self::unix_to_chrome_time(cookie.expires), cookie.is_secure as i32, @@ -348,7 +497,10 @@ impl CookieManager { let cookies = match profile.browser.as_str() { "camoufox" => Self::read_firefox_cookies(&db_path)?, - "wayfern" => Self::read_chrome_cookies(&db_path)?, + "wayfern" => { + let key = Self::get_chrome_encryption_key(profile, &profiles_dir); + Self::read_chrome_cookies(&db_path, key.as_ref())? + } _ => return Err(format!("Unsupported browser type: {}", profile.browser)), }; @@ -401,7 +553,10 @@ impl CookieManager { let source_db_path = Self::get_cookie_db_path(source, &profiles_dir)?; let all_cookies = match source.browser.as_str() { "camoufox" => Self::read_firefox_cookies(&source_db_path)?, - "wayfern" => Self::read_chrome_cookies(&source_db_path)?, + "wayfern" => { + let key = Self::get_chrome_encryption_key(source, &profiles_dir); + Self::read_chrome_cookies(&source_db_path, key.as_ref())? + } _ => return Err(format!("Unsupported browser type: {}", source.browser)), }; @@ -468,7 +623,10 @@ impl CookieManager { let write_result = match target.browser.as_str() { "camoufox" => Self::write_firefox_cookies(&target_db_path, &cookies_to_copy), - "wayfern" => Self::write_chrome_cookies(&target_db_path, &cookies_to_copy), + "wayfern" => { + let key = Self::get_chrome_encryption_key(target, &profiles_dir); + Self::write_chrome_cookies(&target_db_path, &cookies_to_copy, key.as_ref()) + } _ => { results.push(CookieCopyResult { target_profile_id: target_id.clone(), @@ -733,7 +891,10 @@ impl CookieManager { let write_result = match profile.browser.as_str() { "camoufox" => Self::write_firefox_cookies(&db_path, &cookies), - "wayfern" => Self::write_chrome_cookies(&db_path, &cookies), + "wayfern" => { + let key = Self::get_chrome_encryption_key(profile, &profiles_dir); + Self::write_chrome_cookies(&db_path, &cookies, key.as_ref()) + } _ => return Err(format!("Unsupported browser type: {}", profile.browser)), }; diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index 296d1fc..eba4622 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -292,13 +292,6 @@ impl Downloader { Ok(()) } - fn configure_camoufox_search_engine( - &self, - browser_dir: &Path, - ) -> Result<(), Box> { - configure_camoufox_search_engine(browser_dir) - } - pub async fn download_browser( &self, _app_handle: &tauri::AppHandle, @@ -850,10 +843,6 @@ impl Downloader { { log::warn!("Failed to create version.json for Camoufox: {e}"); } - - if let Err(e) = self.configure_camoufox_search_engine(&browser_dir) { - log::warn!("Failed to configure Camoufox search engine: {e}"); - } } // Emit completion @@ -948,168 +937,6 @@ pub async fn cancel_download(browser_str: String, version: String) -> Result<(), } } -/// Find all candidate `distribution/` directories inside the Camoufox browser dir. -/// On macOS: `/.app/Contents/Resources/distribution/` -/// On Linux: `/camoufox/distribution/` -/// On Windows: `/distribution/` -/// Also includes `/distribution/` as a fallback for all platforms. -#[allow(clippy::vec_init_then_push)] -fn find_camoufox_distribution_dirs(browser_dir: &Path) -> Vec { - let mut dirs = Vec::new(); - - #[cfg(target_os = "macos")] - { - if let Ok(entries) = std::fs::read_dir(browser_dir) { - for entry in entries.flatten() { - if entry.path().extension().is_some_and(|ext| ext == "app") { - dirs.push( - entry - .path() - .join("Contents") - .join("Resources") - .join("distribution"), - ); - } - } - } - } - - #[cfg(target_os = "linux")] - { - dirs.push(browser_dir.join("camoufox").join("distribution")); - } - - // Fallback for all platforms - dirs.push(browser_dir.join("distribution")); - - dirs -} - -/// Set DuckDuckGo as the default search engine in Camoufox. -/// Creates or updates distribution/policies.json with a proper DuckDuckGo engine definition. -/// Called both at download time and at launch time to cover existing installations. -pub fn configure_camoufox_search_engine( - browser_dir: &Path, -) -> Result<(), Box> { - let distribution_dirs = find_camoufox_distribution_dirs(browser_dir); - - // Find an existing policies.json, or pick the first candidate dir to create one - let (policies_path, mut policies) = { - let mut found = None; - for dir in &distribution_dirs { - let path = dir.join("policies.json"); - if path.exists() { - if let Ok(content) = std::fs::read_to_string(&path) { - if let Ok(val) = serde_json::from_str::(&content) { - found = Some((path, val)); - break; - } - } - } - } - match found { - Some(f) => f, - None => { - // Pick the first candidate directory that exists (or can be created) - let target_dir = distribution_dirs - .iter() - .find(|d| d.parent().is_some_and(|p| p.exists())) - .or(distribution_dirs.first()) - .ok_or("No suitable distribution directory found")?; - std::fs::create_dir_all(target_dir)?; - ( - target_dir.join("policies.json"), - serde_json::json!({"policies": {}}), - ) - } - } - }; - - // Check if already configured - let has_ddg_default = policies - .get("policies") - .and_then(|p| p.get("SearchEngines")) - .and_then(|se| se.get("Default")) - .and_then(|d| d.as_str()) - == Some("DuckDuckGo"); - - let has_ddg_engine = policies - .get("policies") - .and_then(|p| p.get("SearchEngines")) - .and_then(|se| se.get("Add")) - .and_then(|a| a.as_array()) - .is_some_and(|arr| { - arr - .iter() - .any(|e| e.get("Name").and_then(|n| n.as_str()) == Some("DuckDuckGo")) - }); - - if has_ddg_default && has_ddg_engine { - return Ok(()); - } - - let ddg_engine = serde_json::json!({ - "Name": "DuckDuckGo", - "URLTemplate": "https://duckduckgo.com/?q={searchTerms}", - "SuggestURLTemplate": "https://duckduckgo.com/ac/?q={searchTerms}&type=list", - "Method": "GET", - "IconURL": "https://duckduckgo.com/favicon.ico", - "Alias": "ddg" - }); - - // Ensure policies.SearchEngines exists - let policies_obj = policies - .as_object_mut() - .ok_or("Invalid policies.json")? - .entry("policies") - .or_insert(serde_json::json!({})); - let se = policies_obj - .as_object_mut() - .ok_or("Invalid policies object")? - .entry("SearchEngines") - .or_insert(serde_json::json!({})); - - if let Some(se_obj) = se.as_object_mut() { - // Set DuckDuckGo as default - se_obj.insert( - "Default".to_string(), - serde_json::Value::String("DuckDuckGo".to_string()), - ); - - // Add DuckDuckGo engine definition if not present - let add_arr = se_obj - .entry("Add") - .or_insert(serde_json::json!([])) - .as_array_mut() - .ok_or("SearchEngines.Add is not an array")?; - - // Remove fake "None" engine - add_arr.retain(|entry| entry.get("Name").and_then(|n| n.as_str()) != Some("None")); - - // Add DuckDuckGo if not already present - if !add_arr - .iter() - .any(|e| e.get("Name").and_then(|n| n.as_str()) == Some("DuckDuckGo")) - { - add_arr.push(ddg_engine); - } - - // Ensure DuckDuckGo is not in the Remove list - if let Some(remove_arr) = se_obj.get_mut("Remove").and_then(|r| r.as_array_mut()) { - remove_arr.retain(|v| v.as_str() != Some("DuckDuckGo")); - } - } - - let updated = serde_json::to_string_pretty(&policies)?; - std::fs::write(&policies_path, updated)?; - log::info!( - "Configured DuckDuckGo search engine in {}", - policies_path.display() - ); - - Ok(()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6c68e85..8e0041b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -37,6 +37,7 @@ pub mod proxy_server; pub mod proxy_storage; mod settings_manager; pub mod sync; +mod synchronizer; pub mod traffic_stats; mod wayfern_manager; mod wayfern_terms; @@ -208,11 +209,21 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin async fn create_stored_proxy( app_handle: tauri::AppHandle, name: String, - proxy_settings: crate::browser::ProxySettings, + proxy_settings: Option, + dynamic_proxy_url: Option, + dynamic_proxy_format: Option, ) -> Result { - crate::proxy_manager::PROXY_MANAGER - .create_stored_proxy(&app_handle, name, proxy_settings) - .map_err(|e| format!("Failed to create stored proxy: {e}")) + if let (Some(url), Some(format)) = (&dynamic_proxy_url, &dynamic_proxy_format) { + crate::proxy_manager::PROXY_MANAGER + .create_dynamic_proxy(&app_handle, name, url.clone(), format.clone()) + .map_err(|e| format!("Failed to create dynamic proxy: {e}")) + } else if let Some(settings) = proxy_settings { + crate::proxy_manager::PROXY_MANAGER + .create_stored_proxy(&app_handle, name, settings) + .map_err(|e| format!("Failed to create stored proxy: {e}")) + } else { + Err("Either proxy_settings or dynamic proxy URL and format are required".to_string()) + } } #[tauri::command] @@ -226,10 +237,26 @@ async fn update_stored_proxy( proxy_id: String, name: Option, proxy_settings: Option, + dynamic_proxy_url: Option, + dynamic_proxy_format: Option, ) -> Result { - crate::proxy_manager::PROXY_MANAGER - .update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings) - .map_err(|e| format!("Failed to update stored proxy: {e}")) + // Check if this is a dynamic proxy update + let is_dynamic = crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id); + if is_dynamic || dynamic_proxy_url.is_some() { + crate::proxy_manager::PROXY_MANAGER + .update_dynamic_proxy( + &app_handle, + &proxy_id, + name, + dynamic_proxy_url, + dynamic_proxy_format, + ) + .map_err(|e| format!("Failed to update dynamic proxy: {e}")) + } else { + crate::proxy_manager::PROXY_MANAGER + .update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings) + .map_err(|e| format!("Failed to update stored proxy: {e}")) + } } #[tauri::command] @@ -242,10 +269,32 @@ async fn delete_stored_proxy(app_handle: tauri::AppHandle, proxy_id: String) -> #[tauri::command] async fn check_proxy_validity( proxy_id: String, - proxy_settings: crate::browser::ProxySettings, + proxy_settings: Option, ) -> Result { + // For dynamic proxies, fetch settings first + let settings = if let Some(s) = proxy_settings { + s + } else if crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id) { + crate::proxy_manager::PROXY_MANAGER + .resolve_dynamic_proxy(&proxy_id) + .await? + } else { + crate::proxy_manager::PROXY_MANAGER + .get_proxy_settings_by_id(&proxy_id) + .ok_or_else(|| format!("Proxy '{proxy_id}' not found"))? + }; crate::proxy_manager::PROXY_MANAGER - .check_proxy_validity(&proxy_id, &proxy_settings) + .check_proxy_validity(&proxy_id, &settings) + .await +} + +#[tauri::command] +async fn fetch_dynamic_proxy( + url: String, + format: String, +) -> Result { + crate::proxy_manager::PROXY_MANAGER + .fetch_dynamic_proxy(&url, &format) .await } @@ -1477,7 +1526,7 @@ pub fn run() { } } Err(e) => { - log::debug!("Sync not configured, skipping missing profile check: {}", e); + log::warn!("Sync not configured, skipping missing profile check: {}", e); } } @@ -1572,6 +1621,7 @@ pub fn run() { update_stored_proxy, delete_stored_proxy, check_proxy_validity, + fetch_dynamic_proxy, get_cached_proxy_check, export_proxies, import_proxies_json, @@ -1669,6 +1719,11 @@ pub fn run() { // Team lock commands team_lock::get_team_locks, team_lock::get_team_lock_status, + // Synchronizer commands + synchronizer::start_sync_session, + synchronizer::stop_sync_session, + synchronizer::remove_sync_follower, + synchronizer::get_sync_sessions, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index f89159e..c3461ec 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -96,6 +96,16 @@ impl McpServer { self.is_running.load(Ordering::SeqCst) } + async fn require_paid_subscription(feature: &str) -> Result<(), McpError> { + if !CLOUD_AUTH.has_active_paid_subscription().await { + return Err(McpError { + code: -32000, + message: format!("{feature} requires an active paid subscription"), + }); + } + Ok(()) + } + pub fn get_port(&self) -> Option { let port = self.port.load(Ordering::SeqCst); if port > 0 { @@ -561,7 +571,7 @@ impl McpServer { }, McpTool { name: "create_proxy".to_string(), - description: "Create a new proxy configuration".to_string(), + description: "Create a new proxy configuration. For regular proxies, provide proxy_type/host/port. For dynamic proxies, provide dynamic_proxy_url and dynamic_proxy_format instead.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { @@ -572,26 +582,35 @@ impl McpServer { "proxy_type": { "type": "string", "enum": ["http", "https", "socks4", "socks5"], - "description": "The type of proxy" + "description": "The type of proxy (for regular proxies)" }, "host": { "type": "string", - "description": "The proxy host address" + "description": "The proxy host address (for regular proxies)" }, "port": { "type": "integer", - "description": "The proxy port number" + "description": "The proxy port number (for regular proxies)" }, "username": { "type": "string", - "description": "Optional username for authentication" + "description": "Optional username for authentication (for regular proxies)" }, "password": { "type": "string", - "description": "Optional password for authentication" + "description": "Optional password for authentication (for regular proxies)" + }, + "dynamic_proxy_url": { + "type": "string", + "description": "URL to fetch proxy settings from (for dynamic proxies)" + }, + "dynamic_proxy_format": { + "type": "string", + "enum": ["json", "text"], + "description": "Format of the dynamic proxy response: 'json' for JSON object or 'text' for text like host:port:user:pass (for dynamic proxies)" } }, - "required": ["name", "proxy_type", "host", "port"] + "required": ["name"] }), }, McpTool { @@ -611,23 +630,32 @@ impl McpServer { "proxy_type": { "type": "string", "enum": ["http", "https", "socks4", "socks5"], - "description": "The type of proxy" + "description": "The type of proxy (for regular proxies)" }, "host": { "type": "string", - "description": "The proxy host address" + "description": "The proxy host address (for regular proxies)" }, "port": { "type": "integer", - "description": "The proxy port number" + "description": "The proxy port number (for regular proxies)" }, "username": { "type": "string", - "description": "Optional username for authentication" + "description": "Optional username for authentication (for regular proxies)" }, "password": { "type": "string", - "description": "Optional password for authentication" + "description": "Optional password for authentication (for regular proxies)" + }, + "dynamic_proxy_url": { + "type": "string", + "description": "URL to fetch proxy settings from (for dynamic proxies)" + }, + "dynamic_proxy_format": { + "type": "string", + "enum": ["json", "text"], + "description": "Format of the dynamic proxy response (for dynamic proxies)" } }, "required": ["proxy_id"] @@ -926,6 +954,66 @@ impl McpServer { "required": ["profile_id"] }), }, + // Synchronizer tools + McpTool { + name: "start_sync_session".to_string(), + description: "Start a synchronizer session. Launches a leader profile and follower profiles, then mirrors all actions from the leader to the followers in real time. Only Wayfern profiles are supported. Requires paid subscription.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "leader_profile_id": { + "type": "string", + "description": "The UUID of the leader profile" + }, + "follower_profile_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "UUIDs of follower profiles" + } + }, + "required": ["leader_profile_id", "follower_profile_ids"] + }), + }, + McpTool { + name: "stop_sync_session".to_string(), + description: "Stop an active synchronizer session. Kills all follower profiles and the leader.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "The sync session ID" + } + }, + "required": ["session_id"] + }), + }, + McpTool { + name: "get_sync_sessions".to_string(), + description: "List all active synchronizer sessions.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {} + }), + }, + McpTool { + name: "remove_sync_follower".to_string(), + description: "Remove a follower from an active synchronizer session.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "The sync session ID" + }, + "follower_profile_id": { + "type": "string", + "description": "The UUID of the follower to remove" + } + }, + "required": ["session_id", "follower_profile_id"] + }), + }, // Browser interaction tools McpTool { name: "navigate".to_string(), @@ -1165,7 +1253,10 @@ impl McpServer { match tool_name { "list_profiles" => self.handle_list_profiles().await, "get_profile" => self.handle_get_profile(&arguments).await, - "run_profile" => self.handle_run_profile(&arguments).await, + "run_profile" => { + Self::require_paid_subscription("Browser automation").await?; + self.handle_run_profile(&arguments).await + } "kill_profile" => self.handle_kill_profile(&arguments).await, "create_profile" => self.handle_create_profile(&arguments).await, "update_profile" => self.handle_update_profile(&arguments).await, @@ -1217,14 +1308,43 @@ impl McpServer { // Team lock tools "get_team_locks" => self.handle_get_team_locks().await, "get_team_lock_status" => self.handle_get_team_lock_status(&arguments).await, - // Browser interaction tools - "navigate" => self.handle_navigate(&arguments).await, - "screenshot" => self.handle_screenshot(&arguments).await, - "evaluate_javascript" => self.handle_evaluate_javascript(&arguments).await, - "click_element" => self.handle_click_element(&arguments).await, - "type_text" => self.handle_type_text(&arguments).await, - "get_page_content" => self.handle_get_page_content(&arguments).await, - "get_page_info" => self.handle_get_page_info(&arguments).await, + // Synchronizer tools + "start_sync_session" => { + Self::require_paid_subscription("Synchronizer").await?; + self.handle_start_sync_session(&arguments).await + } + "stop_sync_session" => self.handle_stop_sync_session(&arguments).await, + "get_sync_sessions" => self.handle_get_sync_sessions().await, + "remove_sync_follower" => self.handle_remove_sync_follower(&arguments).await, + // Browser interaction tools (require paid subscription) + "navigate" => { + Self::require_paid_subscription("Browser automation").await?; + self.handle_navigate(&arguments).await + } + "screenshot" => { + Self::require_paid_subscription("Browser automation").await?; + self.handle_screenshot(&arguments).await + } + "evaluate_javascript" => { + Self::require_paid_subscription("Browser automation").await?; + self.handle_evaluate_javascript(&arguments).await + } + "click_element" => { + Self::require_paid_subscription("Browser automation").await?; + self.handle_click_element(&arguments).await + } + "type_text" => { + Self::require_paid_subscription("Browser automation").await?; + self.handle_type_text(&arguments).await + } + "get_page_content" => { + Self::require_paid_subscription("Browser automation").await?; + self.handle_get_page_content(&arguments).await + } + "get_page_info" => { + Self::require_paid_subscription("Browser automation").await?; + self.handle_get_page_info(&arguments).await + } _ => Err(McpError { code: -32602, message: format!("Unknown tool: {tool_name}"), @@ -2013,59 +2133,79 @@ impl McpServer { message: "Missing name".to_string(), })?; - let proxy_type = arguments - .get("proxy_type") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError { - code: -32602, - message: "Missing proxy_type".to_string(), - })?; - - let host = arguments - .get("host") - .and_then(|v| v.as_str()) - .ok_or_else(|| McpError { - code: -32602, - message: "Missing host".to_string(), - })?; - - let port = arguments - .get("port") - .and_then(|v| v.as_u64()) - .ok_or_else(|| McpError { - code: -32602, - message: "Missing port".to_string(), - })? as u16; - - let username = arguments - .get("username") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let password = arguments - .get("password") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let proxy_settings = ProxySettings { - proxy_type: proxy_type.to_string(), - host: host.to_string(), - port, - username, - password, - }; - let inner = self.inner.lock().await; let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { code: -32000, message: "MCP server not properly initialized".to_string(), })?; - let proxy = PROXY_MANAGER - .create_stored_proxy(app_handle, name.to_string(), proxy_settings) - .map_err(|e| McpError { - code: -32000, - message: format!("Failed to create proxy: {e}"), - })?; + // Check if this is a dynamic proxy creation + let dynamic_url = arguments.get("dynamic_proxy_url").and_then(|v| v.as_str()); + let dynamic_format = arguments + .get("dynamic_proxy_format") + .and_then(|v| v.as_str()); + + let proxy = if let (Some(url), Some(format)) = (dynamic_url, dynamic_format) { + PROXY_MANAGER + .create_dynamic_proxy( + app_handle, + name.to_string(), + url.to_string(), + format.to_string(), + ) + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to create dynamic proxy: {e}"), + })? + } else { + let proxy_type = arguments + .get("proxy_type") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing proxy_type (required for regular proxies)".to_string(), + })?; + + let host = arguments + .get("host") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing host (required for regular proxies)".to_string(), + })?; + + let port = arguments + .get("port") + .and_then(|v| v.as_u64()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing port (required for regular proxies)".to_string(), + })? as u16; + + let username = arguments + .get("username") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let password = arguments + .get("password") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let proxy_settings = ProxySettings { + proxy_type: proxy_type.to_string(), + host: host.to_string(), + port, + username, + password, + }; + + PROXY_MANAGER + .create_stored_proxy(app_handle, name.to_string(), proxy_settings) + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to create proxy: {e}"), + })? + }; Ok(serde_json::json!({ "content": [{ @@ -2155,12 +2295,32 @@ impl McpServer { message: "MCP server not properly initialized".to_string(), })?; - let proxy = PROXY_MANAGER - .update_stored_proxy(app_handle, proxy_id, name, proxy_settings) - .map_err(|e| McpError { - code: -32000, - message: format!("Failed to update proxy: {e}"), - })?; + // Check for dynamic proxy fields + let dynamic_url = arguments + .get("dynamic_proxy_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let dynamic_format = arguments + .get("dynamic_proxy_format") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(proxy_id) || dynamic_url.is_some(); + + let proxy = if is_dynamic { + PROXY_MANAGER + .update_dynamic_proxy(app_handle, proxy_id, name, dynamic_url, dynamic_format) + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to update dynamic proxy: {e}"), + })? + } else { + PROXY_MANAGER + .update_stored_proxy(app_handle, proxy_id, name, proxy_settings) + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to update proxy: {e}"), + })? + }; Ok(serde_json::json!({ "content": [{ @@ -3030,9 +3190,8 @@ impl McpServer { let url = format!("http://127.0.0.1:{port}/json"); let client = reqwest::Client::new(); - // Retry connecting to CDP endpoint — Wayfern closes the debugging port - // briefly after launch for anti-detection and reopens it after ~30s. - let max_attempts = 45; + // Retry connecting to CDP endpoint (browser may still be starting up) + let max_attempts = 15; let mut last_err = String::new(); for attempt in 0..max_attempts { if attempt > 0 { @@ -3900,6 +4059,146 @@ impl McpServer { }] })) } + + // --- Synchronizer handlers --- + + async fn handle_start_sync_session( + &self, + arguments: &serde_json::Value, + ) -> Result { + let leader_id = arguments + .get("leader_profile_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing leader_profile_id".to_string(), + })?; + let follower_ids: Vec = arguments + .get("follower_profile_ids") + .and_then(|v| v.as_array()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing follower_profile_ids".to_string(), + })? + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + + let app = { + let inner = self.inner.lock().await; + inner.app_handle.clone().ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + })? + }; + + let info = crate::synchronizer::SynchronizerManager::instance() + .start_session(app, leader_id.to_string(), follower_ids) + .await + .map_err(|e| McpError { + code: -32000, + message: e, + })?; + + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string_pretty(&info).unwrap_or_default() + }] + })) + } + + async fn handle_stop_sync_session( + &self, + arguments: &serde_json::Value, + ) -> Result { + let session_id = arguments + .get("session_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing session_id".to_string(), + })?; + + let app = { + let inner = self.inner.lock().await; + inner.app_handle.clone().ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + })? + }; + + crate::synchronizer::SynchronizerManager::instance() + .stop_session(app, session_id) + .await + .map_err(|e| McpError { + code: -32000, + message: e, + })?; + + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": "Sync session stopped" + }] + })) + } + + async fn handle_get_sync_sessions(&self) -> Result { + let sessions = crate::synchronizer::SynchronizerManager::instance() + .get_sessions() + .await; + + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string_pretty(&sessions).unwrap_or_default() + }] + })) + } + + async fn handle_remove_sync_follower( + &self, + arguments: &serde_json::Value, + ) -> Result { + let session_id = arguments + .get("session_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing session_id".to_string(), + })?; + let follower_id = arguments + .get("follower_profile_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing follower_profile_id".to_string(), + })?; + + let app = { + let inner = self.inner.lock().await; + inner.app_handle.clone().ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + })? + }; + + crate::synchronizer::SynchronizerManager::instance() + .remove_follower(app, session_id, follower_id) + .await + .map_err(|e| McpError { + code: -32000, + message: e, + })?; + + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": "Follower removed from sync session" + }] + })) + } } lazy_static::lazy_static! { @@ -3963,6 +4262,11 @@ mod tests { // Team lock tools assert!(tool_names.contains(&"get_team_locks")); assert!(tool_names.contains(&"get_team_lock_status")); + // Synchronizer tools + assert!(tool_names.contains(&"start_sync_session")); + assert!(tool_names.contains(&"stop_sync_session")); + assert!(tool_names.contains(&"get_sync_sessions")); + assert!(tool_names.contains(&"remove_sync_follower")); // Browser interaction tools assert!(tool_names.contains(&"navigate")); assert!(tool_names.contains(&"screenshot")); diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 4db0fca..4c05445 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -117,6 +117,10 @@ pub struct StoredProxy { pub geo_city: Option, #[serde(default)] pub geo_isp: Option, + #[serde(default)] + pub dynamic_proxy_url: Option, + #[serde(default)] + pub dynamic_proxy_format: Option, } impl StoredProxy { @@ -135,9 +139,15 @@ impl StoredProxy { geo_region: None, geo_city: None, geo_isp: None, + dynamic_proxy_url: None, + dynamic_proxy_format: None, } } + pub fn is_dynamic(&self) -> bool { + self.dynamic_proxy_url.is_some() + } + /// Migrate legacy geo_state to geo_region pub fn migrate_geo_fields(&mut self) { if self.geo_region.is_none() && self.geo_state.is_some() { @@ -450,6 +460,8 @@ impl ProxyManager { geo_region: None, geo_city: None, geo_isp: None, + dynamic_proxy_url: None, + dynamic_proxy_format: None, }; stored_proxies.insert(CLOUD_PROXY_ID.to_string(), cloud_proxy.clone()); drop(stored_proxies); @@ -639,6 +651,8 @@ impl ProxyManager { geo_region: region, geo_city: city, geo_isp: isp, + dynamic_proxy_url: None, + dynamic_proxy_format: None, }; { @@ -965,6 +979,269 @@ impl ProxyManager { self.load_proxy_check_cache(proxy_id) } + // Check if a stored proxy is dynamic + pub fn is_dynamic_proxy(&self, proxy_id: &str) -> bool { + let stored_proxies = self.stored_proxies.lock().unwrap(); + stored_proxies.get(proxy_id).is_some_and(|p| p.is_dynamic()) + } + + // Fetch proxy settings from a dynamic proxy URL + pub async fn fetch_dynamic_proxy( + &self, + url: &str, + format: &str, + ) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {e}"))?; + + let response = client + .get(url) + .send() + .await + .map_err(|e| format!("Failed to fetch dynamic proxy: {e}"))?; + + if !response.status().is_success() { + return Err(format!( + "Dynamic proxy URL returned status {}", + response.status() + )); + } + + let body = response + .text() + .await + .map_err(|e| format!("Failed to read dynamic proxy response: {e}"))?; + + let body = body.trim(); + if body.is_empty() { + return Err("Dynamic proxy URL returned empty response".to_string()); + } + + match format { + "json" => Self::parse_dynamic_proxy_json(body), + "text" => Self::parse_dynamic_proxy_text(body), + _ => Err(format!("Unsupported dynamic proxy format: {format}")), + } + } + + // Parse JSON format: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." } + fn parse_dynamic_proxy_json(body: &str) -> Result { + let json: serde_json::Value = + serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))?; + + let obj = json + .as_object() + .ok_or_else(|| "JSON response is not an object".to_string())?; + + let host = obj + .get("ip") + .or_else(|| obj.get("host")) + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing 'ip' or 'host' field in JSON response".to_string())? + .to_string(); + + let port = obj + .get("port") + .and_then(|v| { + v.as_u64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + }) + .ok_or_else(|| "Missing or invalid 'port' field in JSON response".to_string())? + as u16; + + let proxy_type = obj + .get("type") + .or_else(|| obj.get("proxy_type")) + .and_then(|v| v.as_str()) + .unwrap_or("http") + .to_string(); + + let username = obj + .get("username") + .or_else(|| obj.get("user")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let password = obj + .get("password") + .or_else(|| obj.get("pass")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + Ok(ProxySettings { + proxy_type, + host, + port, + username, + password, + }) + } + + // Parse text format using the same logic as proxy import + fn parse_dynamic_proxy_text(body: &str) -> Result { + let line = body + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or("") + .trim(); + if line.is_empty() { + return Err("Empty text response".to_string()); + } + + match Self::parse_single_proxy_line(line) { + ProxyParseResult::Parsed(parsed) => Ok(ProxySettings { + proxy_type: parsed.proxy_type, + host: parsed.host, + port: parsed.port, + username: parsed.username, + password: parsed.password, + }), + ProxyParseResult::Ambiguous { + possible_formats, .. + } => Err(format!( + "Ambiguous proxy format. Could be: {}", + possible_formats.join(" or ") + )), + ProxyParseResult::Invalid { reason, .. } => { + Err(format!("Failed to parse proxy response: {reason}")) + } + } + } + + // Resolve dynamic proxy: fetch from URL and return settings + pub async fn resolve_dynamic_proxy(&self, proxy_id: &str) -> Result { + let (url, format) = { + let stored_proxies = self.stored_proxies.lock().unwrap(); + let proxy = stored_proxies + .get(proxy_id) + .ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?; + + match (&proxy.dynamic_proxy_url, &proxy.dynamic_proxy_format) { + (Some(url), Some(format)) => (url.clone(), format.clone()), + _ => return Err("Proxy is not a dynamic proxy".to_string()), + } + }; + + self.fetch_dynamic_proxy(&url, &format).await + } + + // Create a dynamic stored proxy + pub fn create_dynamic_proxy( + &self, + _app_handle: &tauri::AppHandle, + name: String, + url: String, + format: String, + ) -> Result { + { + let stored_proxies = self.stored_proxies.lock().unwrap(); + if stored_proxies.values().any(|p| p.name == name) { + return Err(format!("Proxy with name '{name}' already exists")); + } + } + + let placeholder_settings = ProxySettings { + proxy_type: "http".to_string(), + host: "dynamic".to_string(), + port: 0, + username: None, + password: None, + }; + + let mut stored_proxy = StoredProxy::new(name, placeholder_settings); + stored_proxy.dynamic_proxy_url = Some(url); + stored_proxy.dynamic_proxy_format = Some(format); + + { + let mut stored_proxies = self.stored_proxies.lock().unwrap(); + stored_proxies.insert(stored_proxy.id.clone(), stored_proxy.clone()); + } + + if let Err(e) = self.save_proxy(&stored_proxy) { + log::warn!("Failed to save proxy: {e}"); + } + + if let Err(e) = events::emit_empty("proxies-changed") { + log::error!("Failed to emit proxies-changed event: {e}"); + } + + if stored_proxy.sync_enabled { + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let id = stored_proxy.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_proxy_sync(id).await; + }); + } + } + + Ok(stored_proxy) + } + + // Update a dynamic proxy's URL and format + pub fn update_dynamic_proxy( + &self, + _app_handle: &tauri::AppHandle, + proxy_id: &str, + name: Option, + url: Option, + format: Option, + ) -> Result { + { + let stored_proxies = self.stored_proxies.lock().unwrap(); + if !stored_proxies.contains_key(proxy_id) { + return Err(format!("Proxy with ID '{proxy_id}' not found")); + } + if let Some(ref new_name) = name { + if stored_proxies + .values() + .any(|p| p.id != proxy_id && p.name == *new_name) + { + return Err(format!("Proxy with name '{new_name}' already exists")); + } + } + } + + let updated_proxy = { + let mut stored_proxies = self.stored_proxies.lock().unwrap(); + let stored_proxy = stored_proxies.get_mut(proxy_id).unwrap(); + + if let Some(new_name) = name { + stored_proxy.update_name(new_name); + } + if let Some(new_url) = url { + stored_proxy.dynamic_proxy_url = Some(new_url); + } + if let Some(new_format) = format { + stored_proxy.dynamic_proxy_format = Some(new_format); + } + + stored_proxy.clone() + }; + + if let Err(e) = self.save_proxy(&updated_proxy) { + log::warn!("Failed to save proxy: {e}"); + } + + if let Err(e) = events::emit_empty("proxies-changed") { + log::error!("Failed to emit proxies-changed event: {e}"); + } + + if updated_proxy.sync_enabled { + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let id = updated_proxy.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_proxy_sync(id).await; + }); + } + } + + Ok(updated_proxy) + } + // Export all proxies as JSON pub fn export_proxies_json(&self) -> Result { let stored_proxies = self.stored_proxies.lock().unwrap(); @@ -2835,6 +3112,8 @@ mod tests { geo_region: None, geo_city: None, geo_isp: None, + dynamic_proxy_url: None, + dynamic_proxy_format: None, }; // Before migration diff --git a/src-tauri/src/sync/client.rs b/src-tauri/src/sync/client.rs index bf330c6..9808fcc 100644 --- a/src-tauri/src/sync/client.rs +++ b/src-tauri/src/sync/client.rs @@ -127,6 +127,14 @@ impl SyncClient { } pub async fn list(&self, prefix: &str) -> SyncResult { + self.list_page(prefix, None).await + } + + async fn list_page( + &self, + prefix: &str, + continuation_token: Option, + ) -> SyncResult { let response = self .client .post(self.url("list")) @@ -134,7 +142,7 @@ impl SyncClient { .json(&ListRequest { prefix: prefix.to_string(), max_keys: Some(1000), - continuation_token: None, + continuation_token, }) .send() .await @@ -152,6 +160,27 @@ impl SyncClient { .map_err(|e| SyncError::SerializationError(e.to_string())) } + /// List all objects under a prefix, paginating through all results + pub async fn list_all(&self, prefix: &str) -> SyncResult> { + let mut all_objects = Vec::new(); + let mut continuation_token: Option = None; + + loop { + let response = self.list_page(prefix, continuation_token).await?; + all_objects.extend(response.objects); + + if !response.is_truncated { + break; + } + continuation_token = response.next_continuation_token; + if continuation_token.is_none() { + break; + } + } + + Ok(all_objects) + } + pub async fn upload_bytes( &self, presigned_url: &str, diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 286fad0..dff5056 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -9,7 +9,7 @@ use crate::settings_manager::SettingsManager; use chrono::{DateTime, Utc}; use std::collections::{HashMap, HashSet}; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Instant; @@ -49,6 +49,70 @@ fn is_critical_file(path: &str) -> bool { .any(|pattern| path.contains(pattern)) } +/// Checkpoint all SQLite WAL files in a profile directory. +/// +/// When a browser crashes or is killed, SQLite WAL files may contain +/// uncommitted data (e.g. cookies, login data). Since WAL files are +/// excluded from sync, we must checkpoint them into the main database +/// files before generating the manifest to avoid data loss. +fn checkpoint_sqlite_wal_files(profile_dir: &Path) { + fn find_wal_files(dir: &Path, wal_files: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + find_wal_files(&path, wal_files); + } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.ends_with("-wal") { + wal_files.push(path); + } + } + } + } + + let mut wal_files = Vec::new(); + find_wal_files(profile_dir, &mut wal_files); + + for wal_path in &wal_files { + // Only checkpoint non-empty WAL files + let is_non_empty = fs::metadata(wal_path).map(|m| m.len() > 0).unwrap_or(false); + if !is_non_empty { + continue; + } + + // Derive the main database path by stripping the "-wal" suffix + let db_path_str = wal_path.to_string_lossy(); + let db_path = PathBuf::from(db_path_str.strip_suffix("-wal").unwrap()); + + if !db_path.exists() { + continue; + } + + match rusqlite::Connection::open(&db_path) { + Ok(conn) => match conn.pragma_update(None, "wal_checkpoint", "TRUNCATE") { + Ok(_) => { + log::info!( + "Checkpointed WAL for: {}", + db_path.file_name().unwrap_or_default().to_string_lossy() + ); + } + Err(e) => { + log::warn!("Failed to checkpoint WAL for {}: {}", db_path.display(), e); + } + }, + Err(e) => { + log::warn!( + "Failed to open DB for WAL checkpoint {}: {}", + db_path.display(), + e + ); + } + } + } +} + /// Resume state persisted to disk so interrupted syncs can continue #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] struct SyncResumeState { @@ -362,6 +426,10 @@ impl SyncEngine { )) })?; + // Checkpoint any SQLite WAL files to ensure all data is in the main DB + // before we generate the manifest (WAL files are excluded from sync) + checkpoint_sqlite_wal_files(&profile_dir); + // Load or create hash cache let cache_path = get_cache_path(&profile_dir); let mut hash_cache = HashCache::load(&cache_path); @@ -488,9 +556,22 @@ impl SyncEngine { .upload_profile_metadata(&profile_id, profile, &key_prefix) .await?; + // If we recovered from an empty local state (downloaded everything from remote), + // regenerate the manifest from the actual files now on disk so we don't + // overwrite the remote manifest with an empty one. + let final_manifest = if local_manifest.files.is_empty() && !diff.files_to_download.is_empty() { + let mut new_cache = HashCache::load(&cache_path); + let mut regenerated = generate_manifest(&profile_id, &profile_dir, &mut new_cache)?; + new_cache.save(&cache_path)?; + regenerated.encrypted = encryption_key.is_some(); + regenerated + } else { + let mut m = local_manifest; + m.encrypted = encryption_key.is_some(); + m + }; + // Upload manifest.json last for atomicity - let mut final_manifest = local_manifest; - final_manifest.encrypted = encryption_key.is_some(); self .upload_manifest(&profile_id, &final_manifest, &key_prefix) .await?; @@ -2165,14 +2246,14 @@ impl SyncEngine { ) -> SyncResult> { log::info!("Checking for missing synced profiles..."); - // List personal profiles from S3 - let list_response = self.client.list("profiles/").await?; + // List all personal profiles from S3 (paginated) + let all_objects = self.client.list_all("profiles/").await?; let mut downloaded: Vec = Vec::new(); // Extract unique profile IDs with their key prefix let mut profiles_to_check: HashMap = HashMap::new(); - for obj in list_response.objects { + for obj in all_objects { if obj.key.starts_with("profiles/") && obj.key.ends_with("/manifest.json") { if let Some(profile_id) = obj .key @@ -2189,8 +2270,8 @@ impl SyncEngine { if let Some(team_id) = &auth.user.team_id { let team_prefix = format!("teams/{}/", team_id); let team_list_key = format!("{}profiles/", team_prefix); - if let Ok(team_list) = self.client.list(&team_list_key).await { - for obj in team_list.objects { + if let Ok(team_objects) = self.client.list_all(&team_list_key).await { + for obj in team_objects { if obj.key.starts_with("profiles/") && obj.key.ends_with("/manifest.json") { if let Some(profile_id) = obj .key @@ -3341,3 +3422,138 @@ pub async fn set_extension_group_sync_enabled( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_checkpoint_sqlite_wal_files() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + + // Create a SQLite database in WAL mode and insert data. + // Use std::mem::forget to prevent the connection destructor from running, + // which simulates a browser crash where WAL is not checkpointed. + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "wal_autocheckpoint", "0").unwrap(); + conn + .execute( + "CREATE TABLE cookies (id INTEGER PRIMARY KEY, value TEXT)", + [], + ) + .unwrap(); + conn + .execute( + "INSERT INTO cookies (value) VALUES ('session_token_123')", + [], + ) + .unwrap(); + // Leak the connection to prevent auto-checkpoint on drop + std::mem::forget(conn); + } + + // Verify WAL file exists and has data + let wal_path = temp_dir.path().join("test.db-wal"); + assert!(wal_path.exists(), "WAL file should exist"); + let wal_size = fs::metadata(&wal_path).unwrap().len(); + assert!(wal_size > 0, "WAL file should be non-empty"); + + // Run checkpoint + checkpoint_sqlite_wal_files(temp_dir.path()); + + // After checkpoint, WAL should be truncated (empty) + let wal_size_after = fs::metadata(&wal_path).map(|m| m.len()).unwrap_or(0); + assert_eq!( + wal_size_after, 0, + "WAL should be truncated after checkpoint" + ); + + // Verify data is still accessible from the main database + let conn = rusqlite::Connection::open(&db_path).unwrap(); + let value: String = conn + .query_row("SELECT value FROM cookies WHERE id = 1", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(value, "session_token_123"); + } + + #[test] + fn test_checkpoint_handles_missing_db() { + let temp_dir = tempfile::TempDir::new().unwrap(); + + // Create a WAL file without a corresponding database + let wal_path = temp_dir.path().join("missing.db-wal"); + fs::write(&wal_path, b"fake wal data").unwrap(); + + // Should not panic + checkpoint_sqlite_wal_files(temp_dir.path()); + } + + #[test] + fn test_checkpoint_skips_empty_wal() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + + // Create a database and checkpoint immediately (WAL is empty) + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn + .execute("CREATE TABLE t (id INTEGER PRIMARY KEY)", []) + .unwrap(); + } + + // Create an empty WAL file + let wal_path = temp_dir.path().join("test.db-wal"); + fs::write(&wal_path, b"").unwrap(); + + // Should skip empty WAL without error + checkpoint_sqlite_wal_files(temp_dir.path()); + } + + #[test] + fn test_checkpoint_nested_directories() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let nested_dir = temp_dir.path().join("profile").join("Default"); + fs::create_dir_all(&nested_dir).unwrap(); + + let db_path = nested_dir.join("Cookies"); + + // Create a database with WAL data, leak connection to simulate crash + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "wal_autocheckpoint", "0").unwrap(); + conn + .execute( + "CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT)", + [], + ) + .unwrap(); + conn + .execute( + "INSERT INTO cookies VALUES ('.example.com', 'session', 'abc')", + [], + ) + .unwrap(); + std::mem::forget(conn); + } + + let wal_path = nested_dir.join("Cookies-wal"); + assert!(wal_path.exists()); + + // Checkpoint from the top-level directory + checkpoint_sqlite_wal_files(temp_dir.path()); + + // Verify data is in the main database + let conn = rusqlite::Connection::open(&db_path).unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM cookies", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 1); + } +} diff --git a/src-tauri/src/sync/manifest.rs b/src-tauri/src/sync/manifest.rs index 251fb46..4f1088e 100644 --- a/src-tauri/src/sync/manifest.rs +++ b/src-tauri/src/sync/manifest.rs @@ -408,6 +408,19 @@ pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> Mani let remote_files: HashMap<&str, &ManifestFileEntry> = remote.files.iter().map(|f| (f.path.as_str(), f)).collect(); + // Safety: if local is empty but remote has files, always download from remote. + // This prevents data loss when profile data files are deleted but metadata + // survives — the newly generated manifest would have updated_at=NOW, which + // would appear "newer" and cause all remote files to be deleted. + if local.files.is_empty() && !remote.files.is_empty() { + log::info!( + "Local manifest is empty but remote has {} files — downloading from remote to recover", + remote.files.len() + ); + diff.files_to_download = remote.files.clone(); + return diff; + } + // Compare timestamps to determine direction let local_updated = local.updated_at_datetime(); let remote_updated = remote.updated_at_datetime(); @@ -738,4 +751,50 @@ mod tests { let deserialized: SyncManifest = serde_json::from_str(&serialized).unwrap(); assert!(deserialized.encrypted); } + + #[test] + fn test_compute_diff_empty_local_downloads_from_remote() { + // When local has no files but remote does, always download from remote. + // This prevents data loss when profile data is deleted but metadata survives. + let local = SyncManifest { + version: 1, + profile_id: "test".to_string(), + generated_at: Utc::now().to_rfc3339(), + updated_at: Utc::now().to_rfc3339(), // NOW — appears newer than remote + exclude_globs: vec![], + files: vec![], + encrypted: false, + }; + + let remote = SyncManifest { + version: 1, + profile_id: "test".to_string(), + generated_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + exclude_globs: vec![], + files: vec![ + ManifestFileEntry { + path: "Cookies".to_string(), + size: 100, + mtime: 1000, + hash: "abc".to_string(), + }, + ManifestFileEntry { + path: "Local State".to_string(), + size: 200, + mtime: 1000, + hash: "def".to_string(), + }, + ], + encrypted: false, + }; + + let diff = compute_diff(&local, Some(&remote)); + + // Must download all remote files, NOT delete them + assert_eq!(diff.files_to_download.len(), 2); + assert!(diff.files_to_upload.is_empty()); + assert!(diff.files_to_delete_remote.is_empty()); + assert!(diff.files_to_delete_local.is_empty()); + } } diff --git a/src-tauri/src/sync/scheduler.rs b/src-tauri/src/sync/scheduler.rs index 4b06319..083ec6a 100644 --- a/src-tauri/src/sync/scheduler.rs +++ b/src-tauri/src/sync/scheduler.rs @@ -396,97 +396,112 @@ impl SyncScheduler { ready }; + // Mark all profiles as in-flight and filter out duplicates + let mut to_sync = Vec::new(); for profile_id in profiles_to_sync { - // Mark as in-flight to prevent duplicate syncs - { - let mut in_flight = self.in_flight_profiles.lock().await; - if in_flight.contains(&profile_id) { - log::debug!("Profile {} already in-flight, skipping", profile_id); - continue; - } - in_flight.insert(profile_id.clone()); - } - - log::info!("Executing queued sync for profile {}", profile_id); - let _ = events::emit( - "profile-sync-status", - serde_json::json!({ - "profile_id": profile_id, - "status": "syncing" - }), - ); - - let profile_to_sync = { - let profile_manager = ProfileManager::instance(); - profile_manager.list_profiles().ok().and_then(|profiles| { - profiles - .into_iter() - .find(|p| p.id.to_string() == profile_id && p.is_sync_enabled() && !p.is_cross_os()) - }) - }; - - let Some(profile) = profile_to_sync else { - // Remove from in-flight - let mut in_flight = self.in_flight_profiles.lock().await; - in_flight.remove(&profile_id); + let mut in_flight = self.in_flight_profiles.lock().await; + if in_flight.contains(&profile_id) { + log::debug!("Profile {} already in-flight, skipping", profile_id); continue; - }; - - let result = match SyncEngine::create_from_settings(app_handle).await { - Ok(engine) => engine.sync_profile(app_handle, &profile).await, - Err(e) => { - log::error!("Failed to create sync engine: {}", e); - Err(super::types::SyncError::NotConfigured) - } - }; - - // Remove from in-flight and check if sync just completed - let sync_just_completed = { - let mut in_flight = self.in_flight_profiles.lock().await; - in_flight.remove(&profile_id); - // If this was the last in-flight profile and there are no pending profiles, sync just completed - in_flight.is_empty() - && self.pending_profiles.lock().await.is_empty() - && self.pending_proxies.lock().await.is_empty() - && self.pending_groups.lock().await.is_empty() - && self.pending_vpns.lock().await.is_empty() - && self.pending_extensions.lock().await.is_empty() - && self.pending_extension_groups.lock().await.is_empty() - }; - - match result { - Ok(()) => { - log::info!("Profile {} synced successfully", profile_id); - let _ = events::emit( - "profile-sync-status", - serde_json::json!({ - "profile_id": profile_id, - "status": "synced" - }), - ); - } - Err(e) => { - log::error!("Failed to sync profile {}: {}", profile_id, e); - let _ = events::emit( - "profile-sync-status", - serde_json::json!({ - "profile_id": profile_id, - "status": "error", - "error": e.to_string() - }), - ); - } } + in_flight.insert(profile_id.clone()); + to_sync.push(profile_id); + } - // Trigger cleanup after sync completes if this was the last profile - if sync_just_completed { - log::debug!("All profile syncs completed, triggering cleanup"); - let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(); - if let Err(e) = registry.cleanup_unused_binaries() { - log::warn!("Cleanup after sync failed: {e}"); - } else { - log::debug!("Cleanup after sync completed successfully"); + // Sync all profiles in parallel + let mut sync_set = tokio::task::JoinSet::new(); + for profile_id in to_sync { + let app = app_handle.clone(); + let in_flight = self.in_flight_profiles.clone(); + sync_set.spawn(async move { + log::info!("Executing queued sync for profile {}", profile_id); + let _ = events::emit( + "profile-sync-status", + serde_json::json!({ + "profile_id": profile_id, + "status": "syncing" + }), + ); + + let profile_to_sync = { + let profile_manager = ProfileManager::instance(); + profile_manager.list_profiles().ok().and_then(|profiles| { + profiles + .into_iter() + .find(|p| p.id.to_string() == profile_id && p.is_sync_enabled() && !p.is_cross_os()) + }) + }; + + let Some(profile) = profile_to_sync else { + let mut inf = in_flight.lock().await; + inf.remove(&profile_id); + return; + }; + + let result = match SyncEngine::create_from_settings(&app).await { + Ok(engine) => engine.sync_profile(&app, &profile).await, + Err(e) => { + log::error!("Failed to create sync engine: {}", e); + Err(super::types::SyncError::NotConfigured) + } + }; + + { + let mut inf = in_flight.lock().await; + inf.remove(&profile_id); } + + match result { + Ok(()) => { + log::info!("Profile {} synced successfully", profile_id); + let _ = events::emit( + "profile-sync-status", + serde_json::json!({ + "profile_id": profile_id, + "status": "synced" + }), + ); + } + Err(e) => { + log::error!("Failed to sync profile {}: {}", profile_id, e); + let _ = events::emit( + "profile-sync-status", + serde_json::json!({ + "profile_id": profile_id, + "status": "error", + "error": e.to_string() + }), + ); + } + } + }); + } + + // Wait for all parallel syncs to finish + while let Some(result) = sync_set.join_next().await { + if let Err(e) = result { + log::error!("Profile sync task panicked: {e}"); + } + } + + // Trigger cleanup if everything is done + let all_done = { + let in_flight = self.in_flight_profiles.lock().await; + in_flight.is_empty() + && self.pending_profiles.lock().await.is_empty() + && self.pending_proxies.lock().await.is_empty() + && self.pending_groups.lock().await.is_empty() + && self.pending_vpns.lock().await.is_empty() + && self.pending_extensions.lock().await.is_empty() + && self.pending_extension_groups.lock().await.is_empty() + }; + if all_done { + log::debug!("All profile syncs completed, triggering cleanup"); + let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(); + if let Err(e) = registry.cleanup_unused_binaries() { + log::warn!("Cleanup after sync failed: {e}"); + } else { + log::debug!("Cleanup after sync completed successfully"); } } } diff --git a/src-tauri/src/synchronizer.rs b/src-tauri/src/synchronizer.rs new file mode 100644 index 0000000..7072d87 --- /dev/null +++ b/src-tauri/src/synchronizer.rs @@ -0,0 +1,1017 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use tauri::Emitter; +use tokio::sync::Mutex as AsyncMutex; + +use crate::profile::manager::ProfileManager; +use crate::profile::types::BrowserProfile; + +/// Maximum number of profiles to launch concurrently +const MAX_CONCURRENT_LAUNCHES: usize = 5; + +/// Event captured from the leader browser via Wayfern.inputCaptured CDP events. +/// Fields match the Wayfern.inputCaptured event schema directly. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CapturedEvent { + #[serde(rename = "type")] + pub event_type: String, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub x: Option, + #[serde(default)] + pub y: Option, + #[serde(default)] + pub button: Option, + #[serde(default, rename = "clickCount")] + pub click_count: Option, + #[serde(default)] + pub key: Option, + #[serde(default)] + pub code: Option, + #[serde(default, rename = "windowsVirtualKeyCode")] + pub key_code: Option, + #[serde(default)] + pub modifiers: Option, + #[serde(default)] + pub text: Option, + #[serde(default, rename = "deltaX")] + pub delta_x: Option, + #[serde(default, rename = "deltaY")] + pub delta_y: Option, + #[serde(default)] + pub timestamp: Option, +} + +// No JavaScript injection needed — Wayfern.enableInputCapture handles everything natively. + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncFollowerState { + pub profile_id: String, + pub profile_name: String, + /// None = healthy, Some(url) = desynced at this URL + pub failed_at_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncSessionInfo { + pub id: String, + pub leader_profile_id: String, + pub leader_profile_name: String, + pub followers: Vec, +} + +/// Internal session state +struct SyncSession { + id: String, + leader_profile_id: String, + leader_profile_name: String, + followers: HashMap, + /// Cancellation token — drop sender to stop the listener task + cancel_tx: tokio::sync::watch::Sender, +} + +pub struct SynchronizerManager { + inner: Arc>, +} + +struct SynchronizerInner { + sessions: HashMap, +} + +static SYNCHRONIZER: std::sync::OnceLock = std::sync::OnceLock::new(); + +impl SynchronizerManager { + pub fn instance() -> &'static SynchronizerManager { + SYNCHRONIZER.get_or_init(|| SynchronizerManager { + inner: Arc::new(AsyncMutex::new(SynchronizerInner { + sessions: HashMap::new(), + })), + }) + } + + /// Start a new sync session. Launches all profiles and begins event capture. + pub async fn start_session( + &self, + app_handle: tauri::AppHandle, + leader_profile_id: String, + follower_profile_ids: Vec, + ) -> Result { + // Validate: leader must be wayfern + let profiles = ProfileManager::instance() + .list_profiles() + .map_err(|e| format!("Failed to list profiles: {e}"))?; + + let leader = profiles + .iter() + .find(|p| p.id.to_string() == leader_profile_id) + .ok_or("Leader profile not found")? + .clone(); + + if leader.browser != "wayfern" { + return Err( + "Synchronizer only supports Wayfern profiles. Camoufox profiles cannot be used." + .to_string(), + ); + } + + // Check leader is not already running + if leader.process_id.is_some() { + let sys = sysinfo::System::new_all(); + if let Some(pid) = leader.process_id { + if sys.process(sysinfo::Pid::from(pid as usize)).is_some() { + return Err( + "Leader profile is already running. Stop it first to start a sync session.".to_string(), + ); + } + } + } + + let mut follower_profiles: Vec = Vec::new(); + for fid in &follower_profile_ids { + let fp = profiles + .iter() + .find(|p| p.id.to_string() == *fid) + .ok_or(format!("Follower profile '{fid}' not found"))? + .clone(); + if fp.browser != "wayfern" { + return Err(format!( + "Profile '{}' is not a Wayfern profile. Only Wayfern profiles can be synchronized.", + fp.name + )); + } + follower_profiles.push(fp); + } + + // Check no profile is part of another active session + { + let inner = self.inner.lock().await; + for session in inner.sessions.values() { + if session.leader_profile_id == leader_profile_id { + return Err("Leader profile is already in another sync session.".to_string()); + } + for fid in &follower_profile_ids { + if session.leader_profile_id == *fid || session.followers.contains_key(fid) { + return Err(format!( + "Profile '{fid}' is already part of another sync session." + )); + } + } + } + } + + let session_id = uuid::Uuid::new_v4().to_string(); + + log::info!( + "Synchronizer: launching leader '{}' and {} followers", + leader.name, + follower_profiles.len() + ); + + // Launch leader first so it gets focus + crate::browser_runner::launch_browser_profile(app_handle.clone(), leader.clone(), None) + .await + .map_err(|e| format!("Failed to launch leader: {e}"))?; + + // Launch followers in parallel batches of MAX_CONCURRENT_LAUNCHES + for chunk in follower_profiles.chunks(MAX_CONCURRENT_LAUNCHES) { + let mut set = tokio::task::JoinSet::new(); + for fp in chunk { + let ah = app_handle.clone(); + let fp = fp.clone(); + set.spawn(async move { + crate::browser_runner::launch_browser_profile(ah, fp.clone(), None) + .await + .map_err(|e| (fp.name.clone(), e.to_string())) + }); + } + while let Some(result) = set.join_next().await { + match result { + Ok(Ok(_)) => {} + Ok(Err((name, e))) => { + log::error!("Failed to launch follower '{name}': {e}"); + // Kill leader and all already-launched followers + let _ = + crate::browser_runner::kill_browser_profile(app_handle.clone(), leader.clone()).await; + for fp in &follower_profiles { + let _ = + crate::browser_runner::kill_browser_profile(app_handle.clone(), fp.clone()).await; + } + return Err(format!("Failed to launch follower '{name}': {e}")); + } + Err(e) => { + log::error!("Launch task panicked: {e}"); + let _ = + crate::browser_runner::kill_browser_profile(app_handle.clone(), leader.clone()).await; + return Err(format!("Launch task panicked: {e}")); + } + } + } + } + + // Bring leader window to front after all followers launched + Self::focus_leader_window(&leader).await; + + // Build follower states + let mut followers = HashMap::new(); + for fp in &follower_profiles { + followers.insert( + fp.id.to_string(), + SyncFollowerState { + profile_id: fp.id.to_string(), + profile_name: fp.name.clone(), + failed_at_url: None, + }, + ); + } + + let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + + let session = SyncSession { + id: session_id.clone(), + leader_profile_id: leader_profile_id.clone(), + leader_profile_name: leader.name.clone(), + followers: followers.clone(), + cancel_tx, + }; + + let info = SyncSessionInfo { + id: session_id.clone(), + leader_profile_id: leader_profile_id.clone(), + leader_profile_name: leader.name.clone(), + followers: followers.values().cloned().collect(), + }; + + { + let mut inner = self.inner.lock().await; + inner.sessions.insert(session_id.clone(), session); + } + + // Emit initial session event + let _ = app_handle.emit("sync-session-changed", &info); + + // Spawn the CDP listener task with a readiness signal + let manager = self.inner.clone(); + let ah = app_handle.clone(); + let sid = session_id.clone(); + let lid = leader_profile_id.clone(); + let fids: Vec = follower_profile_ids.clone(); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::>(); + + let all_profile_ids: Vec = std::iter::once(leader_profile_id.clone()) + .chain(follower_profile_ids.iter().cloned()) + .collect(); + + log::info!("Synchronizer: spawning CDP listener task"); + + tokio::spawn(async move { + log::info!("Synchronizer: CDP listener task started"); + if let Err(e) = Self::run_session_loop( + ah.clone(), + manager.clone(), + sid.clone(), + lid, + fids, + cancel_rx, + ready_tx, + ) + .await + { + log::error!("Synchronizer session {sid} error: {e}"); + // Kill all profiles on error (leader + followers) + for pid in &all_profile_ids { + if let Ok(p) = Self::get_profile(pid) { + let _ = crate::browser_runner::kill_browser_profile(ah.clone(), p).await; + } + } + } + // Session ended — clean up + let mut inner = manager.lock().await; + inner.sessions.remove(&sid); + let _ = ah.emit("sync-session-ended", &sid); + }); + + // Wait for the CDP session to be ready (or fail) + match tokio::time::timeout(std::time::Duration::from_secs(90), ready_rx).await { + Ok(Ok(Ok(()))) => Ok(info), + Ok(Ok(Err(e))) => Err(format!("Synchronizer setup failed: {e}")), + Ok(Err(_)) => Err("Synchronizer setup channel closed unexpectedly".to_string()), + Err(_) => Err("Synchronizer setup timed out".to_string()), + } + } + + /// Bring the leader browser window to front. + async fn focus_leader_window(leader: &BrowserProfile) { + let profile = match Self::get_profile(&leader.id.to_string()) { + Ok(p) => p, + Err(_) => return, + }; + let Some(pid) = profile.process_id else { + return; + }; + + #[cfg(target_os = "macos")] + { + let _ = tokio::process::Command::new("osascript") + .arg("-e") + .arg(format!( + "tell application \"System Events\" to set frontmost of (first process whose unix id is {}) to true", + pid + )) + .output() + .await; + } + + #[cfg(target_os = "linux")] + { + let _ = tokio::process::Command::new("xdotool") + .args([ + "search", + "--pid", + &pid.to_string(), + "--onlyvisible", + "windowactivate", + ]) + .output() + .await; + } + + #[cfg(target_os = "windows")] + { + let _ = pid; + } + } + + /// Core session loop: inject capture script on leader, listen for events, replay on followers. + async fn run_session_loop( + app_handle: tauri::AppHandle, + manager: Arc>, + session_id: String, + leader_profile_id: String, + follower_profile_ids: Vec, + mut cancel_rx: tokio::sync::watch::Receiver, + ready_tx: tokio::sync::oneshot::Sender>, + ) -> Result<(), String> { + use futures_util::sink::SinkExt; + use futures_util::stream::StreamExt; + use tokio_tungstenite::connect_async; + use tokio_tungstenite::tungstenite::Message; + + log::info!("Synchronizer: run_session_loop started, waiting 1s for browsers"); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // Connect to leader page-level target for reliable event capture + log::info!("Synchronizer: getting leader CDP port"); + let leader_profile = Self::get_profile(&leader_profile_id)?; + let leader_port = Self::get_cdp_port(&leader_profile).await?; + log::info!("Synchronizer: leader CDP port = {leader_port}, getting WS URL"); + let leader_ws_url = Self::get_page_ws_url(leader_port).await?; + + log::info!("Synchronizer: connecting to leader page at {leader_ws_url}"); + + let (mut ws_stream, _) = connect_async(&leader_ws_url) + .await + .map_err(|e| format!("Failed to connect to leader CDP: {e}"))?; + + // Helper: send command and collect response, buffering non-response events + let mut cmd_id: u64 = 0; + let mut pending_events: Vec = Vec::new(); + + // Send a CDP command and wait for its response, buffering events that arrive in between + async fn send_cmd( + ws: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + cmd_id: &mut u64, + pending_events: &mut Vec, + method: &str, + params: serde_json::Value, + ) -> Result { + *cmd_id += 1; + let id = *cmd_id; + let cmd = serde_json::json!({ "id": id, "method": method, "params": params }); + ws.send(Message::Text(cmd.to_string().into())) + .await + .map_err(|e| format!("Failed to send {method}: {e}"))?; + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(format!("Timeout waiting for {method} response")); + } + match tokio::time::timeout(remaining, ws.next()).await { + Ok(Some(Ok(Message::Text(text)))) => { + let resp: serde_json::Value = serde_json::from_str(text.as_str()).unwrap_or_default(); + if resp.get("id") == Some(&serde_json::json!(id)) { + if let Some(error) = resp.get("error") { + return Err(format!("CDP error for {method}: {error}")); + } + return Ok(resp.get("result").cloned().unwrap_or(serde_json::json!({}))); + } + // Buffer events that arrive while waiting for response + if resp.get("method").is_some() { + pending_events.push(resp); + } + } + Ok(Some(Ok(_))) => continue, + Ok(Some(Err(e))) => return Err(format!("WebSocket error: {e}")), + Ok(None) => return Err("WebSocket closed".to_string()), + Err(_) => return Err(format!("Timeout waiting for {method} response")), + } + } + } + + // Use Wayfern's native input capture — no JS injection needed. + // Captures all real user input at the browser process level. + let setup_commands: Vec<(&str, serde_json::Value)> = vec![ + ("Page.enable", serde_json::json!({})), + ("Wayfern.enableInputCapture", serde_json::json!({})), + ]; + + for (method, params) in setup_commands { + match send_cmd( + &mut ws_stream, + &mut cmd_id, + &mut pending_events, + method, + params, + ) + .await + { + Ok(_) => log::info!("Synchronizer: {method} OK"), + Err(e) => { + log::error!("Synchronizer: {method} FAILED: {e}"); + return Err(format!("{method} failed: {e}")); + } + } + } + + log::info!("Synchronizer: input capture enabled"); + + // Get leader window size and resize all followers to match + let leader_bounds = send_cmd( + &mut ws_stream, + &mut cmd_id, + &mut pending_events, + "Browser.getWindowForTarget", + serde_json::json!({}), + ) + .await + .ok(); + + if let Some(bounds_result) = &leader_bounds { + if let Some(bounds) = bounds_result.get("bounds") { + let width = bounds.get("width").and_then(|v| v.as_i64()).unwrap_or(0); + let height = bounds.get("height").and_then(|v| v.as_i64()).unwrap_or(0); + if width > 0 && height > 0 { + log::info!("Synchronizer: leader window size {width}x{height}, resizing followers"); + for fid in &follower_profile_ids { + if let Ok(fp) = Self::get_profile(fid) { + if let Ok(port) = Self::get_cdp_port(&fp).await { + if let Ok(f_ws) = Self::get_page_ws_url(port).await { + if let Ok((mut fws, _)) = tokio_tungstenite::connect_async(&f_ws).await { + // Get follower's window ID + let get_win = serde_json::json!({ "id": 1, "method": "Browser.getWindowForTarget", "params": {} }); + let _ = fws.send(Message::Text(get_win.to_string().into())).await; + if let Some(Ok(Message::Text(text))) = fws.next().await { + if let Ok(resp) = serde_json::from_str::(text.as_str()) { + if let Some(win_id) = resp + .get("result") + .and_then(|r| r.get("windowId")) + .and_then(|w| w.as_i64()) + { + let set_bounds = serde_json::json!({ + "id": 2, + "method": "Browser.setWindowBounds", + "params": { "windowId": win_id, "bounds": { "width": width, "height": height } } + }); + let _ = fws.send(Message::Text(set_bounds.to_string().into())).await; + } + } + } + } + } + } + } + } + } + } + } + + log::info!("Synchronizer: opening persistent connections to followers"); + + // Open persistent WebSocket connections to each follower and create event channels. + // Each follower gets a dedicated replay task with a long-lived WS connection. + let mut follower_senders: HashMap> = + HashMap::new(); + + for fid in &follower_profile_ids { + match Self::get_profile(fid) { + Ok(fp) => match Self::get_cdp_port(&fp).await { + Ok(port) => match Self::get_page_ws_url(port).await { + Ok(url) => { + match tokio_tungstenite::connect_async(&url).await { + Ok((ws, _)) => { + log::info!("Synchronizer: follower {} connected at {}", fp.name, url); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); + follower_senders.insert(fid.clone(), tx); + + // Spawn dedicated replay task for this follower + let fid_clone = fid.clone(); + let mgr = manager.clone(); + let sid = session_id.clone(); + let ah = app_handle.clone(); + tokio::spawn(async move { + Self::follower_replay_loop(ws, rx, fid_clone, mgr, sid, ah).await; + }); + } + Err(e) => log::warn!( + "Synchronizer: failed to connect to follower {}: {e}", + fp.name + ), + } + } + Err(e) => log::warn!( + "Synchronizer: failed to get WS URL for follower {}: {e}", + fp.name + ), + }, + Err(e) => log::warn!( + "Synchronizer: failed to get CDP port for follower {}: {e}", + fp.name + ), + }, + Err(e) => log::warn!("Synchronizer: failed to get follower profile {fid}: {e}"), + } + } + + log::info!( + "Synchronizer: {} of {} followers connected", + follower_senders.len(), + follower_profile_ids.len() + ); + + // Track when the last user interaction was captured (for suppressing click-caused nav replay) + let mut last_user_event_time = std::time::Instant::now() - std::time::Duration::from_secs(60); + + // Signal that the session is ready for interaction + let _ = ready_tx.send(Ok(())); + + // Process any events that were buffered during setup + for event in pending_events.drain(..) { + Self::handle_cdp_event( + &event, + &app_handle, + &manager, + &session_id, + &follower_senders, + false, + ) + .await; + } + + // Main event loop — listen for Wayfern.inputCaptured events + loop { + tokio::select! { + _ = cancel_rx.changed() => { + if *cancel_rx.borrow() { + log::info!("Synchronizer session {session_id}: cancelled"); + break; + } + } + msg = ws_stream.next() => { + match msg { + Some(Ok(Message::Text(text))) => { + let value: serde_json::Value = match serde_json::from_str(text.as_str()) { + Ok(v) => v, + Err(_) => continue, + }; + + let method = value.get("method").and_then(|m| m.as_str()).unwrap_or(""); + + // Log CDP command response errors + if let Some(id) = value.get("id") { + if let Some(error) = value.get("error") { + log::warn!("Synchronizer: CDP command {id} error: {error}"); + } + } + + // Track user interaction timing + if method == "Wayfern.inputCaptured" { + last_user_event_time = std::time::Instant::now(); + } + + let recent_user_event = last_user_event_time.elapsed() < std::time::Duration::from_secs(2); + + Self::handle_cdp_event( + &value, + &app_handle, + &manager, + &session_id, + &follower_senders, + recent_user_event, + ).await; + } + Some(Ok(_)) => {} // pings, binary, etc. + Some(Err(e)) => { + log::error!("Synchronizer: leader WebSocket error: {e}"); + break; + } + None => { + log::info!("Synchronizer: leader WebSocket closed (browser closed)"); + break; + } + } + } + } + } + + // Leader closed or session cancelled — kill all followers + log::info!("Synchronizer session {session_id}: stopping all followers"); + let follower_ids: Vec = { + let inner = manager.lock().await; + if let Some(session) = inner.sessions.get(&session_id) { + session.followers.keys().cloned().collect() + } else { + Vec::new() + } + }; + + for fid in follower_ids { + if let Ok(fp) = Self::get_profile(&fid) { + let _ = crate::browser_runner::kill_browser_profile(app_handle.clone(), fp).await; + } + } + + Ok(()) + } + + /// Handle a single CDP event from the leader + async fn handle_cdp_event( + value: &serde_json::Value, + _app_handle: &tauri::AppHandle, + _manager: &Arc>, + _session_id: &str, + follower_senders: &HashMap>, + recent_user_event: bool, + ) { + let method = value.get("method").and_then(|m| m.as_str()).unwrap_or(""); + + // Handle Wayfern.inputCaptured — native input events from the browser process + if method == "Wayfern.inputCaptured" { + if let Some(params) = value.get("params") { + let event_type = params.get("type").and_then(|v| v.as_str()).unwrap_or(""); + // Skip mousemove — too noisy and not useful for synchronization + if event_type == "mousemove" { + return; + } + if let Ok(event) = serde_json::from_value::(params.clone()) { + log::info!("Synchronizer: captured {event_type}"); + for tx in follower_senders.values() { + let _ = tx.send(event.clone()); + } + } + } + } + + // Handle Page.frameNavigated — replay only for address-bar navigations + if method == "Page.frameNavigated" && !recent_user_event { + if let Some(params) = value.get("params") { + if let Some(frame) = params.get("frame") { + let is_top = frame.get("parentId").is_none(); + if is_top { + if let Some(url) = frame.get("url").and_then(|v| v.as_str()) { + if !url.starts_with("about:") && !url.starts_with("chrome://") { + log::info!("Synchronizer: replaying address-bar navigation to {url}"); + let nav_event = CapturedEvent { + event_type: "navigate".to_string(), + url: Some(url.to_string()), + x: None, + y: None, + button: None, + click_count: None, + key: None, + code: None, + key_code: None, + modifiers: None, + text: None, + delta_x: None, + delta_y: None, + timestamp: None, + }; + for tx in follower_senders.values() { + let _ = tx.send(nav_event.clone()); + } + } + } + } + } + } + } + } + + /// Dedicated replay loop for a single follower with a persistent WebSocket connection. + /// Processes events from the channel sequentially — no per-event connection overhead. + async fn follower_replay_loop( + mut ws: tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + mut rx: tokio::sync::mpsc::UnboundedReceiver, + follower_id: String, + manager: Arc>, + session_id: String, + app_handle: tauri::AppHandle, + ) { + use futures_util::sink::SinkExt; + use tokio_tungstenite::tungstenite::Message; + + let mut cmd_id: u64 = 0; + + while let Some(event) = rx.recv().await { + cmd_id += 1; + let button = event.button.clone().unwrap_or_else(|| "left".to_string()); + + let command = match event.event_type.as_str() { + "navigate" => event + .url + .as_ref() + .map(|url| ("Page.navigate", serde_json::json!({ "url": url }))), + "mousedown" => Some(( + "Input.dispatchMouseEvent", + serde_json::json!({ + "type": "mousePressed", + "x": event.x.unwrap_or(0.0), + "y": event.y.unwrap_or(0.0), + "button": button, + "clickCount": event.click_count.unwrap_or(1), + "modifiers": event.modifiers.unwrap_or(0), + }), + )), + "mouseup" => Some(( + "Input.dispatchMouseEvent", + serde_json::json!({ + "type": "mouseReleased", + "x": event.x.unwrap_or(0.0), + "y": event.y.unwrap_or(0.0), + "button": button, + "modifiers": event.modifiers.unwrap_or(0), + }), + )), + "keydown" => Some(( + "Input.dispatchKeyEvent", + serde_json::json!({ + "type": "keyDown", + "key": event.key.clone().unwrap_or_default(), + "code": event.code.clone().unwrap_or_default(), + "windowsVirtualKeyCode": event.key_code.unwrap_or(0), + "modifiers": event.modifiers.unwrap_or(0), + }), + )), + "keyup" => Some(( + "Input.dispatchKeyEvent", + serde_json::json!({ + "type": "keyUp", + "key": event.key.clone().unwrap_or_default(), + "code": event.code.clone().unwrap_or_default(), + "windowsVirtualKeyCode": event.key_code.unwrap_or(0), + "modifiers": event.modifiers.unwrap_or(0), + }), + )), + "char" => { + let text = event.text.clone().unwrap_or_default(); + if text.is_empty() { + None + } else { + Some(( + "Input.dispatchKeyEvent", + serde_json::json!({ + "type": "char", + "text": text, + "unmodifiedText": text, + "modifiers": event.modifiers.unwrap_or(0), + }), + )) + } + } + "wheel" => Some(( + "Input.dispatchMouseEvent", + serde_json::json!({ + "type": "mouseWheel", + "x": event.x.unwrap_or(0.0), + "y": event.y.unwrap_or(0.0), + "deltaX": -event.delta_x.unwrap_or(0.0), + "deltaY": -event.delta_y.unwrap_or(0.0), + "modifiers": event.modifiers.unwrap_or(0), + }), + )), + _ => None, + }; + + if let Some((method, params)) = command { + let cmd = serde_json::json!({ "id": cmd_id, "method": method, "params": params }); + if let Err(e) = ws.send(Message::Text(cmd.to_string().into())).await { + log::warn!("Synchronizer: follower {follower_id} send failed: {e}"); + // Mark as desynced + let mut inner = manager.lock().await; + if let Some(session) = inner.sessions.get_mut(&session_id) { + if let Some(follower) = session.followers.get_mut(&follower_id) { + follower.failed_at_url = Some("connection lost".to_string()); + let info = SyncSessionInfo { + id: session.id.clone(), + leader_profile_id: session.leader_profile_id.clone(), + leader_profile_name: session.leader_profile_name.clone(), + followers: session.followers.values().cloned().collect(), + }; + let _ = app_handle.emit("sync-session-changed", &info); + } + } + break; + } + // Don't wait for response — fire and forget for speed. + // CDP commands are processed in order by Chromium. + } + } + } + + /// Stop a sync session by ID. Kills all followers. + pub async fn stop_session( + &self, + app_handle: tauri::AppHandle, + session_id: &str, + ) -> Result<(), String> { + let mut inner = self.inner.lock().await; + let session = inner + .sessions + .remove(session_id) + .ok_or("Session not found")?; + + // Signal the listener task to stop + let _ = session.cancel_tx.send(true); + + // Kill followers + for fid in session.followers.keys() { + if let Ok(fp) = Self::get_profile(fid) { + let _ = crate::browser_runner::kill_browser_profile(app_handle.clone(), fp).await; + } + } + + // Kill leader + if let Ok(leader) = Self::get_profile(&session.leader_profile_id) { + let _ = crate::browser_runner::kill_browser_profile(app_handle.clone(), leader).await; + } + + let _ = app_handle.emit("sync-session-ended", session_id); + Ok(()) + } + + /// Remove a single follower from an active session (user clicked stop on follower). + pub async fn remove_follower( + &self, + app_handle: tauri::AppHandle, + session_id: &str, + follower_profile_id: &str, + ) -> Result<(), String> { + let mut inner = self.inner.lock().await; + let session = inner + .sessions + .get_mut(session_id) + .ok_or("Session not found")?; + + session.followers.remove(follower_profile_id); + + // Kill the follower browser + if let Ok(fp) = Self::get_profile(follower_profile_id) { + let _ = crate::browser_runner::kill_browser_profile(app_handle.clone(), fp).await; + } + + // Emit updated session info + let info = SyncSessionInfo { + id: session.id.clone(), + leader_profile_id: session.leader_profile_id.clone(), + leader_profile_name: session.leader_profile_name.clone(), + followers: session.followers.values().cloned().collect(), + }; + let _ = app_handle.emit("sync-session-changed", &info); + + Ok(()) + } + + /// Get all active sync sessions. + pub async fn get_sessions(&self) -> Vec { + let inner = self.inner.lock().await; + inner + .sessions + .values() + .map(|s| SyncSessionInfo { + id: s.id.clone(), + leader_profile_id: s.leader_profile_id.clone(), + leader_profile_name: s.leader_profile_name.clone(), + followers: s.followers.values().cloned().collect(), + }) + .collect() + } + + // --- Helper methods --- + + fn get_profile(profile_id: &str) -> Result { + let profiles = ProfileManager::instance() + .list_profiles() + .map_err(|e| format!("Failed to list profiles: {e}"))?; + profiles + .into_iter() + .find(|p| p.id.to_string() == profile_id) + .ok_or(format!("Profile '{profile_id}' not found")) + } + + async fn get_cdp_port(profile: &BrowserProfile) -> Result { + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let profile_path = profile.get_profile_data_path(&profiles_dir); + let profile_path_str = profile_path.to_string_lossy(); + + for attempt in 0..15 { + if attempt > 0 { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + let port = crate::wayfern_manager::WayfernManager::instance() + .get_cdp_port(&profile_path_str) + .await; + if let Some(p) = port { + return Ok(p); + } + } + Err(format!( + "No CDP port available for profile '{}'. Browser may not be running.", + profile.name + )) + } + + /// Get a page-level WebSocket URL + async fn get_page_ws_url(port: u16) -> Result { + let url = format!("http://127.0.0.1:{port}/json"); + let client = reqwest::Client::new(); + for attempt in 0..15 { + if attempt > 0 { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + if let Ok(resp) = client + .get(&url) + .timeout(std::time::Duration::from_secs(3)) + .send() + .await + { + if let Ok(targets) = resp.json::>().await { + if let Some(ws_url) = targets + .iter() + .find(|t| t.get("type").and_then(|v| v.as_str()) == Some("page")) + .and_then(|t| t.get("webSocketDebuggerUrl")) + .and_then(|v| v.as_str()) + { + return Ok(ws_url.to_string()); + } + } + } + } + Err("Failed to get CDP WebSocket URL".to_string()) + } +} + +// --- Tauri Commands --- + +#[tauri::command] +pub async fn start_sync_session( + app_handle: tauri::AppHandle, + leader_profile_id: String, + follower_profile_ids: Vec, +) -> Result { + SynchronizerManager::instance() + .start_session(app_handle, leader_profile_id, follower_profile_ids) + .await +} + +#[tauri::command] +pub async fn stop_sync_session( + app_handle: tauri::AppHandle, + session_id: String, +) -> Result<(), String> { + SynchronizerManager::instance() + .stop_session(app_handle, &session_id) + .await +} + +#[tauri::command] +pub async fn remove_sync_follower( + app_handle: tauri::AppHandle, + session_id: String, + follower_profile_id: String, +) -> Result<(), String> { + SynchronizerManager::instance() + .remove_follower(app_handle, &session_id, &follower_profile_id) + .await +} + +#[tauri::command] +pub async fn get_sync_sessions() -> Result, String> { + Ok(SynchronizerManager::instance().get_sessions().await) +} diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index a47dc43..04054c9 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -514,6 +514,15 @@ impl WayfernManager { args.push(format!("--load-extension={}", extension_paths.join(","))); } + // Pass wayfern token as CLI flag so the browser can gate CDP features + let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; + if let Some(ref token) = wayfern_token { + args.push(format!("--wayfern-token={token}")); + log::info!("Wayfern token passed as CLI flag (length: {})", token.len()); + } else { + log::warn!("No wayfern token available — CDP gated methods will be blocked"); + } + // Don't add URL to args - we'll navigate via CDP after setting fingerprint // This ensures fingerprint is applied at navigation commit time @@ -674,25 +683,6 @@ impl WayfernManager { } } - // Close the debugging port to prevent localhost port-scan detection. - // Reopen on a random high port after 5s so we can still manage the browser. - let reopen_port = port; // Reopen on same port for find_wayfern_by_profile recovery - if let Some(target) = page_targets.first() { - if let Some(ws_url) = &target.websocket_debugger_url { - match self - .send_cdp_command( - ws_url, - "Wayfern.closeDebuggingPort", - json!({ "reopenPort": reopen_port, "reopenDelayMs": 30000 }), - ) - .await - { - Ok(_) => log::info!("Closed debugging port, will reopen on {reopen_port} after 30s"), - Err(e) => log::warn!("Failed to close debugging port: {e}"), - } - } - } - let id = uuid::Uuid::new_v4().to_string(); let instance = WayfernInstance { id: id.clone(), diff --git a/src/app/page.tsx b/src/app/page.tsx index 7a10936..b9f6153 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -29,6 +29,7 @@ import { ProxyManagementDialog } from "@/components/proxy-management-dialog"; import { SettingsDialog } from "@/components/settings-dialog"; import { SyncAllDialog } from "@/components/sync-all-dialog"; import { SyncConfigDialog } from "@/components/sync-config-dialog"; +import { SyncFollowerDialog } from "@/components/sync-follower-dialog"; import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog"; import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog"; import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications"; @@ -39,6 +40,7 @@ import type { PermissionType } from "@/hooks/use-permissions"; import { usePermissions } from "@/hooks/use-permissions"; import { useProfileEvents } from "@/hooks/use-profile-events"; import { useProxyEvents } from "@/hooks/use-proxy-events"; +import { useSyncSessions } from "@/hooks/use-sync-session"; import { useUpdateNotifications } from "@/hooks/use-update-notifications"; import { useVersionUpdater } from "@/hooks/use-version-updater"; import { useVpnEvents } from "@/hooks/use-vpn-events"; @@ -90,6 +92,11 @@ export default function Home() { const { vpnConfigs } = useVpnEvents(); + // Synchronizer sessions + const { getProfileSyncInfo } = useSyncSessions(); + const [syncLeaderProfile, setSyncLeaderProfile] = + useState(null); + // Wayfern terms and commercial trial hooks const { termsAccepted, @@ -802,6 +809,7 @@ export default function Home() { useEffect(() => { let unlistenStatus: (() => void) | undefined; let unlistenProgress: (() => void) | undefined; + const profilesWithTransfer = new Set(); (async () => { try { unlistenStatus = await listen<{ @@ -815,19 +823,15 @@ export default function Home() { const profile = profiles.find((p) => p.id === profile_id); const name = profile_name || profile?.name || "Unknown"; - if (status === "syncing") { - showToast({ - type: "loading", - title: `Syncing profile '${name}'...`, - id: toastId, - duration: Number.POSITIVE_INFINITY, - onCancel: () => dismissToast(toastId), - }); - } else if (status === "synced") { + if (status === "synced") { dismissToast(toastId); - showSuccessToast(`Profile '${name}' synced successfully`); + if (profilesWithTransfer.has(profile_id)) { + profilesWithTransfer.delete(profile_id); + showSuccessToast(`Profile '${name}' synced successfully`); + } } else if (status === "error") { dismissToast(toastId); + profilesWithTransfer.delete(profile_id); showErrorToast( `Failed to sync profile '${name}'${error ? `: ${error}` : ""}`, ); @@ -856,6 +860,7 @@ export default function Home() { payload.phase === "uploading" || payload.phase === "downloading" ) { + profilesWithTransfer.add(payload.profile_id); showSyncProgressToast( name, { @@ -1088,6 +1093,8 @@ export default function Home() { onToggleProfileSync={handleToggleProfileSync} crossOsUnlocked={crossOsUnlocked} syncUnlocked={syncUnlocked} + getProfileSyncInfo={getProfileSyncInfo} + onLaunchWithSync={(profile) => setSyncLeaderProfile(profile)} /> @@ -1319,6 +1326,14 @@ export default function Home() { windowResizeWarningResolver.current = null; }} /> + + setSyncLeaderProfile(null)} + leaderProfile={syncLeaderProfile} + allProfiles={profiles} + runningProfiles={runningProfiles} + /> ); } diff --git a/src/components/group-management-dialog.tsx b/src/components/group-management-dialog.tsx index 3478484..21edce4 100644 --- a/src/components/group-management-dialog.tsx +++ b/src/components/group-management-dialog.tsx @@ -240,152 +240,150 @@ 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}
+ )} - {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} - - + {/* 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 ( + + +
-
- - 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"} -

- )} +

{syncDot.tooltip}

- - -
- - - - - -

Edit group

-
-
- - - - - -

Delete group

-
-
-
-
- - ); - })} - -
-
-
- )} -
- + {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/home-header.tsx b/src/components/home-header.tsx index e87c48d..b09e3fa 100644 --- a/src/components/home-header.tsx +++ b/src/components/home-header.tsx @@ -1,3 +1,4 @@ +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { FaDownload } from "react-icons/fa"; import { FiWifi } from "react-icons/fi"; @@ -24,6 +25,148 @@ import { Input } from "./ui/input"; import { ProBadge } from "./ui/pro-badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; +const CLICK_THRESHOLD = 5; +const CLICK_WINDOW_MS = 2000; +const GRAVITY = 2200; +const BOUNCE_DAMPING = 0.6; +const INITIAL_HORIZONTAL_SPEED = 350; +const SPIN_SPEED = 720; +const MIN_BOUNCE_VELOCITY = 60; +const LOGO_HIDDEN_KEY = "donut-logo-hidden"; + +function useLogoEasterEgg() { + const clickTimestamps = useRef([]); + const [isPressed, setIsPressed] = useState(false); + const [wobbleKey, setWobbleKey] = useState(0); + const [isFalling, setIsFalling] = useState(false); + const [isHidden, setIsHidden] = useState(() => { + try { + return sessionStorage.getItem(LOGO_HIDDEN_KEY) === "1"; + } catch { + return false; + } + }); + const logoRef = useRef(null); + const animFrameRef = useRef(0); + + const triggerFall = useCallback(() => { + const el = logoRef.current; + if (!el || isFalling) return; + + setIsFalling(true); + + const rect = el.getBoundingClientRect(); + const startX = rect.left; + const startY = rect.top; + const floorY = window.innerHeight; + const leftWall = 0; + const rightWall = window.innerWidth; + + const clone = el.cloneNode(true) as HTMLElement; + clone.style.position = "fixed"; + clone.style.left = `${startX}px`; + clone.style.top = `${startY}px`; + clone.style.zIndex = "9999"; + clone.style.pointerEvents = "none"; + clone.style.margin = "0"; + document.body.appendChild(clone); + + el.style.visibility = "hidden"; + + let x = 0; + let y = 0; + let vy = -500; + let vx = -INITIAL_HORIZONTAL_SPEED; + let rotation = 0; + let lastTime = performance.now(); + + const animate = (time: number) => { + const dt = Math.min((time - lastTime) / 1000, 0.05); + lastTime = time; + + vy += GRAVITY * dt; + x += vx * dt; + y += vy * dt; + rotation += SPIN_SPEED * dt * (vx > 0 ? 1 : -1); + + // Floor bounce + const currentBottom = startY + y + rect.height; + if (currentBottom >= floorY && vy > 0) { + y = floorY - startY - rect.height; + if (Math.abs(vy) > MIN_BOUNCE_VELOCITY) { + vy = -Math.abs(vy) * BOUNCE_DAMPING; + } else { + vy = -MIN_BOUNCE_VELOCITY * 3; + } + } + + // Left wall bounce only — right wall lets it fly off screen + const currentLeft = startX + x; + if (currentLeft <= leftWall && vx < 0) { + x = leftWall - startX; + vx = Math.abs(vx) * 1.1; + } + + clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`; + + // Only end when fully off-screen vertically (bounced out the top or flew off bottom somehow) + const currentTop = startY + y; + const offScreenRight = startX + x > rightWall + 50; + const offScreenBottom = currentTop > floorY + 100; + const offScreenTop = currentTop + rect.height < -200; + + if (offScreenRight || offScreenBottom || offScreenTop) { + clone.remove(); + try { + sessionStorage.setItem(LOGO_HIDDEN_KEY, "1"); + } catch { + // ignore + } + setIsHidden(true); + setIsFalling(false); + return; + } + + animFrameRef.current = requestAnimationFrame(animate); + }; + + animFrameRef.current = requestAnimationFrame(animate); + }, [isFalling]); + + useEffect(() => { + return () => { + if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); + }; + }, []); + + const handleClick = useCallback(() => { + if (isFalling || isHidden) return; + + const now = Date.now(); + clickTimestamps.current = clickTimestamps.current.filter( + (t) => now - t < CLICK_WINDOW_MS, + ); + clickTimestamps.current.push(now); + + if (clickTimestamps.current.length >= CLICK_THRESHOLD) { + clickTimestamps.current = []; + triggerFall(); + } else { + setWobbleKey((k) => k + 1); + } + }, [isFalling, isHidden, triggerFall]); + + return { + logoRef, + isPressed, + setIsPressed, + wobbleKey, + isFalling, + isHidden, + handleClick, + }; +} + type Props = { onSettingsDialogOpen: (open: boolean) => void; onProxyManagementDialogOpen: (open: boolean) => void; @@ -52,24 +195,44 @@ const HomeHeader = ({ crossOsUnlocked = false, }: Props) => { const { t } = useTranslation(); - const handleLogoClick = () => { - // Trigger the same URL handling logic as if the URL came from the system - const event = new CustomEvent("url-open-request", { - detail: "https://donutbrowser.com", - }); - window.dispatchEvent(event); - }; + const { + logoRef, + isPressed, + setIsPressed, + wobbleKey, + isFalling, + isHidden, + handleClick, + } = useLogoEasterEgg(); + return (
- + {!isHidden ? ( + + ) : ( +
+ )} Donut
diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 4d2f0a0..fd6c227 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -25,6 +25,7 @@ import { LuLock, LuPuzzle, LuTrash2, + LuTriangleAlert, LuUsers, } from "react-icons/lu"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; @@ -83,6 +84,7 @@ import type { LocationItem, ProxyCheckResult, StoredProxy, + SyncSessionInfo, TrafficSnapshot, VpnConfig, } from "@/types"; @@ -204,6 +206,16 @@ type TableMeta = { // Team locks isProfileLockedByAnother: (profileId: string) => boolean; getProfileLockEmail: (profileId: string) => string | undefined; + + // Synchronizer + getProfileSyncInfo: (profileId: string) => + | { + session: SyncSessionInfo; + isLeader: boolean; + failedAtUrl: string | null; + } + | undefined; + onLaunchWithSync: (profile: BrowserProfile) => void; }; type SyncStatusDot = { @@ -242,7 +254,7 @@ function getProfileSyncStatusDot( case "waiting": return { color: "bg-warning", - tooltip: "Waiting to sync", + tooltip: "Close the profile to sync", animate: false, encrypted, }; @@ -801,6 +813,14 @@ interface ProfilesDataTableProps { onToggleProfileSync?: (profile: BrowserProfile) => void; crossOsUnlocked?: boolean; syncUnlocked?: boolean; + getProfileSyncInfo?: (profileId: string) => + | { + session: SyncSessionInfo; + isLeader: boolean; + failedAtUrl: string | null; + } + | undefined; + onLaunchWithSync?: (profile: BrowserProfile) => void; } export function ProfilesDataTable({ @@ -828,6 +848,8 @@ export function ProfilesDataTable({ onToggleProfileSync, crossOsUnlocked = false, syncUnlocked = false, + getProfileSyncInfo, + onLaunchWithSync, }: ProfilesDataTableProps) { const { t } = useTranslation(); const { getTableSorting, updateSorting, isLoaded } = useTableSorting(); @@ -951,8 +973,7 @@ export function ProfilesDataTable({ // Country proxy creation state (for inline proxy creation in dropdown) const [countries, setCountries] = React.useState([]); const [countriesLoaded, setCountriesLoaded] = React.useState(false); - const hasCloudProxy = storedProxies.some((p) => p.is_cloud_managed); - const canCreateLocationProxy = hasCloudProxy || crossOsUnlocked; + const canCreateLocationProxy = false; const loadCountries = React.useCallback(async () => { if (countriesLoaded || !canCreateLocationProxy) return; @@ -963,7 +984,7 @@ export function ProfilesDataTable({ } catch (e) { console.error("Failed to load countries:", e); } - }, [countriesLoaded, canCreateLocationProxy]); + }, [countriesLoaded]); // Load cached check results for proxies React.useEffect(() => { @@ -1528,6 +1549,10 @@ export function ProfilesDataTable({ isProfileLockedByAnother: isProfileLocked, getProfileLockEmail: (profileId: string) => getLockInfo(profileId)?.lockedByEmail, + + // Synchronizer + getProfileSyncInfo: getProfileSyncInfo ?? (() => undefined), + onLaunchWithSync: onLaunchWithSync ?? (() => {}), }), [ t, @@ -1577,11 +1602,12 @@ export function ProfilesDataTable({ crossOsUnlocked, syncUnlocked, countries, - canCreateLocationProxy, loadCountries, handleCreateCountryProxy, isProfileLocked, getLockInfo, + getProfileSyncInfo, + onLaunchWithSync, ], ); @@ -1806,23 +1832,81 @@ export function ProfilesDataTable({ } }; + const syncInfo = meta.getProfileSyncInfo(profile.id); + const isLeader = syncInfo?.isLeader === true; + const isFollower = syncInfo?.isLeader === false; + const isDesynced = isFollower && syncInfo?.failedAtUrl != null; + const stopTooltip = isLeader + ? meta.t("profiles.synchronizer.stopLeader") + : isFollower + ? meta.t("profiles.synchronizer.stopFollower", { + leaderName: syncInfo?.session.leader_profile_name ?? "", + }) + : tooltipContent; + + const handleStop = async () => { + if (isLeader && syncInfo) { + // Stop leader: invoke stop_sync_session which kills leader + all followers + try { + await invoke("stop_sync_session", { + sessionId: syncInfo.session.id, + }); + } catch (error) { + console.error("Failed to stop sync session:", error); + } + } else if (isFollower && syncInfo) { + // Stop follower: remove from session + try { + await invoke("remove_sync_follower", { + sessionId: syncInfo.session.id, + followerProfileId: profile.id, + }); + } catch (error) { + console.error("Failed to remove sync follower:", error); + } + } else { + await handleProfileStop(profile); + } + }; + + const buttonVariant = isRunning + ? isFollower + ? "secondary" + : "destructive" + : "default"; + return (
+ {isDesynced && ( + + + + + + + + {meta.t("profiles.synchronizer.desyncedTooltip", { + url: syncInfo?.failedAtUrl ?? "", + })} + + + )} isRunning - ? handleProfileStop(profile) + ? void handleStop() : handleProfileLaunch(profile) } > @@ -1838,8 +1922,10 @@ export function ProfilesDataTable({ - {tooltipContent && ( - {tooltipContent} + {(stopTooltip || tooltipContent) && ( + + {isRunning ? stopTooltip : tooltipContent} + )}
@@ -2188,28 +2274,35 @@ export function ProfilesDataTable({ /> None - {meta.storedProxies.map((proxy) => ( - - void meta.handleProxySelection( - profile.id, - proxy.id, - ) - } - > - - {proxy.name} - - ))} + {meta.storedProxies + .filter( + (proxy: StoredProxy) => + !proxy.is_cloud_managed && + !proxy.is_cloud_derived, + ) + .map((proxy: StoredProxy) => ( + + void meta.handleProxySelection( + profile.id, + proxy.id, + ) + } + > + + {proxy.name} + + ))} {meta.vpnConfigs.length > 0 && ( @@ -2519,6 +2612,7 @@ export function ProfilesDataTable({ onAssignExtensionGroup={onAssignExtensionGroup} onOpenBypassRules={(profile) => setBypassRulesProfile(profile)} onCloneProfile={onCloneProfile} + onLaunchWithSync={onLaunchWithSync} onDeleteProfile={(profile) => { setProfileForInfoDialog(null); setProfileToDelete(profile); diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 604b0a7..8a334d5 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -19,6 +19,7 @@ import { LuSettings, LuShieldCheck, LuTrash2, + LuUsers, LuX, } from "react-icons/lu"; import { Badge } from "@/components/ui/badge"; @@ -65,6 +66,7 @@ interface ProfileInfoDialogProps { onOpenBypassRules?: (profile: BrowserProfile) => void; onCloneProfile?: (profile: BrowserProfile) => void; onDeleteProfile?: (profile: BrowserProfile) => void; + onLaunchWithSync?: (profile: BrowserProfile) => void; crossOsUnlocked?: boolean; isRunning?: boolean; isDisabled?: boolean; @@ -110,6 +112,7 @@ export function ProfileInfoDialog({ onOpenBypassRules, onCloneProfile, onDeleteProfile, + onLaunchWithSync, crossOsUnlocked = false, isRunning = false, isDisabled = false, @@ -251,6 +254,14 @@ export function ProfileInfoDialog({ runningBadge: isRunning, hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox, }, + { + icon: , + label: t("profiles.synchronizer.launchWithSync"), + onClick: () => handleAction(() => onLaunchWithSync?.(profile)), + disabled: isDisabled || isRunning || !crossOsUnlocked, + proBadge: !crossOsUnlocked, + hidden: profile.browser !== "wayfern" || !onLaunchWithSync, + }, { icon: , label: t("profiles.actions.copyCookiesToProfile"), diff --git a/src/components/proxy-assignment-dialog.tsx b/src/components/proxy-assignment-dialog.tsx index 6c0abf3..5dc9d1d 100644 --- a/src/components/proxy-assignment-dialog.tsx +++ b/src/components/proxy-assignment-dialog.tsx @@ -186,9 +186,7 @@ export function ProxyAssignmentDialog({ const proxy = storedProxies.find( (p) => p.id === selectedId, ); - return proxy - ? `${proxy.name}${proxy.is_cloud_managed ? " (Included)" : ""}` - : "None"; + return proxy ? proxy.name : "None"; })()} @@ -216,28 +214,32 @@ export function ProxyAssignmentDialog({ /> None - {storedProxies.map((proxy) => ( - { - handleValueChange(proxy.id); - setProxyPopoverOpen(false); - }} - > - - {proxy.name} - {proxy.is_cloud_managed ? " (Included)" : ""} - - ))} + {storedProxies + .filter( + (proxy) => + !proxy.is_cloud_managed && !proxy.is_cloud_derived, + ) + .map((proxy) => ( + { + handleValueChange(proxy.id); + setProxyPopoverOpen(false); + }} + > + + {proxy.name} + + ))} {vpnConfigs.length > 0 && ( diff --git a/src/components/proxy-check-button.tsx b/src/components/proxy-check-button.tsx index a352ee9..282a75b 100644 --- a/src/components/proxy-check-button.tsx +++ b/src/components/proxy-check-button.tsx @@ -50,7 +50,9 @@ export function ProxyCheckButton({ try { const result = await invoke("check_proxy_validity", { proxyId: proxy.id, - proxySettings: proxy.proxy_settings, + proxySettings: proxy.dynamic_proxy_url + ? undefined + : proxy.proxy_settings, }); setLocalResult(result); onCheckComplete?.(result); diff --git a/src/components/proxy-form-dialog.tsx b/src/components/proxy-form-dialog.tsx index 4f6993e..9f8adfe 100644 --- a/src/components/proxy-form-dialog.tsx +++ b/src/components/proxy-form-dialog.tsx @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { LoadingButton } from "@/components/loading-button"; import { @@ -20,10 +21,11 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { StoredProxy } from "@/types"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { ProxySettings, StoredProxy } from "@/types"; import { RippleButton } from "./ui/ripple"; -interface ProxyFormData { +interface RegularFormData { name: string; proxy_type: string; host: string; @@ -32,6 +34,14 @@ interface ProxyFormData { password: string; } +interface DynamicFormData { + name: string; + url: string; + format: string; +} + +type ProxyMode = "regular" | "dynamic"; + interface ProxyFormDialogProps { isOpen: boolean; onClose: () => void; @@ -43,8 +53,11 @@ export function ProxyFormDialog({ onClose, editingProxy, }: ProxyFormDialogProps) { + const { t } = useTranslation(); const [isSubmitting, setIsSubmitting] = useState(false); - const [formData, setFormData] = useState({ + const [isTesting, setIsTesting] = useState(false); + const [mode, setMode] = useState("regular"); + const [regularForm, setRegularForm] = useState({ name: "", proxy_type: "http", host: "", @@ -52,9 +65,14 @@ export function ProxyFormDialog({ username: "", password: "", }); + const [dynamicForm, setDynamicForm] = useState({ + name: "", + url: "", + format: "json", + }); const resetForm = useCallback(() => { - setFormData({ + setRegularForm({ name: "", proxy_type: "http", host: "", @@ -62,62 +80,134 @@ export function ProxyFormDialog({ username: "", password: "", }); + setDynamicForm({ + name: "", + url: "", + format: "json", + }); + setMode("regular"); }, []); - // Load editing proxy data when dialog opens useEffect(() => { if (isOpen) { if (editingProxy) { - setFormData({ - name: editingProxy.name, - proxy_type: editingProxy.proxy_settings.proxy_type, - host: editingProxy.proxy_settings.host, - port: editingProxy.proxy_settings.port, - username: editingProxy.proxy_settings.username || "", - password: editingProxy.proxy_settings.password || "", - }); + if (editingProxy.dynamic_proxy_url) { + setMode("dynamic"); + setDynamicForm({ + name: editingProxy.name, + url: editingProxy.dynamic_proxy_url, + format: editingProxy.dynamic_proxy_format || "json", + }); + } else { + setMode("regular"); + setRegularForm({ + name: editingProxy.name, + proxy_type: editingProxy.proxy_settings.proxy_type, + host: editingProxy.proxy_settings.host, + port: editingProxy.proxy_settings.port, + username: editingProxy.proxy_settings.username || "", + password: editingProxy.proxy_settings.password || "", + }); + } } else { resetForm(); } } }, [isOpen, editingProxy, resetForm]); - const handleSubmit = useCallback(async () => { - if (!formData.name.trim()) { - toast.error("Proxy name is required"); + const handleTestDynamic = useCallback(async () => { + if (!dynamicForm.url.trim()) { + toast.error(t("proxies.dynamic.urlRequired")); return; } + setIsTesting(true); + try { + const settings = await invoke("fetch_dynamic_proxy", { + url: dynamicForm.url.trim(), + format: dynamicForm.format, + }); + toast.success( + t("proxies.dynamic.testSuccess", { + host: settings.host, + port: settings.port, + }), + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error(t("proxies.dynamic.testFailed", { error: errorMessage })); + } finally { + setIsTesting(false); + } + }, [dynamicForm, t]); - if (!formData.host.trim() || !formData.port) { - toast.error("Host and port are required"); - return; + const handleSubmit = useCallback(async () => { + if (mode === "regular") { + if (!regularForm.name.trim()) { + toast.error(t("proxies.form.nameRequired", "Proxy name is required")); + return; + } + if (!regularForm.host.trim() || !regularForm.port) { + toast.error( + t("proxies.form.hostPortRequired", "Host and port are required"), + ); + return; + } + } else { + if (!dynamicForm.name.trim()) { + toast.error(t("proxies.form.nameRequired", "Proxy name is required")); + return; + } + if (!dynamicForm.url.trim()) { + toast.error(t("proxies.dynamic.urlRequired")); + return; + } } setIsSubmitting(true); try { - const proxySettings = { - proxy_type: formData.proxy_type, - host: formData.host.trim(), - port: formData.port, - username: formData.username.trim() || undefined, - password: formData.password.trim() || undefined, - }; - if (editingProxy) { - // Update existing proxy - await invoke("update_stored_proxy", { - proxyId: editingProxy.id, - name: formData.name.trim(), - proxySettings, - }); - toast.success("Proxy updated successfully"); + if (mode === "dynamic") { + await invoke("update_stored_proxy", { + proxyId: editingProxy.id, + name: dynamicForm.name.trim(), + dynamicProxyUrl: dynamicForm.url.trim(), + dynamicProxyFormat: dynamicForm.format, + }); + } else { + await invoke("update_stored_proxy", { + proxyId: editingProxy.id, + name: regularForm.name.trim(), + proxySettings: { + proxy_type: regularForm.proxy_type, + host: regularForm.host.trim(), + port: regularForm.port, + username: regularForm.username.trim() || undefined, + password: regularForm.password.trim() || undefined, + }, + }); + } + toast.success(t("toasts.success.proxyUpdated")); } else { - // Create new proxy - await invoke("create_stored_proxy", { - name: formData.name.trim(), - proxySettings, - }); - toast.success("Proxy created successfully"); + if (mode === "dynamic") { + await invoke("create_stored_proxy", { + name: dynamicForm.name.trim(), + dynamicProxyUrl: dynamicForm.url.trim(), + dynamicProxyFormat: dynamicForm.format, + }); + } else { + await invoke("create_stored_proxy", { + name: regularForm.name.trim(), + proxySettings: { + proxy_type: regularForm.proxy_type, + host: regularForm.host.trim(), + port: regularForm.port, + username: regularForm.username.trim() || undefined, + password: regularForm.password.trim() || undefined, + }, + }); + } + toast.success(t("toasts.success.proxyCreated")); } onClose(); @@ -129,7 +219,7 @@ export function ProxyFormDialog({ } finally { setIsSubmitting(false); } - }, [formData, editingProxy, onClose]); + }, [mode, regularForm, dynamicForm, editingProxy, onClose, t]); const handleClose = useCallback(() => { if (!isSubmitting) { @@ -137,125 +227,227 @@ export function ProxyFormDialog({ } }, [isSubmitting, onClose]); - const isFormValid = - formData.name.trim() && - formData.host.trim() && - formData.port > 0 && - formData.port <= 65535; + const isRegularValid = + regularForm.name.trim() && + regularForm.host.trim() && + regularForm.port > 0 && + regularForm.port <= 65535; + + const isDynamicValid = dynamicForm.name.trim() && dynamicForm.url.trim(); + + const isFormValid = mode === "regular" ? isRegularValid : isDynamicValid; + + const isEditingDynamic = editingProxy?.dynamic_proxy_url != null; return ( - {editingProxy ? "Edit Proxy" : "Create New Proxy"} + {editingProxy ? t("proxies.edit") : t("proxies.add")}
-
- - - setFormData({ ...formData, name: e.target.value }) - } - placeholder="e.g. Office Proxy, Home VPN, etc." - disabled={isSubmitting} - /> -
+ {!editingProxy && ( + setMode(v as ProxyMode)}> + + + {t("proxies.tabs.regular")} + + + {t("proxies.tabs.dynamic")} + + + + )} -
- - -
+ {editingProxy && isEditingDynamic && ( +

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

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

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

+
+ + + {isTesting + ? t("proxies.dynamic.testing") + : t("proxies.dynamic.testUrl")} + + + )}
@@ -264,14 +456,14 @@ export function ProxyFormDialog({ onClick={handleClose} disabled={isSubmitting} > - Cancel + {t("common.cancel", "Cancel")} - {editingProxy ? "Update Proxy" : "Create Proxy"} + {editingProxy ? t("proxies.edit") : t("proxies.add")}
diff --git a/src/components/proxy-management-dialog.tsx b/src/components/proxy-management-dialog.tsx index 4a75368..d2ee703 100644 --- a/src/components/proxy-management-dialog.tsx +++ b/src/components/proxy-management-dialog.tsx @@ -3,7 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { emit, listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useState } from "react"; -import { GoGlobe, GoPlus } from "react-icons/go"; +import { GoPlus } from "react-icons/go"; import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu"; import { toast } from "sonner"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; @@ -40,8 +40,6 @@ import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useVpnEvents } from "@/hooks/use-vpn-events"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types"; -import { FlagIcon } from "./flag-icon"; -import { LocationProxyDialog } from "./location-proxy-dialog"; import { ProxyCheckButton } from "./proxy-check-button"; import { RippleButton } from "./ui/ripple"; import { VpnCheckButton } from "./vpn-check-button"; @@ -102,7 +100,6 @@ export function ProxyManagementDialog({ const [showProxyForm, setShowProxyForm] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false); const [showExportDialog, setShowExportDialog] = useState(false); - const [showLocationDialog, setShowLocationDialog] = useState(false); const [editingProxy, setEditingProxy] = useState(null); const [proxyToDelete, setProxyToDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); @@ -142,12 +139,10 @@ export function ProxyManagementDialog({ const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents(); const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents(); - // Filter out the base cloud-managed proxy (it's an internal indicator, not user-facing) - // Keep cloud-derived location proxies + // Filter out cloud-managed and cloud-derived proxies (cloud proxies are deprecated) const storedProxies = rawProxies - .filter((p) => !p.is_cloud_managed) + .filter((p) => !p.is_cloud_managed && !p.is_cloud_derived) .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(() => { @@ -412,17 +407,6 @@ export function ProxyManagementDialog({
- {hasCloudProxy && ( - setShowLocationDialog(true)} - className="flex gap-2 items-center" - > - - Location - - )}
- {isDerived && proxy.geo_country && ( - - )} - {!isDerived && ( - - -
- - -

{syncDot.tooltip}

-
- - )} + + +
+ + +

{syncDot.tooltip}

+
+ {proxy.name} + {proxy.dynamic_proxy_url && ( + + Dynamic + + )}
@@ -554,24 +537,22 @@ export function ProxyManagementDialog({ })); }} /> - {!isDerived && ( - - - - - -

Edit proxy

-
-
- )} + + + + + +

Edit proxy

+
+
@@ -830,11 +811,6 @@ export function ProxyManagementDialog({ isOpen={showExportDialog} onClose={() => setShowExportDialog(false)} /> - setShowLocationDialog(false)} - /> - void; + leaderProfile: BrowserProfile | null; + allProfiles: BrowserProfile[]; + runningProfiles: Set; +} + +export function SyncFollowerDialog({ + isOpen, + onClose, + leaderProfile, + allProfiles, + runningProfiles, +}: SyncFollowerDialogProps) { + const { t } = useTranslation(); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const eligibleProfiles = allProfiles.filter( + (p) => + p.id !== leaderProfile?.id && + p.browser === "wayfern" && + !runningProfiles.has(p.id) && + !isCrossOsProfile(p), + ); + + const leaderScreenSize = useMemo( + () => (leaderProfile ? getScreenSize(leaderProfile) : null), + [leaderProfile], + ); + + const handleToggle = useCallback((id: string, checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (checked) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + }, []); + + const handleStart = useCallback(() => { + if (!leaderProfile || selectedIds.size === 0) return; + const ids = Array.from(selectedIds); + const leaderId = leaderProfile.id; + setSelectedIds(new Set()); + onClose(); + + invoke("start_sync_session", { + leaderProfileId: leaderId, + followerProfileIds: ids, + }).catch((err) => { + console.error("Failed to start sync session:", err); + showErrorToast(err instanceof Error ? err.message : String(err)); + }); + }, [leaderProfile, selectedIds, onClose]); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + setSelectedIds(new Set()); + onClose(); + } + }, + [onClose], + ); + + return ( + + + + + {t("profiles.synchronizer.selectFollowers")} + + + {t("profiles.synchronizer.selectFollowersDesc")} + + + + {leaderProfile && ( +
+
+ + {t("profiles.synchronizer.leader")} + + + {leaderProfile.name} + +
+ +
+ +
+ {eligibleProfiles.length === 0 ? ( +

+ {t("profiles.synchronizer.wayfernOnly")} +

+ ) : ( + eligibleProfiles.map((profile) => { + const followerSize = getScreenSize(profile); + const isFlaky = + leaderScreenSize && + followerSize && + (leaderScreenSize.w !== followerSize.w || + leaderScreenSize.h !== followerSize.h); + + return ( +
+ handleToggle( + profile.id, + !selectedIds.has(profile.id), + ) + } + onKeyDown={() => {}} + role="button" + tabIndex={0} + > + + handleToggle(profile.id, checked === true) + } + /> + + {profile.name} + + {isFlaky && ( + + + + {t("profiles.synchronizer.flakyBadge")} + + + + {t("profiles.synchronizer.flakyTooltip")} + + + )} +
+ ); + }) + )} +
+
+
+
+ )} + + + handleOpenChange(false)} + > + {t("common.buttons.cancel")} + + + {t("profiles.synchronizer.startSession")} + + +
+
+ ); +} diff --git a/src/hooks/use-sync-session.ts b/src/hooks/use-sync-session.ts new file mode 100644 index 0000000..ae17244 --- /dev/null +++ b/src/hooks/use-sync-session.ts @@ -0,0 +1,89 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { useCallback, useEffect, useState } from "react"; +import type { SyncSessionInfo } from "@/types"; + +/** + * Hook to track active synchronizer sessions and provide helper methods + * for determining if a profile is a leader, follower, or desynced. + */ +export function useSyncSessions() { + const [sessions, setSessions] = useState([]); + + const loadSessions = useCallback(async () => { + try { + const data = await invoke("get_sync_sessions"); + setSessions(data); + } catch (err) { + console.error("Failed to load sync sessions:", err); + } + }, []); + + useEffect(() => { + let changedUnlisten: (() => void) | undefined; + let endedUnlisten: (() => void) | undefined; + + const setup = async () => { + await loadSessions(); + + changedUnlisten = await listen( + "sync-session-changed", + (event) => { + setSessions((prev) => { + const idx = prev.findIndex((s) => s.id === event.payload.id); + if (idx >= 0) { + const next = [...prev]; + next[idx] = event.payload; + return next; + } + return [...prev, event.payload]; + }); + }, + ); + + endedUnlisten = await listen("sync-session-ended", (event) => { + setSessions((prev) => prev.filter((s) => s.id !== event.payload)); + }); + }; + + void setup(); + + return () => { + changedUnlisten?.(); + endedUnlisten?.(); + }; + }, [loadSessions]); + + /** Find the session a profile belongs to and its role */ + const getProfileSyncInfo = useCallback( + ( + profileId: string, + ): + | { + session: SyncSessionInfo; + isLeader: boolean; + failedAtUrl: string | null; + } + | undefined => { + for (const session of sessions) { + if (session.leader_profile_id === profileId) { + return { session, isLeader: true, failedAtUrl: null }; + } + const follower = session.followers.find( + (f) => f.profile_id === profileId, + ); + if (follower) { + return { + session, + isLeader: false, + failedAtUrl: follower.failed_at_url, + }; + } + } + return undefined; + }, + [sessions], + ); + + return { sessions, getProfileSyncInfo, loadSessions }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cb72e39..4789d0a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -184,6 +184,22 @@ "changeFingerprint": "Change Fingerprint", "copyCookiesToProfile": "Copy Cookies to Profile" }, + "synchronizer": { + "launchWithSync": "Launch with Synchronizer", + "stopLeader": "Stop this profile and all its followers", + "stopFollower": "Following actions of {{leaderName}}", + "desyncedTooltip": "Synchronization failed at {{url}}", + "paidFeature": "Synchronizer is a paid feature", + "wayfernOnly": "Only Wayfern profiles can be synchronized", + "selectFollowers": "Select Follower Profiles", + "selectFollowersDesc": "Choose profiles that will mirror the actions of the leader profile. Only stopped Wayfern profiles can be selected.", + "leader": "Leader", + "follower": "Follower", + "startSession": "Start Sync Session", + "noFollowers": "Select at least one follower profile", + "flakyBadge": "FLAKY", + "flakyTooltip": "This profile has a different screen resolution than the leader. Page layouts may differ, causing clicks and interactions to hit the wrong elements." + }, "ephemeral": "Ephemeral", "ephemeralDescription": "The browser is forced to write profile data into memory instead of disk. Data is deleted when the browser is closed.", "ephemeralBadge": "Ephemeral", @@ -264,6 +280,26 @@ "socks4": "SOCKS4", "socks5": "SOCKS5" }, + "tabs": { + "regular": "Regular", + "dynamic": "Dynamic" + }, + "dynamic": { + "description": "Dynamic proxy fetches connection details from a URL each time a profile is launched.", + "url": "Proxy URL", + "urlPlaceholder": "https://api.example.com/proxy", + "urlRequired": "Dynamic proxy URL is required", + "format": "Response Format", + "formatJson": "JSON", + "formatText": "Text", + "formatJsonHint": "Expects JSON: {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}", + "formatTextHint": "Expects text like: host:port:username:password or protocol://user:pass@host:port", + "testUrl": "Test URL", + "testing": "Testing...", + "testSuccess": "Dynamic proxy resolved to {{host}}:{{port}}", + "testFailed": "Failed to fetch proxy: {{error}}", + "fetchFailed": "Failed to fetch dynamic proxy: {{error}}" + }, "check": { "checking": "Checking proxy...", "valid": "Proxy is valid", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e3b2a2c..7c340cd 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -184,6 +184,22 @@ "changeFingerprint": "Cambiar Huella Digital", "copyCookiesToProfile": "Copiar Cookies al Perfil" }, + "synchronizer": { + "launchWithSync": "Lanzar con Sincronizador", + "stopLeader": "Detener este perfil y todos sus seguidores", + "stopFollower": "Siguiendo las acciones de {{leaderName}}", + "desyncedTooltip": "La sincronización falló en {{url}}", + "paidFeature": "El sincronizador es una función de pago", + "wayfernOnly": "Solo los perfiles Wayfern pueden sincronizarse", + "selectFollowers": "Seleccionar perfiles seguidores", + "selectFollowersDesc": "Elige los perfiles que replicarán las acciones del perfil líder. Solo se pueden seleccionar perfiles Wayfern detenidos.", + "leader": "Líder", + "follower": "Seguidor", + "startSession": "Iniciar sesión de sincronización", + "noFollowers": "Selecciona al menos un perfil seguidor", + "flakyBadge": "FLAKY", + "flakyTooltip": "Este perfil tiene una resolución de pantalla diferente a la del líder. El diseño de las páginas puede variar, lo que puede causar que los clics e interacciones fallen." + }, "ephemeral": "Efímero", "ephemeralDescription": "El navegador es forzado a escribir los datos del perfil en memoria en lugar del disco. Los datos se eliminan al cerrar el navegador.", "ephemeralBadge": "Efímero", @@ -264,6 +280,26 @@ "socks4": "SOCKS4", "socks5": "SOCKS5" }, + "tabs": { + "regular": "Regular", + "dynamic": "Dinámico" + }, + "dynamic": { + "description": "El proxy dinámico obtiene los detalles de conexión desde una URL cada vez que se inicia un perfil.", + "url": "URL del Proxy", + "urlPlaceholder": "https://api.example.com/proxy", + "urlRequired": "La URL del proxy dinámico es obligatoria", + "format": "Formato de Respuesta", + "formatJson": "JSON", + "formatText": "Texto", + "formatJsonHint": "Espera JSON: {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}", + "formatTextHint": "Espera texto como: host:port:username:password o protocol://user:pass@host:port", + "testUrl": "Probar URL", + "testing": "Probando...", + "testSuccess": "El proxy dinámico se resolvió a {{host}}:{{port}}", + "testFailed": "Error al obtener el proxy: {{error}}", + "fetchFailed": "Error al obtener el proxy dinámico: {{error}}" + }, "check": { "checking": "Verificando proxy...", "valid": "El proxy es válido", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2e39070..26a9b9e 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -184,6 +184,22 @@ "changeFingerprint": "Changer l'Empreinte", "copyCookiesToProfile": "Copier les Cookies vers le Profil" }, + "synchronizer": { + "launchWithSync": "Lancer avec le synchroniseur", + "stopLeader": "Arrêter ce profil et tous ses suiveurs", + "stopFollower": "Suit les actions de {{leaderName}}", + "desyncedTooltip": "La synchronisation a échoué à {{url}}", + "paidFeature": "Le synchroniseur est une fonctionnalité payante", + "wayfernOnly": "Seuls les profils Wayfern peuvent être synchronisés", + "selectFollowers": "Sélectionner les profils suiveurs", + "selectFollowersDesc": "Choisissez les profils qui reproduiront les actions du profil leader. Seuls les profils Wayfern arrêtés peuvent être sélectionnés.", + "leader": "Leader", + "follower": "Suiveur", + "startSession": "Démarrer la session de synchronisation", + "noFollowers": "Sélectionnez au moins un profil suiveur", + "flakyBadge": "FLAKY", + "flakyTooltip": "Ce profil a une résolution d'écran différente de celle du leader. La mise en page des pages peut différer, ce qui peut causer des clics et interactions erronés." + }, "ephemeral": "Éphémère", "ephemeralDescription": "Le navigateur est forcé d'écrire les données du profil en mémoire au lieu du disque. Les données sont supprimées à la fermeture du navigateur.", "ephemeralBadge": "Éphémère", @@ -264,6 +280,26 @@ "socks4": "SOCKS4", "socks5": "SOCKS5" }, + "tabs": { + "regular": "Standard", + "dynamic": "Dynamique" + }, + "dynamic": { + "description": "Le proxy dynamique récupère les détails de connexion depuis une URL à chaque lancement d'un profil.", + "url": "URL du Proxy", + "urlPlaceholder": "https://api.example.com/proxy", + "urlRequired": "L'URL du proxy dynamique est requise", + "format": "Format de Réponse", + "formatJson": "JSON", + "formatText": "Texte", + "formatJsonHint": "Attend du JSON : {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}", + "formatTextHint": "Attend du texte comme : host:port:username:password ou protocol://user:pass@host:port", + "testUrl": "Tester l'URL", + "testing": "Test en cours...", + "testSuccess": "Le proxy dynamique a été résolu en {{host}}:{{port}}", + "testFailed": "Échec de la récupération du proxy : {{error}}", + "fetchFailed": "Échec de la récupération du proxy dynamique : {{error}}" + }, "check": { "checking": "Vérification du proxy...", "valid": "Le proxy est valide", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 202fe07..f14ef00 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -184,6 +184,22 @@ "changeFingerprint": "フィンガープリントを変更", "copyCookiesToProfile": "Cookieをプロファイルにコピー" }, + "synchronizer": { + "launchWithSync": "シンクロナイザーで起動", + "stopLeader": "このプロフィールとすべてのフォロワーを停止", + "stopFollower": "{{leaderName}}のアクションを追従中", + "desyncedTooltip": "{{url}}で同期に失敗しました", + "paidFeature": "シンクロナイザーは有料機能です", + "wayfernOnly": "Wayfernプロフィールのみ同期可能です", + "selectFollowers": "フォロワープロフィールを選択", + "selectFollowersDesc": "リーダープロフィールのアクションを複製するプロフィールを選択してください。停止中のWayfernプロフィールのみ選択できます。", + "leader": "リーダー", + "follower": "フォロワー", + "startSession": "同期セッションを開始", + "noFollowers": "少なくとも1つのフォロワープロフィールを選択してください", + "flakyBadge": "FLAKY", + "flakyTooltip": "このプロフィールはリーダーと画面解像度が異なります。ページレイアウトが異なる可能性があり、クリックや操作が正しく動作しない場合があります。" + }, "ephemeral": "一時的", "ephemeralDescription": "ブラウザはプロファイルデータをディスクではなくメモリに書き込むよう強制されます。ブラウザを閉じるとデータは削除されます。", "ephemeralBadge": "一時的", @@ -264,6 +280,26 @@ "socks4": "SOCKS4", "socks5": "SOCKS5" }, + "tabs": { + "regular": "通常", + "dynamic": "ダイナミック" + }, + "dynamic": { + "description": "ダイナミックプロキシは、プロファイルが起動されるたびにURLから接続情報を取得します。", + "url": "プロキシURL", + "urlPlaceholder": "https://api.example.com/proxy", + "urlRequired": "ダイナミックプロキシのURLは必須です", + "format": "レスポンス形式", + "formatJson": "JSON", + "formatText": "テキスト", + "formatJsonHint": "JSON形式: {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}", + "formatTextHint": "テキスト形式: host:port:username:password または protocol://user:pass@host:port", + "testUrl": "URLをテスト", + "testing": "テスト中...", + "testSuccess": "ダイナミックプロキシは {{host}}:{{port}} に解決されました", + "testFailed": "プロキシの取得に失敗しました: {{error}}", + "fetchFailed": "ダイナミックプロキシの取得に失敗しました: {{error}}" + }, "check": { "checking": "プロキシを確認中...", "valid": "プロキシは有効です", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index e276364..717f175 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -184,6 +184,22 @@ "changeFingerprint": "Alterar Impressão Digital", "copyCookiesToProfile": "Copiar Cookies para o Perfil" }, + "synchronizer": { + "launchWithSync": "Iniciar com Sincronizador", + "stopLeader": "Parar este perfil e todos os seus seguidores", + "stopFollower": "Seguindo as ações de {{leaderName}}", + "desyncedTooltip": "A sincronização falhou em {{url}}", + "paidFeature": "O sincronizador é um recurso pago", + "wayfernOnly": "Apenas perfis Wayfern podem ser sincronizados", + "selectFollowers": "Selecionar perfis seguidores", + "selectFollowersDesc": "Escolha os perfis que replicarão as ações do perfil líder. Apenas perfis Wayfern parados podem ser selecionados.", + "leader": "Líder", + "follower": "Seguidor", + "startSession": "Iniciar sessão de sincronização", + "noFollowers": "Selecione pelo menos um perfil seguidor", + "flakyBadge": "FLAKY", + "flakyTooltip": "Este perfil tem uma resolução de tela diferente do líder. O layout das páginas pode variar, fazendo com que cliques e interações atinjam elementos errados." + }, "ephemeral": "Efêmero", "ephemeralDescription": "O navegador é forçado a gravar os dados do perfil na memória em vez do disco. Os dados são excluídos ao fechar o navegador.", "ephemeralBadge": "Efêmero", @@ -264,6 +280,26 @@ "socks4": "SOCKS4", "socks5": "SOCKS5" }, + "tabs": { + "regular": "Regular", + "dynamic": "Dinâmico" + }, + "dynamic": { + "description": "O proxy dinâmico obtém os detalhes de conexão de uma URL cada vez que um perfil é iniciado.", + "url": "URL do Proxy", + "urlPlaceholder": "https://api.example.com/proxy", + "urlRequired": "A URL do proxy dinâmico é obrigatória", + "format": "Formato de Resposta", + "formatJson": "JSON", + "formatText": "Texto", + "formatJsonHint": "Espera JSON: {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}", + "formatTextHint": "Espera texto como: host:port:username:password ou protocol://user:pass@host:port", + "testUrl": "Testar URL", + "testing": "Testando...", + "testSuccess": "O proxy dinâmico foi resolvido para {{host}}:{{port}}", + "testFailed": "Falha ao obter o proxy: {{error}}", + "fetchFailed": "Falha ao obter o proxy dinâmico: {{error}}" + }, "check": { "checking": "Verificando proxy...", "valid": "O proxy é válido", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index f2ef9ad..030a77b 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -184,6 +184,22 @@ "changeFingerprint": "Изменить отпечаток", "copyCookiesToProfile": "Копировать Cookie в профиль" }, + "synchronizer": { + "launchWithSync": "Запустить с синхронизатором", + "stopLeader": "Остановить этот профиль и всех его последователей", + "stopFollower": "Повторяет действия {{leaderName}}", + "desyncedTooltip": "Синхронизация не удалась на {{url}}", + "paidFeature": "Синхронизатор — платная функция", + "wayfernOnly": "Синхронизировать можно только профили Wayfern", + "selectFollowers": "Выберите профили-последователи", + "selectFollowersDesc": "Выберите профили, которые будут повторять действия профиля-лидера. Можно выбрать только остановленные профили Wayfern.", + "leader": "Лидер", + "follower": "Последователь", + "startSession": "Начать сессию синхронизации", + "noFollowers": "Выберите хотя бы один профиль-последователь", + "flakyBadge": "FLAKY", + "flakyTooltip": "У этого профиля разрешение экрана отличается от лидера. Макет страниц может отличаться, что может привести к неправильным кликам и взаимодействиям." + }, "ephemeral": "Временный", "ephemeralDescription": "Браузер принудительно записывает данные профиля в память вместо диска. Данные удаляются при закрытии браузера.", "ephemeralBadge": "Временный", @@ -264,6 +280,26 @@ "socks4": "SOCKS4", "socks5": "SOCKS5" }, + "tabs": { + "regular": "Обычный", + "dynamic": "Динамический" + }, + "dynamic": { + "description": "Динамический прокси получает данные подключения по URL при каждом запуске профиля.", + "url": "URL прокси", + "urlPlaceholder": "https://api.example.com/proxy", + "urlRequired": "URL динамического прокси обязателен", + "format": "Формат ответа", + "formatJson": "JSON", + "formatText": "Текст", + "formatJsonHint": "Ожидается JSON: {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}", + "formatTextHint": "Ожидается текст вида: host:port:username:password или protocol://user:pass@host:port", + "testUrl": "Проверить URL", + "testing": "Проверка...", + "testSuccess": "Динамический прокси разрешён в {{host}}:{{port}}", + "testFailed": "Не удалось получить прокси: {{error}}", + "fetchFailed": "Не удалось получить динамический прокси: {{error}}" + }, "check": { "checking": "Проверка прокси...", "valid": "Прокси действителен", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 89db2a9..9d6ee17 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -184,6 +184,22 @@ "changeFingerprint": "更改指纹", "copyCookiesToProfile": "复制 Cookies 到配置文件" }, + "synchronizer": { + "launchWithSync": "使用同步器启动", + "stopLeader": "停止此配置文件及其所有跟随者", + "stopFollower": "正在跟随 {{leaderName}} 的操作", + "desyncedTooltip": "在 {{url}} 同步失败", + "paidFeature": "同步器是付费功能", + "wayfernOnly": "只有 Wayfern 配置文件可以同步", + "selectFollowers": "选择跟随者配置文件", + "selectFollowersDesc": "选择将复制领导者配置文件操作的配置文件。只能选择已停止的 Wayfern 配置文件。", + "leader": "领导者", + "follower": "跟随者", + "startSession": "开始同步会话", + "noFollowers": "请至少选择一个跟随者配置文件", + "flakyBadge": "FLAKY", + "flakyTooltip": "此配置文件的屏幕分辨率与领导者不同。页面布局可能不同,导致点击和交互可能命中错误的元素。" + }, "ephemeral": "临时", "ephemeralDescription": "浏览器被强制将配置数据写入内存而非磁盘。关闭浏览器时数据将被删除。", "ephemeralBadge": "临时", @@ -264,6 +280,26 @@ "socks4": "SOCKS4", "socks5": "SOCKS5" }, + "tabs": { + "regular": "常规", + "dynamic": "动态" + }, + "dynamic": { + "description": "动态代理在每次启动配置文件时从URL获取连接详情。", + "url": "代理URL", + "urlPlaceholder": "https://api.example.com/proxy", + "urlRequired": "动态代理URL为必填项", + "format": "响应格式", + "formatJson": "JSON", + "formatText": "文本", + "formatJsonHint": "期望 JSON: {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}", + "formatTextHint": "期望文本格式: host:port:username:password 或 protocol://user:pass@host:port", + "testUrl": "测试URL", + "testing": "测试中...", + "testSuccess": "动态代理已解析为 {{host}}:{{port}}", + "testFailed": "获取代理失败: {{error}}", + "fetchFailed": "获取动态代理失败: {{error}}" + }, "check": { "checking": "检查代理中...", "valid": "代理有效", diff --git a/src/types.ts b/src/types.ts index 0220ada..3ee33cd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -134,6 +134,8 @@ export interface StoredProxy { geo_region?: string; geo_city?: string; geo_isp?: string; + dynamic_proxy_url?: string; + dynamic_proxy_format?: string; } export interface LocationItem { @@ -510,6 +512,20 @@ export interface WayfernLaunchResult { cdp_port?: number; } +// Synchronizer types +export interface SyncFollowerState { + profile_id: string; + profile_name: string; + failed_at_url: string | null; +} + +export interface SyncSessionInfo { + id: string; + leader_profile_id: string; + leader_profile_name: string; + followers: SyncFollowerState[]; +} + // Traffic stats types export interface BandwidthDataPoint { timestamp: number; diff --git a/tailwind.config.js b/tailwind.config.js index 9eadda5..3c123ae 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -8,6 +8,16 @@ export default { backgroundColor: { dark: "#000000", }, + keyframes: { + wiggle: { + "0%, 100%": { transform: "rotate(0deg)" }, + "25%": { transform: "rotate(-12deg)" }, + "75%": { transform: "rotate(12deg)" }, + }, + }, + animation: { + wiggle: "wiggle 0.3s ease-in-out", + }, }, }, };