feat: synchronizer

This commit is contained in:
zhom
2026-03-15 18:00:04 +04:00
parent e72874142b
commit 5bea6a32e0
38 changed files with 3943 additions and 957 deletions
+7
View File
@@ -13,6 +13,7 @@
"autoconfig", "autoconfig",
"autologin", "autologin",
"biomejs", "biomejs",
"boringtun",
"breezedark", "breezedark",
"browserforge", "browserforge",
"busctl", "busctl",
@@ -42,6 +43,7 @@
"DBAPI", "DBAPI",
"dconf", "dconf",
"debuginfo", "debuginfo",
"desynced",
"devedition", "devedition",
"direnv", "direnv",
"distro", "distro",
@@ -170,9 +172,11 @@
"repogen", "repogen",
"reportingpolicy", "reportingpolicy",
"reqwest", "reqwest",
"resvg",
"ridedott", "ridedott",
"rlib", "rlib",
"rsplit", "rsplit",
"rusqlite",
"rustc", "rustc",
"rwxr", "rwxr",
"SARIF", "SARIF",
@@ -192,6 +196,7 @@
"signon", "signon",
"signum", "signum",
"sklearn", "sklearn",
"smoltcp",
"SMTO", "SMTO",
"sonner", "sonner",
"splitn", "splitn",
@@ -212,6 +217,7 @@
"TERX", "TERX",
"testpass", "testpass",
"testuser", "testuser",
"thiserror",
"timedatectl", "timedatectl",
"titlebar", "titlebar",
"tkinter", "tkinter",
@@ -219,6 +225,7 @@
"tqdm", "tqdm",
"trackingprotection", "trackingprotection",
"trailhead", "trailhead",
"tungstenite",
"turbopack", "turbopack",
"turtledemo", "turtledemo",
"typer", "typer",
+23 -16
View File
@@ -652,6 +652,15 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "block2" name = "block2"
version = "0.6.2" version = "0.6.2"
@@ -932,6 +941,15 @@ dependencies = [
"toml 0.9.12+spec-1.1.0", "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]] [[package]]
name = "cc" name = "cc"
version = "1.2.57" version = "1.2.57"
@@ -1690,6 +1708,7 @@ dependencies = [
name = "donutbrowser" name = "donutbrowser"
version = "0.16.1" version = "0.16.1"
dependencies = [ dependencies = [
"aes",
"aes-gcm", "aes-gcm",
"argon2", "argon2",
"async-socks5", "async-socks5",
@@ -1699,6 +1718,7 @@ dependencies = [
"blake3", "blake3",
"boringtun", "boringtun",
"bzip2 0.6.1", "bzip2 0.6.1",
"cbc",
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"clap", "clap",
@@ -1717,7 +1737,6 @@ dependencies = [
"lazy_static", "lazy_static",
"libc", "libc",
"log", "log",
"lz4_flex",
"lzma-rs", "lzma-rs",
"maxminddb", "maxminddb",
"mime_guess", "mime_guess",
@@ -1727,6 +1746,7 @@ dependencies = [
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
"once_cell", "once_cell",
"pbkdf2",
"playwright", "playwright",
"quick-xml 0.39.2", "quick-xml 0.39.2",
"rand 0.9.2", "rand 0.9.2",
@@ -1738,6 +1758,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"serial_test", "serial_test",
"sha1",
"smoltcp", "smoltcp",
"sys-locale", "sys-locale",
"sysinfo", "sysinfo",
@@ -3167,6 +3188,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [ dependencies = [
"block-padding",
"generic-array", "generic-array",
] ]
@@ -3571,15 +3593,6 @@ dependencies = [
"imgref", "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]] [[package]]
name = "lzma-rs" name = "lzma-rs"
version = "0.3.0" version = "0.3.0"
@@ -7420,12 +7433,6 @@ dependencies = [
"utf-8", "utf-8",
] ]
[[package]]
name = "twox-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
[[package]] [[package]]
name = "typed-path" name = "typed-path"
version = "0.12.3" version = "0.12.3"
+4 -1
View File
@@ -81,6 +81,10 @@ utoipa = { version = "5", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.2" utoipa-axum = "0.2"
argon2 = "0.5" argon2 = "0.5"
aes-gcm = "0.10" aes-gcm = "0.10"
aes = "0.8"
cbc = "0.1"
pbkdf2 = "0.12"
sha1 = "0.10"
hyper = { version = "1.8", features = ["full"] } hyper = { version = "1.8", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] } hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1" http-body-util = "0.1"
@@ -101,7 +105,6 @@ maxminddb = "0.27"
quick-xml = { version = "0.39", features = ["serialize"] } quick-xml = { version = "0.39", features = ["serialize"] }
# VPN support # VPN support
lz4_flex = "0.11"
boringtun = "0.7" boringtun = "0.7"
smoltcp = { version = "0.11", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] } smoltcp = { version = "0.11", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
+62 -24
View File
@@ -111,13 +111,17 @@ struct ApiProxyResponse {
name: String, name: String,
#[schema(value_type = Object)] #[schema(value_type = Object)]
proxy_settings: ProxySettings, proxy_settings: ProxySettings,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema)]
struct CreateProxyRequest { struct CreateProxyRequest {
name: String, name: String,
#[schema(value_type = Object)] #[schema(value_type = Object)]
proxy_settings: ProxySettings, proxy_settings: Option<ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema)]
@@ -125,6 +129,8 @@ struct UpdateProxyRequest {
name: Option<String>, name: Option<String>,
#[schema(value_type = Object)] #[schema(value_type = Object)]
proxy_settings: Option<ProxySettings>, proxy_settings: Option<ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
} }
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema)]
@@ -1028,6 +1034,8 @@ async fn get_proxies(
.map(|p| ApiProxyResponse { .map(|p| ApiProxyResponse {
id: p.id, id: p.id,
name: p.name, name: p.name,
dynamic_proxy_url: p.dynamic_proxy_url,
dynamic_proxy_format: p.dynamic_proxy_format,
proxy_settings: p.proxy_settings, proxy_settings: p.proxy_settings,
}) })
.collect(), .collect(),
@@ -1061,6 +1069,8 @@ async fn get_proxy(
id: proxy.id, id: proxy.id,
name: proxy.name, name: proxy.name,
proxy_settings: proxy.proxy_settings, proxy_settings: proxy.proxy_settings,
dynamic_proxy_url: proxy.dynamic_proxy_url,
dynamic_proxy_format: proxy.dynamic_proxy_format,
})) }))
} else { } else {
Err(StatusCode::NOT_FOUND) Err(StatusCode::NOT_FOUND)
@@ -1086,14 +1096,27 @@ async fn create_proxy(
State(state): State<ApiServerState>, State(state): State<ApiServerState>,
Json(request): Json<CreateProxyRequest>, Json(request): Json<CreateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, StatusCode> { ) -> Result<Json<ApiProxyResponse>, StatusCode> {
match PROXY_MANAGER.create_stored_proxy( let result = if let (Some(url), Some(format)) =
&state.app_handle, (&request.dynamic_proxy_url, &request.dynamic_proxy_format)
request.name.clone(), {
request.proxy_settings, 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 { Ok(proxy) => Ok(Json(ApiProxyResponse {
id: proxy.id, id: proxy.id,
name: proxy.name, name: proxy.name,
dynamic_proxy_url: proxy.dynamic_proxy_url,
dynamic_proxy_format: proxy.dynamic_proxy_format,
proxy_settings: proxy.proxy_settings, proxy_settings: proxy.proxy_settings,
})), })),
Err(_) => Err(StatusCode::BAD_REQUEST), Err(_) => Err(StatusCode::BAD_REQUEST),
@@ -1124,28 +1147,29 @@ async fn update_proxy(
State(state): State<ApiServerState>, State(state): State<ApiServerState>,
Json(request): Json<UpdateProxyRequest>, Json(request): Json<UpdateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, StatusCode> { ) -> Result<Json<ApiProxyResponse>, StatusCode> {
let proxies = PROXY_MANAGER.get_stored_proxies(); let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(&id) || request.dynamic_proxy_url.is_some();
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());
match PROXY_MANAGER.update_stored_proxy( let result = if is_dynamic {
PROXY_MANAGER.update_dynamic_proxy(
&state.app_handle, &state.app_handle,
&id, &id,
Some(new_name.clone()), request.name,
Some(new_proxy_settings.clone()), request.dynamic_proxy_url,
) { request.dynamic_proxy_format,
Ok(_) => Ok(Json(ApiProxyResponse { )
id,
name: new_name,
proxy_settings: new_proxy_settings,
})),
Err(_) => Err(StatusCode::BAD_REQUEST),
}
} else { } 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<ApiServerState>, State(state): State<ApiServerState>,
Json(request): Json<RunProfileRequest>, Json(request): Json<RunProfileRequest>,
) -> Result<Json<RunProfileResponse>, StatusCode> { ) -> Result<Json<RunProfileResponse>, 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 headless = request.headless.unwrap_or(false);
let url = request.url; let url = request.url;
@@ -1357,6 +1388,13 @@ async fn open_url_in_profile(
State(state): State<ApiServerState>, State(state): State<ApiServerState>,
Json(request): Json<OpenUrlRequest>, Json(request): Json<OpenUrlRequest>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
if !crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await
{
return Err(StatusCode::PAYMENT_REQUIRED);
}
let browser_runner = crate::browser_runner::BrowserRunner::instance(); let browser_runner = crate::browser_runner::BrowserRunner::instance();
browser_runner browser_runner
+24 -8
View File
@@ -40,12 +40,25 @@ impl BrowserRunner {
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy, /// 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. /// 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( async fn resolve_proxy_with_refresh(
&self, &self,
proxy_id: Option<&String>, proxy_id: Option<&String>,
profile_id: Option<&str>, profile_id: Option<&str>,
) -> Option<ProxySettings> { ) -> Result<Option<ProxySettings>, String> {
let proxy_id = proxy_id?; 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) { if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
log::info!("Refreshing cloud proxy credentials before launch for proxy {proxy_id}"); log::info!("Refreshing cloud proxy credentials before launch for proxy {proxy_id}");
CLOUD_AUTH.sync_cloud_proxy().await; CLOUD_AUTH.sync_cloud_proxy().await;
@@ -53,10 +66,10 @@ impl BrowserRunner {
// For cloud-derived proxies, inject profile-specific sid for sticky sessions // For cloud-derived proxies, inject profile-specific sid for sticky sessions
if let Some(pid) = profile_id { if let Some(pid) = profile_id {
if PROXY_MANAGER.is_cloud_or_derived(proxy_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 /// Get the executable path for a browser profile
@@ -117,7 +130,8 @@ impl BrowserRunner {
// Refresh cloud proxy credentials if needed before resolving // Refresh cloud proxy credentials if needed before resolving
let mut upstream_proxy = self let mut upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string())) .resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await; .await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream // If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() { if upstream_proxy.is_none() {
@@ -375,7 +389,8 @@ impl BrowserRunner {
// Refresh cloud proxy credentials if needed before resolving // Refresh cloud proxy credentials if needed before resolving
let mut upstream_proxy = self let mut upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string())) .resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await; .await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream // If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() { if upstream_proxy.is_none() {
@@ -728,7 +743,8 @@ impl BrowserRunner {
// Refresh cloud proxy credentials before resolving // Refresh cloud proxy credentials before resolving
let upstream_proxy = self let upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string())) .resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await; .await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch // Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32; let temp_pid = 1u32;
@@ -2231,7 +2247,7 @@ pub async fn launch_browser_profile(
profile_for_launch.proxy_id.as_ref(), profile_for_launch.proxy_id.as_ref(),
Some(&profile_for_launch.id.to_string()), 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 profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() { if upstream_proxy.is_none() {
-74
View File
@@ -685,9 +685,6 @@ impl CamoufoxManager {
} }
} }
// Write search.json.mozlz4 with default search engines (DuckDuckGo + Google)
write_default_search_config(&profile_path);
self self
.launch_camoufox( .launch_camoufox(
&app_handle, &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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+3 -3
View File
@@ -1080,8 +1080,8 @@ impl CloudAuthManager {
// Sync cloud proxy credentials // Sync cloud proxy credentials
CLOUD_AUTH.sync_cloud_proxy().await; CLOUD_AUTH.sync_cloud_proxy().await;
// Refresh wayfern token every 12 hours (72 iterations of 10-minute loop) // Refresh wayfern token every 10 hours (60 iterations of 10-minute loop)
if wayfern_refresh_counter >= 72 { if wayfern_refresh_counter >= 60 {
wayfern_refresh_counter = 0; wayfern_refresh_counter = 0;
if CLOUD_AUTH.has_active_paid_subscription().await { if CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = CLOUD_AUTH.request_wayfern_token().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) => { Err(e) => {
log::debug!("Sync not configured, skipping missing profile check: {}", e); log::warn!("Sync not configured, skipping missing profile check: {}", e);
} }
} }
+186 -25
View File
@@ -7,6 +7,112 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tauri::AppHandle; 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<aes::Aes128>;
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
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::<sha1::Sha1>(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<String> {
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::<Pkcs7>(&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<u8> {
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::<Pkcs7>(&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 /// Unified cookie representation that works across both browser types
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnifiedCookie { pub struct UnifiedCookie {
@@ -77,6 +183,12 @@ impl CookieManager {
/// Windows epoch offset: seconds between 1601-01-01 and 1970-01-01 /// Windows epoch offset: seconds between 1601-01-01 and 1970-01-01
const WINDOWS_EPOCH_DIFF: i64 = 11644473600; 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 /// Get the cookie database path for a profile
fn get_cookie_db_path(profile: &BrowserProfile, profiles_dir: &Path) -> Result<PathBuf, String> { fn get_cookie_db_path(profile: &BrowserProfile, profiles_dir: &Path) -> Result<PathBuf, String> {
let profile_data_path = profile.get_profile_data_path(profiles_dir); let profile_data_path = profile.get_profile_data_path(profiles_dir);
@@ -155,31 +267,58 @@ impl CookieManager {
Ok(cookies) Ok(cookies)
} }
/// Read cookies from a Chrome/Wayfern profile /// Read cookies from a Chrome/Wayfern profile.
fn read_chrome_cookies(db_path: &Path) -> Result<Vec<UnifiedCookie>, String> { /// 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<Vec<UnifiedCookie>, String> {
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?; let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT name, value, host_key, path, expires_utc, is_secure, "SELECT name, value, host_key, path, expires_utc, is_secure,
is_httponly, samesite, creation_utc, last_access_utc is_httponly, samesite, creation_utc, last_access_utc, encrypted_value
FROM cookies", FROM cookies",
) )
.map_err(|e| format!("Failed to prepare statement: {e}"))?; .map_err(|e| format!("Failed to prepare statement: {e}"))?;
let cookies = stmt let cookies = stmt
.query_map([], |row| { .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<u8> = 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 { Ok(UnifiedCookie {
name: row.get(0)?, name,
value: row.get(1)?, value,
domain: row.get(2)?, domain,
path: row.get(3)?, path,
expires: Self::chrome_time_to_unix(row.get(4)?), expires: Self::chrome_time_to_unix(expires_utc),
is_secure: row.get::<_, i32>(5)? != 0, is_secure: is_secure != 0,
is_http_only: row.get::<_, i32>(6)? != 0, is_http_only: is_httponly != 0,
same_site: row.get(7)?, same_site: samesite,
creation_time: Self::chrome_time_to_unix(row.get(8)?), creation_time: Self::chrome_time_to_unix(creation_utc),
last_accessed: Self::chrome_time_to_unix(row.get(9)?), last_accessed: Self::chrome_time_to_unix(last_access_utc),
}) })
}) })
.map_err(|e| format!("Failed to query cookies: {e}"))? .map_err(|e| format!("Failed to query cookies: {e}"))?
@@ -256,10 +395,12 @@ impl CookieManager {
Ok((copied, replaced)) 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( fn write_chrome_cookies(
db_path: &Path, db_path: &Path,
cookies: &[UnifiedCookie], cookies: &[UnifiedCookie],
encryption_key: Option<&[u8; 16]>,
) -> Result<(usize, usize), String> { ) -> Result<(usize, usize), String> {
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?; 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; .as_secs() as i64;
for cookie in cookies { for cookie in cookies {
// Prepare value/encrypted_value based on whether we have an encryption key
let (value_str, encrypted_bytes): (&str, Vec<u8>) = match encryption_key {
Some(key) => ("", chrome_decrypt::encrypt(&cookie.value, key)),
None => (cookie.value.as_str(), Vec::new()),
};
let existing: Option<i64> = conn let existing: Option<i64> = conn
.query_row( .query_row(
"SELECT rowid FROM cookies WHERE host_key = ?1 AND name = ?2 AND path = ?3", "SELECT rowid FROM cookies WHERE host_key = ?1 AND name = ?2 AND path = ?3",
@@ -283,11 +430,12 @@ impl CookieManager {
if existing.is_some() { if existing.is_some() {
conn conn
.execute( .execute(
"UPDATE cookies SET value = ?1, expires_utc = ?2, is_secure = ?3, "UPDATE cookies SET value = ?1, encrypted_value = ?2, expires_utc = ?3, is_secure = ?4,
is_httponly = ?4, samesite = ?5, last_access_utc = ?6, last_update_utc = ?7 is_httponly = ?5, samesite = ?6, last_access_utc = ?7, last_update_utc = ?8
WHERE host_key = ?8 AND name = ?9 AND path = ?10", WHERE host_key = ?9 AND name = ?10 AND path = ?11",
params![ params![
&cookie.value, value_str,
encrypted_bytes,
Self::unix_to_chrome_time(cookie.expires), Self::unix_to_chrome_time(cookie.expires),
cookie.is_secure as i32, cookie.is_secure as i32,
cookie.is_http_only 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, path, expires_utc, is_secure, is_httponly, last_access_utc, has_expires,
is_persistent, priority, samesite, source_scheme, source_port, source_type, is_persistent, priority, samesite, source_scheme, source_port, source_type,
has_cross_site_ancestor, last_update_utc) 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![ params![
Self::unix_to_chrome_time(cookie.creation_time), Self::unix_to_chrome_time(cookie.creation_time),
&cookie.domain, &cookie.domain,
&cookie.name, &cookie.name,
&cookie.value, value_str,
encrypted_bytes,
&cookie.path, &cookie.path,
Self::unix_to_chrome_time(cookie.expires), Self::unix_to_chrome_time(cookie.expires),
cookie.is_secure as i32, cookie.is_secure as i32,
@@ -348,7 +497,10 @@ impl CookieManager {
let cookies = match profile.browser.as_str() { let cookies = match profile.browser.as_str() {
"camoufox" => Self::read_firefox_cookies(&db_path)?, "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)), _ => 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 source_db_path = Self::get_cookie_db_path(source, &profiles_dir)?;
let all_cookies = match source.browser.as_str() { let all_cookies = match source.browser.as_str() {
"camoufox" => Self::read_firefox_cookies(&source_db_path)?, "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)), _ => return Err(format!("Unsupported browser type: {}", source.browser)),
}; };
@@ -468,7 +623,10 @@ impl CookieManager {
let write_result = match target.browser.as_str() { let write_result = match target.browser.as_str() {
"camoufox" => Self::write_firefox_cookies(&target_db_path, &cookies_to_copy), "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 { results.push(CookieCopyResult {
target_profile_id: target_id.clone(), target_profile_id: target_id.clone(),
@@ -733,7 +891,10 @@ impl CookieManager {
let write_result = match profile.browser.as_str() { let write_result = match profile.browser.as_str() {
"camoufox" => Self::write_firefox_cookies(&db_path, &cookies), "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)), _ => return Err(format!("Unsupported browser type: {}", profile.browser)),
}; };
-173
View File
@@ -292,13 +292,6 @@ impl Downloader {
Ok(()) Ok(())
} }
fn configure_camoufox_search_engine(
&self,
browser_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
configure_camoufox_search_engine(browser_dir)
}
pub async fn download_browser<R: tauri::Runtime>( pub async fn download_browser<R: tauri::Runtime>(
&self, &self,
_app_handle: &tauri::AppHandle<R>, _app_handle: &tauri::AppHandle<R>,
@@ -850,10 +843,6 @@ impl Downloader {
{ {
log::warn!("Failed to create version.json for Camoufox: {e}"); 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 // 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: `<browser_dir>/<app>.app/Contents/Resources/distribution/`
/// On Linux: `<browser_dir>/camoufox/distribution/`
/// On Windows: `<browser_dir>/distribution/`
/// Also includes `<browser_dir>/distribution/` as a fallback for all platforms.
#[allow(clippy::vec_init_then_push)]
fn find_camoufox_distribution_dirs(browser_dir: &Path) -> Vec<std::path::PathBuf> {
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<dyn std::error::Error + Send + Sync>> {
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::<serde_json::Value>(&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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+65 -10
View File
@@ -37,6 +37,7 @@ pub mod proxy_server;
pub mod proxy_storage; pub mod proxy_storage;
mod settings_manager; mod settings_manager;
pub mod sync; pub mod sync;
mod synchronizer;
pub mod traffic_stats; pub mod traffic_stats;
mod wayfern_manager; mod wayfern_manager;
mod wayfern_terms; mod wayfern_terms;
@@ -208,11 +209,21 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
async fn create_stored_proxy( async fn create_stored_proxy(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
name: String, name: String,
proxy_settings: crate::browser::ProxySettings, proxy_settings: Option<crate::browser::ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
) -> Result<crate::proxy_manager::StoredProxy, String> { ) -> Result<crate::proxy_manager::StoredProxy, String> {
crate::proxy_manager::PROXY_MANAGER if let (Some(url), Some(format)) = (&dynamic_proxy_url, &dynamic_proxy_format) {
.create_stored_proxy(&app_handle, name, proxy_settings) crate::proxy_manager::PROXY_MANAGER
.map_err(|e| format!("Failed to create stored proxy: {e}")) .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] #[tauri::command]
@@ -226,10 +237,26 @@ async fn update_stored_proxy(
proxy_id: String, proxy_id: String,
name: Option<String>, name: Option<String>,
proxy_settings: Option<crate::browser::ProxySettings>, proxy_settings: Option<crate::browser::ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
) -> Result<crate::proxy_manager::StoredProxy, String> { ) -> Result<crate::proxy_manager::StoredProxy, String> {
crate::proxy_manager::PROXY_MANAGER // Check if this is a dynamic proxy update
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings) let is_dynamic = crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id);
.map_err(|e| format!("Failed to update stored proxy: {e}")) 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] #[tauri::command]
@@ -242,10 +269,32 @@ async fn delete_stored_proxy(app_handle: tauri::AppHandle, proxy_id: String) ->
#[tauri::command] #[tauri::command]
async fn check_proxy_validity( async fn check_proxy_validity(
proxy_id: String, proxy_id: String,
proxy_settings: crate::browser::ProxySettings, proxy_settings: Option<crate::browser::ProxySettings>,
) -> Result<crate::proxy_manager::ProxyCheckResult, String> { ) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
// 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 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::browser::ProxySettings, String> {
crate::proxy_manager::PROXY_MANAGER
.fetch_dynamic_proxy(&url, &format)
.await .await
} }
@@ -1477,7 +1526,7 @@ pub fn run() {
} }
} }
Err(e) => { 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, update_stored_proxy,
delete_stored_proxy, delete_stored_proxy,
check_proxy_validity, check_proxy_validity,
fetch_dynamic_proxy,
get_cached_proxy_check, get_cached_proxy_check,
export_proxies, export_proxies,
import_proxies_json, import_proxies_json,
@@ -1669,6 +1719,11 @@ pub fn run() {
// Team lock commands // Team lock commands
team_lock::get_team_locks, team_lock::get_team_locks,
team_lock::get_team_lock_status, 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!()) .build(tauri::generate_context!())
.expect("error while building tauri application") .expect("error while building tauri application")
+381 -77
View File
@@ -96,6 +96,16 @@ impl McpServer {
self.is_running.load(Ordering::SeqCst) 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<u16> { pub fn get_port(&self) -> Option<u16> {
let port = self.port.load(Ordering::SeqCst); let port = self.port.load(Ordering::SeqCst);
if port > 0 { if port > 0 {
@@ -561,7 +571,7 @@ impl McpServer {
}, },
McpTool { McpTool {
name: "create_proxy".to_string(), 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!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
@@ -572,26 +582,35 @@ impl McpServer {
"proxy_type": { "proxy_type": {
"type": "string", "type": "string",
"enum": ["http", "https", "socks4", "socks5"], "enum": ["http", "https", "socks4", "socks5"],
"description": "The type of proxy" "description": "The type of proxy (for regular proxies)"
}, },
"host": { "host": {
"type": "string", "type": "string",
"description": "The proxy host address" "description": "The proxy host address (for regular proxies)"
}, },
"port": { "port": {
"type": "integer", "type": "integer",
"description": "The proxy port number" "description": "The proxy port number (for regular proxies)"
}, },
"username": { "username": {
"type": "string", "type": "string",
"description": "Optional username for authentication" "description": "Optional username for authentication (for regular proxies)"
}, },
"password": { "password": {
"type": "string", "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 { McpTool {
@@ -611,23 +630,32 @@ impl McpServer {
"proxy_type": { "proxy_type": {
"type": "string", "type": "string",
"enum": ["http", "https", "socks4", "socks5"], "enum": ["http", "https", "socks4", "socks5"],
"description": "The type of proxy" "description": "The type of proxy (for regular proxies)"
}, },
"host": { "host": {
"type": "string", "type": "string",
"description": "The proxy host address" "description": "The proxy host address (for regular proxies)"
}, },
"port": { "port": {
"type": "integer", "type": "integer",
"description": "The proxy port number" "description": "The proxy port number (for regular proxies)"
}, },
"username": { "username": {
"type": "string", "type": "string",
"description": "Optional username for authentication" "description": "Optional username for authentication (for regular proxies)"
}, },
"password": { "password": {
"type": "string", "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"] "required": ["proxy_id"]
@@ -926,6 +954,66 @@ impl McpServer {
"required": ["profile_id"] "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 // Browser interaction tools
McpTool { McpTool {
name: "navigate".to_string(), name: "navigate".to_string(),
@@ -1165,7 +1253,10 @@ impl McpServer {
match tool_name { match tool_name {
"list_profiles" => self.handle_list_profiles().await, "list_profiles" => self.handle_list_profiles().await,
"get_profile" => self.handle_get_profile(&arguments).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, "kill_profile" => self.handle_kill_profile(&arguments).await,
"create_profile" => self.handle_create_profile(&arguments).await, "create_profile" => self.handle_create_profile(&arguments).await,
"update_profile" => self.handle_update_profile(&arguments).await, "update_profile" => self.handle_update_profile(&arguments).await,
@@ -1217,14 +1308,43 @@ impl McpServer {
// Team lock tools // Team lock tools
"get_team_locks" => self.handle_get_team_locks().await, "get_team_locks" => self.handle_get_team_locks().await,
"get_team_lock_status" => self.handle_get_team_lock_status(&arguments).await, "get_team_lock_status" => self.handle_get_team_lock_status(&arguments).await,
// Browser interaction tools // Synchronizer tools
"navigate" => self.handle_navigate(&arguments).await, "start_sync_session" => {
"screenshot" => self.handle_screenshot(&arguments).await, Self::require_paid_subscription("Synchronizer").await?;
"evaluate_javascript" => self.handle_evaluate_javascript(&arguments).await, self.handle_start_sync_session(&arguments).await
"click_element" => self.handle_click_element(&arguments).await, }
"type_text" => self.handle_type_text(&arguments).await, "stop_sync_session" => self.handle_stop_sync_session(&arguments).await,
"get_page_content" => self.handle_get_page_content(&arguments).await, "get_sync_sessions" => self.handle_get_sync_sessions().await,
"get_page_info" => self.handle_get_page_info(&arguments).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 { _ => Err(McpError {
code: -32602, code: -32602,
message: format!("Unknown tool: {tool_name}"), message: format!("Unknown tool: {tool_name}"),
@@ -2013,59 +2133,79 @@ impl McpServer {
message: "Missing name".to_string(), 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 inner = self.inner.lock().await;
let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError {
code: -32000, code: -32000,
message: "MCP server not properly initialized".to_string(), message: "MCP server not properly initialized".to_string(),
})?; })?;
let proxy = PROXY_MANAGER // Check if this is a dynamic proxy creation
.create_stored_proxy(app_handle, name.to_string(), proxy_settings) let dynamic_url = arguments.get("dynamic_proxy_url").and_then(|v| v.as_str());
.map_err(|e| McpError { let dynamic_format = arguments
code: -32000, .get("dynamic_proxy_format")
message: format!("Failed to create proxy: {e}"), .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!({ Ok(serde_json::json!({
"content": [{ "content": [{
@@ -2155,12 +2295,32 @@ impl McpServer {
message: "MCP server not properly initialized".to_string(), message: "MCP server not properly initialized".to_string(),
})?; })?;
let proxy = PROXY_MANAGER // Check for dynamic proxy fields
.update_stored_proxy(app_handle, proxy_id, name, proxy_settings) let dynamic_url = arguments
.map_err(|e| McpError { .get("dynamic_proxy_url")
code: -32000, .and_then(|v| v.as_str())
message: format!("Failed to update proxy: {e}"), .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!({ Ok(serde_json::json!({
"content": [{ "content": [{
@@ -3030,9 +3190,8 @@ impl McpServer {
let url = format!("http://127.0.0.1:{port}/json"); let url = format!("http://127.0.0.1:{port}/json");
let client = reqwest::Client::new(); let client = reqwest::Client::new();
// Retry connecting to CDP endpoint — Wayfern closes the debugging port // Retry connecting to CDP endpoint (browser may still be starting up)
// briefly after launch for anti-detection and reopens it after ~30s. let max_attempts = 15;
let max_attempts = 45;
let mut last_err = String::new(); let mut last_err = String::new();
for attempt in 0..max_attempts { for attempt in 0..max_attempts {
if attempt > 0 { if attempt > 0 {
@@ -3900,6 +4059,146 @@ impl McpServer {
}] }]
})) }))
} }
// --- Synchronizer handlers ---
async fn handle_start_sync_session(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
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<String> = 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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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! { lazy_static::lazy_static! {
@@ -3963,6 +4262,11 @@ mod tests {
// Team lock tools // Team lock tools
assert!(tool_names.contains(&"get_team_locks")); assert!(tool_names.contains(&"get_team_locks"));
assert!(tool_names.contains(&"get_team_lock_status")); 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 // Browser interaction tools
assert!(tool_names.contains(&"navigate")); assert!(tool_names.contains(&"navigate"));
assert!(tool_names.contains(&"screenshot")); assert!(tool_names.contains(&"screenshot"));
+279
View File
@@ -117,6 +117,10 @@ pub struct StoredProxy {
pub geo_city: Option<String>, pub geo_city: Option<String>,
#[serde(default)] #[serde(default)]
pub geo_isp: Option<String>, pub geo_isp: Option<String>,
#[serde(default)]
pub dynamic_proxy_url: Option<String>,
#[serde(default)]
pub dynamic_proxy_format: Option<String>,
} }
impl StoredProxy { impl StoredProxy {
@@ -135,9 +139,15 @@ impl StoredProxy {
geo_region: None, geo_region: None,
geo_city: None, geo_city: None,
geo_isp: 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 /// Migrate legacy geo_state to geo_region
pub fn migrate_geo_fields(&mut self) { pub fn migrate_geo_fields(&mut self) {
if self.geo_region.is_none() && self.geo_state.is_some() { if self.geo_region.is_none() && self.geo_state.is_some() {
@@ -450,6 +460,8 @@ impl ProxyManager {
geo_region: None, geo_region: None,
geo_city: None, geo_city: None,
geo_isp: None, geo_isp: None,
dynamic_proxy_url: None,
dynamic_proxy_format: None,
}; };
stored_proxies.insert(CLOUD_PROXY_ID.to_string(), cloud_proxy.clone()); stored_proxies.insert(CLOUD_PROXY_ID.to_string(), cloud_proxy.clone());
drop(stored_proxies); drop(stored_proxies);
@@ -639,6 +651,8 @@ impl ProxyManager {
geo_region: region, geo_region: region,
geo_city: city, geo_city: city,
geo_isp: isp, geo_isp: isp,
dynamic_proxy_url: None,
dynamic_proxy_format: None,
}; };
{ {
@@ -965,6 +979,269 @@ impl ProxyManager {
self.load_proxy_check_cache(proxy_id) 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<ProxySettings, String> {
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<ProxySettings, String> {
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<ProxySettings, String> {
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<ProxySettings, String> {
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<StoredProxy, String> {
{
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<String>,
url: Option<String>,
format: Option<String>,
) -> Result<StoredProxy, String> {
{
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 // Export all proxies as JSON
pub fn export_proxies_json(&self) -> Result<String, String> { pub fn export_proxies_json(&self) -> Result<String, String> {
let stored_proxies = self.stored_proxies.lock().unwrap(); let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -2835,6 +3112,8 @@ mod tests {
geo_region: None, geo_region: None,
geo_city: None, geo_city: None,
geo_isp: None, geo_isp: None,
dynamic_proxy_url: None,
dynamic_proxy_format: None,
}; };
// Before migration // Before migration
+30 -1
View File
@@ -127,6 +127,14 @@ impl SyncClient {
} }
pub async fn list(&self, prefix: &str) -> SyncResult<ListResponse> { pub async fn list(&self, prefix: &str) -> SyncResult<ListResponse> {
self.list_page(prefix, None).await
}
async fn list_page(
&self,
prefix: &str,
continuation_token: Option<String>,
) -> SyncResult<ListResponse> {
let response = self let response = self
.client .client
.post(self.url("list")) .post(self.url("list"))
@@ -134,7 +142,7 @@ impl SyncClient {
.json(&ListRequest { .json(&ListRequest {
prefix: prefix.to_string(), prefix: prefix.to_string(),
max_keys: Some(1000), max_keys: Some(1000),
continuation_token: None, continuation_token,
}) })
.send() .send()
.await .await
@@ -152,6 +160,27 @@ impl SyncClient {
.map_err(|e| SyncError::SerializationError(e.to_string())) .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<Vec<ListObject>> {
let mut all_objects = Vec::new();
let mut continuation_token: Option<String> = 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( pub async fn upload_bytes(
&self, &self,
presigned_url: &str, presigned_url: &str,
+224 -8
View File
@@ -9,7 +9,7 @@ use crate::settings_manager::SettingsManager;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::path::Path; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
@@ -49,6 +49,70 @@ fn is_critical_file(path: &str) -> bool {
.any(|pattern| path.contains(pattern)) .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<PathBuf>) {
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 /// Resume state persisted to disk so interrupted syncs can continue
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct SyncResumeState { 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 // Load or create hash cache
let cache_path = get_cache_path(&profile_dir); let cache_path = get_cache_path(&profile_dir);
let mut hash_cache = HashCache::load(&cache_path); let mut hash_cache = HashCache::load(&cache_path);
@@ -488,9 +556,22 @@ impl SyncEngine {
.upload_profile_metadata(&profile_id, profile, &key_prefix) .upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?; .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 // Upload manifest.json last for atomicity
let mut final_manifest = local_manifest;
final_manifest.encrypted = encryption_key.is_some();
self self
.upload_manifest(&profile_id, &final_manifest, &key_prefix) .upload_manifest(&profile_id, &final_manifest, &key_prefix)
.await?; .await?;
@@ -2165,14 +2246,14 @@ impl SyncEngine {
) -> SyncResult<Vec<String>> { ) -> SyncResult<Vec<String>> {
log::info!("Checking for missing synced profiles..."); log::info!("Checking for missing synced profiles...");
// List personal profiles from S3 // List all personal profiles from S3 (paginated)
let list_response = self.client.list("profiles/").await?; let all_objects = self.client.list_all("profiles/").await?;
let mut downloaded: Vec<String> = Vec::new(); let mut downloaded: Vec<String> = Vec::new();
// Extract unique profile IDs with their key prefix // Extract unique profile IDs with their key prefix
let mut profiles_to_check: HashMap<String, String> = HashMap::new(); let mut profiles_to_check: HashMap<String, String> = 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 obj.key.starts_with("profiles/") && obj.key.ends_with("/manifest.json") {
if let Some(profile_id) = obj if let Some(profile_id) = obj
.key .key
@@ -2189,8 +2270,8 @@ impl SyncEngine {
if let Some(team_id) = &auth.user.team_id { if let Some(team_id) = &auth.user.team_id {
let team_prefix = format!("teams/{}/", team_id); let team_prefix = format!("teams/{}/", team_id);
let team_list_key = format!("{}profiles/", team_prefix); let team_list_key = format!("{}profiles/", team_prefix);
if let Ok(team_list) = self.client.list(&team_list_key).await { if let Ok(team_objects) = self.client.list_all(&team_list_key).await {
for obj in team_list.objects { for obj in team_objects {
if obj.key.starts_with("profiles/") && obj.key.ends_with("/manifest.json") { if obj.key.starts_with("profiles/") && obj.key.ends_with("/manifest.json") {
if let Some(profile_id) = obj if let Some(profile_id) = obj
.key .key
@@ -3341,3 +3422,138 @@ pub async fn set_extension_group_sync_enabled(
Ok(()) 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);
}
}
+59
View File
@@ -408,6 +408,19 @@ pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> Mani
let remote_files: HashMap<&str, &ManifestFileEntry> = let remote_files: HashMap<&str, &ManifestFileEntry> =
remote.files.iter().map(|f| (f.path.as_str(), f)).collect(); 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 // Compare timestamps to determine direction
let local_updated = local.updated_at_datetime(); let local_updated = local.updated_at_datetime();
let remote_updated = remote.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(); let deserialized: SyncManifest = serde_json::from_str(&serialized).unwrap();
assert!(deserialized.encrypted); 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());
}
} }
+101 -86
View File
@@ -396,97 +396,112 @@ impl SyncScheduler {
ready ready
}; };
// Mark all profiles as in-flight and filter out duplicates
let mut to_sync = Vec::new();
for profile_id in profiles_to_sync { 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) {
let mut in_flight = self.in_flight_profiles.lock().await; log::debug!("Profile {} already in-flight, skipping", profile_id);
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);
continue; 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 // Sync all profiles in parallel
if sync_just_completed { let mut sync_set = tokio::task::JoinSet::new();
log::debug!("All profile syncs completed, triggering cleanup"); for profile_id in to_sync {
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(); let app = app_handle.clone();
if let Err(e) = registry.cleanup_unused_binaries() { let in_flight = self.in_flight_profiles.clone();
log::warn!("Cleanup after sync failed: {e}"); sync_set.spawn(async move {
} else { log::info!("Executing queued sync for profile {}", profile_id);
log::debug!("Cleanup after sync completed successfully"); 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");
} }
} }
} }
File diff suppressed because it is too large Load Diff
+9 -19
View File
@@ -514,6 +514,15 @@ impl WayfernManager {
args.push(format!("--load-extension={}", extension_paths.join(","))); 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 // Don't add URL to args - we'll navigate via CDP after setting fingerprint
// This ensures fingerprint is applied at navigation commit time // 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 id = uuid::Uuid::new_v4().to_string();
let instance = WayfernInstance { let instance = WayfernInstance {
id: id.clone(), id: id.clone(),
+25 -10
View File
@@ -29,6 +29,7 @@ import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { SettingsDialog } from "@/components/settings-dialog"; import { SettingsDialog } from "@/components/settings-dialog";
import { SyncAllDialog } from "@/components/sync-all-dialog"; import { SyncAllDialog } from "@/components/sync-all-dialog";
import { SyncConfigDialog } from "@/components/sync-config-dialog"; import { SyncConfigDialog } from "@/components/sync-config-dialog";
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog"; import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog"; import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications"; 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 { usePermissions } from "@/hooks/use-permissions";
import { useProfileEvents } from "@/hooks/use-profile-events"; import { useProfileEvents } from "@/hooks/use-profile-events";
import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useSyncSessions } from "@/hooks/use-sync-session";
import { useUpdateNotifications } from "@/hooks/use-update-notifications"; import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater"; import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events"; import { useVpnEvents } from "@/hooks/use-vpn-events";
@@ -90,6 +92,11 @@ export default function Home() {
const { vpnConfigs } = useVpnEvents(); const { vpnConfigs } = useVpnEvents();
// Synchronizer sessions
const { getProfileSyncInfo } = useSyncSessions();
const [syncLeaderProfile, setSyncLeaderProfile] =
useState<BrowserProfile | null>(null);
// Wayfern terms and commercial trial hooks // Wayfern terms and commercial trial hooks
const { const {
termsAccepted, termsAccepted,
@@ -802,6 +809,7 @@ export default function Home() {
useEffect(() => { useEffect(() => {
let unlistenStatus: (() => void) | undefined; let unlistenStatus: (() => void) | undefined;
let unlistenProgress: (() => void) | undefined; let unlistenProgress: (() => void) | undefined;
const profilesWithTransfer = new Set<string>();
(async () => { (async () => {
try { try {
unlistenStatus = await listen<{ unlistenStatus = await listen<{
@@ -815,19 +823,15 @@ export default function Home() {
const profile = profiles.find((p) => p.id === profile_id); const profile = profiles.find((p) => p.id === profile_id);
const name = profile_name || profile?.name || "Unknown"; const name = profile_name || profile?.name || "Unknown";
if (status === "syncing") { if (status === "synced") {
showToast({
type: "loading",
title: `Syncing profile '${name}'...`,
id: toastId,
duration: Number.POSITIVE_INFINITY,
onCancel: () => dismissToast(toastId),
});
} else if (status === "synced") {
dismissToast(toastId); 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") { } else if (status === "error") {
dismissToast(toastId); dismissToast(toastId);
profilesWithTransfer.delete(profile_id);
showErrorToast( showErrorToast(
`Failed to sync profile '${name}'${error ? `: ${error}` : ""}`, `Failed to sync profile '${name}'${error ? `: ${error}` : ""}`,
); );
@@ -856,6 +860,7 @@ export default function Home() {
payload.phase === "uploading" || payload.phase === "uploading" ||
payload.phase === "downloading" payload.phase === "downloading"
) { ) {
profilesWithTransfer.add(payload.profile_id);
showSyncProgressToast( showSyncProgressToast(
name, name,
{ {
@@ -1088,6 +1093,8 @@ export default function Home() {
onToggleProfileSync={handleToggleProfileSync} onToggleProfileSync={handleToggleProfileSync}
crossOsUnlocked={crossOsUnlocked} crossOsUnlocked={crossOsUnlocked}
syncUnlocked={syncUnlocked} syncUnlocked={syncUnlocked}
getProfileSyncInfo={getProfileSyncInfo}
onLaunchWithSync={(profile) => setSyncLeaderProfile(profile)}
/> />
</div> </div>
</main> </main>
@@ -1319,6 +1326,14 @@ export default function Home() {
windowResizeWarningResolver.current = null; windowResizeWarningResolver.current = null;
}} }}
/> />
<SyncFollowerDialog
isOpen={syncLeaderProfile !== null}
onClose={() => setSyncLeaderProfile(null)}
leaderProfile={syncLeaderProfile}
allProfiles={profiles}
runningProfiles={runningProfiles}
/>
</div> </div>
); );
} }
+136 -138
View File
@@ -240,152 +240,150 @@ export function GroupManagementDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ScrollArea className="overflow-y-auto flex-1"> <div className="space-y-4">
<div className="space-y-4"> {/* Create new group button */}
{/* Create new group button */} <div className="flex justify-between items-center">
<div className="flex justify-between items-center"> <Label>Groups</Label>
<Label>Groups</Label> <RippleButton
<RippleButton size="sm"
size="sm" onClick={() => setCreateDialogOpen(true)}
onClick={() => setCreateDialogOpen(true)} className="flex gap-2 items-center"
className="flex gap-2 items-center" >
> <GoPlus className="w-4 h-4" />
<GoPlus className="w-4 h-4" /> Create
Create </RippleButton>
</RippleButton> </div>
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div> </div>
)}
{error && ( {/* Groups list */}
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md"> {isLoading ? (
{error} <div className="text-sm text-muted-foreground">
</div> Loading groups...
)} </div>
) : groups.length === 0 ? (
{/* Groups list */} <div className="text-sm text-muted-foreground">
{isLoading ? ( No groups created yet. Create your first group using the button
<div className="text-sm text-muted-foreground"> above.
Loading groups... </div>
</div> ) : (
) : groups.length === 0 ? ( <div className="border rounded-md">
<div className="text-sm text-muted-foreground"> <ScrollArea className="h-[240px]">
No groups created yet. Create your first group using the <Table>
button above. <TableHeader>
</div> <TableRow>
) : ( <TableHead>Name</TableHead>
<div className="border rounded-md"> <TableHead className="w-20">Profiles</TableHead>
<ScrollArea className="h-[240px]"> <TableHead className="w-24">Sync</TableHead>
<Table> <TableHead className="w-24">Actions</TableHead>
<TableHeader> </TableRow>
<TableRow> </TableHeader>
<TableHead>Name</TableHead> <TableBody>
<TableHead className="w-20">Profiles</TableHead> {groups.map((group) => {
<TableHead className="w-24">Sync</TableHead> const syncDot = getSyncStatusDot(
<TableHead className="w-24">Actions</TableHead> group,
</TableRow> groupSyncStatus[group.id],
</TableHeader> groupSyncErrors[group.id],
<TableBody> );
{groups.map((group) => { return (
const syncDot = getSyncStatusDot( <TableRow key={group.id}>
group, <TableCell className="font-medium">
groupSyncStatus[group.id], <div className="flex items-center gap-2">
groupSyncErrors[group.id],
);
return (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate ? "animate-pulse" : ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{group.name}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">{group.count}</Badge>
</TableCell>
<TableCell>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <div
<Checkbox className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
checked={group.sync_enabled} syncDot.animate ? "animate-pulse" : ""
onCheckedChange={() => }`}
handleToggleSync(group) />
}
disabled={
isTogglingSync[group.id] ||
groupInUse[group.id]
}
/>
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{groupInUse[group.id] ? ( <p>{syncDot.tooltip}</p>
<p>
Sync cannot be disabled while this group
is used by synced profiles
</p>
) : (
<p>
{group.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TableCell> {group.name}
<TableCell> </div>
<div className="flex gap-1"> </TableCell>
<Tooltip> <TableCell>
<TooltipTrigger asChild> <Badge variant="secondary">{group.count}</Badge>
<Button </TableCell>
variant="ghost" <TableCell>
size="sm" <Tooltip>
onClick={() => handleEditGroup(group)} <TooltipTrigger asChild>
> <div className="flex items-center">
<LuPencil className="w-4 h-4" /> <Checkbox
</Button> checked={group.sync_enabled}
</TooltipTrigger> onCheckedChange={() =>
<TooltipContent> handleToggleSync(group)
<p>Edit group</p> }
</TooltipContent> disabled={
</Tooltip> isTogglingSync[group.id] ||
<Tooltip> groupInUse[group.id]
<TooltipTrigger asChild> }
<Button />
variant="ghost" </div>
size="sm" </TooltipTrigger>
onClick={() => handleDeleteGroup(group)} <TooltipContent>
> {groupInUse[group.id] ? (
<LuTrash2 className="w-4 h-4" /> <p>
</Button> Sync cannot be disabled while this group
</TooltipTrigger> is used by synced profiles
<TooltipContent> </p>
<p>Delete group</p> ) : (
</TooltipContent> <p>
</Tooltip> {group.sync_enabled
</div> ? "Disable sync"
</TableCell> : "Enable sync"}
</TableRow> </p>
); )}
})} </TooltipContent>
</TableBody> </Tooltip>
</Table> </TableCell>
</ScrollArea> <TableCell>
</div> <div className="flex gap-1">
)} <Tooltip>
</div> <TooltipTrigger asChild>
</ScrollArea> <Button
variant="ghost"
size="sm"
onClick={() => handleEditGroup(group)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit group</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteGroup(group)}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete group</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
<DialogFooter> <DialogFooter>
<RippleButton variant="outline" onClick={onClose}> <RippleButton variant="outline" onClick={onClose}>
+178 -15
View File
@@ -1,3 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa"; import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi"; import { FiWifi } from "react-icons/fi";
@@ -24,6 +25,148 @@ import { Input } from "./ui/input";
import { ProBadge } from "./ui/pro-badge"; import { ProBadge } from "./ui/pro-badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; 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<number[]>([]);
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<HTMLButtonElement>(null);
const animFrameRef = useRef<number>(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 = { type Props = {
onSettingsDialogOpen: (open: boolean) => void; onSettingsDialogOpen: (open: boolean) => void;
onProxyManagementDialogOpen: (open: boolean) => void; onProxyManagementDialogOpen: (open: boolean) => void;
@@ -52,24 +195,44 @@ const HomeHeader = ({
crossOsUnlocked = false, crossOsUnlocked = false,
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const handleLogoClick = () => { const {
// Trigger the same URL handling logic as if the URL came from the system logoRef,
const event = new CustomEvent("url-open-request", { isPressed,
detail: "https://donutbrowser.com", setIsPressed,
}); wobbleKey,
window.dispatchEvent(event); isFalling,
}; isHidden,
handleClick,
} = useLogoEasterEgg();
return ( return (
<div className="flex justify-between items-center mt-6"> <div className="flex justify-between items-center mt-6">
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<button {!isHidden ? (
type="button" <button
className="p-1 cursor-pointer" ref={logoRef}
title="Open donutbrowser.com" type="button"
onClick={handleLogoClick} className="p-1 cursor-pointer select-none"
> onClick={handleClick}
<Logo className="w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110" /> onPointerDown={() => setIsPressed(true)}
</button> onPointerUp={() => setIsPressed(false)}
onPointerLeave={() => setIsPressed(false)}
>
<Logo
key={wobbleKey}
className={cn(
"w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110",
isPressed && "scale-90",
!isFalling &&
!isPressed &&
wobbleKey > 0 &&
"animate-[wiggle_0.3s_ease-in-out]",
)}
/>
</button>
) : (
<div className="p-1 w-10 h-10" />
)}
<CardTitle>Donut</CardTitle> <CardTitle>Donut</CardTitle>
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
+125 -31
View File
@@ -25,6 +25,7 @@ import {
LuLock, LuLock,
LuPuzzle, LuPuzzle,
LuTrash2, LuTrash2,
LuTriangleAlert,
LuUsers, LuUsers,
} from "react-icons/lu"; } from "react-icons/lu";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
@@ -83,6 +84,7 @@ import type {
LocationItem, LocationItem,
ProxyCheckResult, ProxyCheckResult,
StoredProxy, StoredProxy,
SyncSessionInfo,
TrafficSnapshot, TrafficSnapshot,
VpnConfig, VpnConfig,
} from "@/types"; } from "@/types";
@@ -204,6 +206,16 @@ type TableMeta = {
// Team locks // Team locks
isProfileLockedByAnother: (profileId: string) => boolean; isProfileLockedByAnother: (profileId: string) => boolean;
getProfileLockEmail: (profileId: string) => string | undefined; getProfileLockEmail: (profileId: string) => string | undefined;
// Synchronizer
getProfileSyncInfo: (profileId: string) =>
| {
session: SyncSessionInfo;
isLeader: boolean;
failedAtUrl: string | null;
}
| undefined;
onLaunchWithSync: (profile: BrowserProfile) => void;
}; };
type SyncStatusDot = { type SyncStatusDot = {
@@ -242,7 +254,7 @@ function getProfileSyncStatusDot(
case "waiting": case "waiting":
return { return {
color: "bg-warning", color: "bg-warning",
tooltip: "Waiting to sync", tooltip: "Close the profile to sync",
animate: false, animate: false,
encrypted, encrypted,
}; };
@@ -801,6 +813,14 @@ interface ProfilesDataTableProps {
onToggleProfileSync?: (profile: BrowserProfile) => void; onToggleProfileSync?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean; crossOsUnlocked?: boolean;
syncUnlocked?: boolean; syncUnlocked?: boolean;
getProfileSyncInfo?: (profileId: string) =>
| {
session: SyncSessionInfo;
isLeader: boolean;
failedAtUrl: string | null;
}
| undefined;
onLaunchWithSync?: (profile: BrowserProfile) => void;
} }
export function ProfilesDataTable({ export function ProfilesDataTable({
@@ -828,6 +848,8 @@ export function ProfilesDataTable({
onToggleProfileSync, onToggleProfileSync,
crossOsUnlocked = false, crossOsUnlocked = false,
syncUnlocked = false, syncUnlocked = false,
getProfileSyncInfo,
onLaunchWithSync,
}: ProfilesDataTableProps) { }: ProfilesDataTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { getTableSorting, updateSorting, isLoaded } = useTableSorting(); const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
@@ -951,8 +973,7 @@ export function ProfilesDataTable({
// Country proxy creation state (for inline proxy creation in dropdown) // Country proxy creation state (for inline proxy creation in dropdown)
const [countries, setCountries] = React.useState<LocationItem[]>([]); const [countries, setCountries] = React.useState<LocationItem[]>([]);
const [countriesLoaded, setCountriesLoaded] = React.useState(false); const [countriesLoaded, setCountriesLoaded] = React.useState(false);
const hasCloudProxy = storedProxies.some((p) => p.is_cloud_managed); const canCreateLocationProxy = false;
const canCreateLocationProxy = hasCloudProxy || crossOsUnlocked;
const loadCountries = React.useCallback(async () => { const loadCountries = React.useCallback(async () => {
if (countriesLoaded || !canCreateLocationProxy) return; if (countriesLoaded || !canCreateLocationProxy) return;
@@ -963,7 +984,7 @@ export function ProfilesDataTable({
} catch (e) { } catch (e) {
console.error("Failed to load countries:", e); console.error("Failed to load countries:", e);
} }
}, [countriesLoaded, canCreateLocationProxy]); }, [countriesLoaded]);
// Load cached check results for proxies // Load cached check results for proxies
React.useEffect(() => { React.useEffect(() => {
@@ -1528,6 +1549,10 @@ export function ProfilesDataTable({
isProfileLockedByAnother: isProfileLocked, isProfileLockedByAnother: isProfileLocked,
getProfileLockEmail: (profileId: string) => getProfileLockEmail: (profileId: string) =>
getLockInfo(profileId)?.lockedByEmail, getLockInfo(profileId)?.lockedByEmail,
// Synchronizer
getProfileSyncInfo: getProfileSyncInfo ?? (() => undefined),
onLaunchWithSync: onLaunchWithSync ?? (() => {}),
}), }),
[ [
t, t,
@@ -1577,11 +1602,12 @@ export function ProfilesDataTable({
crossOsUnlocked, crossOsUnlocked,
syncUnlocked, syncUnlocked,
countries, countries,
canCreateLocationProxy,
loadCountries, loadCountries,
handleCreateCountryProxy, handleCreateCountryProxy,
isProfileLocked, isProfileLocked,
getLockInfo, 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 ( return (
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{isDesynced && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<LuTriangleAlert className="w-4 h-4 text-warning" />
</span>
</TooltipTrigger>
<TooltipContent>
{meta.t("profiles.synchronizer.desyncedTooltip", {
url: syncInfo?.failedAtUrl ?? "",
})}
</TooltipContent>
</Tooltip>
)}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="inline-flex"> <span className="inline-flex">
<RippleButton <RippleButton
variant={isRunning ? "destructive" : "default"} variant={buttonVariant}
size="sm" size="sm"
disabled={!canLaunch || isLaunching || isStopping} disabled={!canLaunch || isLaunching || isStopping}
className={cn( className={cn(
"min-w-[70px] h-7", "min-w-[70px] h-7",
!canLaunch && "opacity-50 cursor-not-allowed", !canLaunch && "opacity-50 cursor-not-allowed",
canLaunch && "cursor-pointer", canLaunch && "cursor-pointer",
isFollower && "border-accent",
)} )}
onClick={() => onClick={() =>
isRunning isRunning
? handleProfileStop(profile) ? void handleStop()
: handleProfileLaunch(profile) : handleProfileLaunch(profile)
} }
> >
@@ -1838,8 +1922,10 @@ export function ProfilesDataTable({
</RippleButton> </RippleButton>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
{tooltipContent && ( {(stopTooltip || tooltipContent) && (
<TooltipContent>{tooltipContent}</TooltipContent> <TooltipContent>
{isRunning ? stopTooltip : tooltipContent}
</TooltipContent>
)} )}
</Tooltip> </Tooltip>
</div> </div>
@@ -2188,28 +2274,35 @@ export function ProfilesDataTable({
/> />
None None
</CommandItem> </CommandItem>
{meta.storedProxies.map((proxy) => ( {meta.storedProxies
<CommandItem .filter(
key={proxy.id} (proxy: StoredProxy) =>
value={proxy.name} !proxy.is_cloud_managed &&
onSelect={() => !proxy.is_cloud_derived,
void meta.handleProxySelection( )
profile.id, .map((proxy: StoredProxy) => (
proxy.id, <CommandItem
) key={proxy.id}
} value={proxy.name}
> onSelect={() =>
<LuCheck void meta.handleProxySelection(
className={cn( profile.id,
"mr-2 h-4 w-4", proxy.id,
effectiveProxyId === proxy.id && !effectiveVpn )
? "opacity-100" }
: "opacity-0", >
)} <LuCheck
/> className={cn(
{proxy.name} "mr-2 h-4 w-4",
</CommandItem> effectiveProxyId === proxy.id &&
))} !effectiveVpn
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
</CommandGroup> </CommandGroup>
{meta.vpnConfigs.length > 0 && ( {meta.vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs"> <CommandGroup heading="VPNs">
@@ -2519,6 +2612,7 @@ export function ProfilesDataTable({
onAssignExtensionGroup={onAssignExtensionGroup} onAssignExtensionGroup={onAssignExtensionGroup}
onOpenBypassRules={(profile) => setBypassRulesProfile(profile)} onOpenBypassRules={(profile) => setBypassRulesProfile(profile)}
onCloneProfile={onCloneProfile} onCloneProfile={onCloneProfile}
onLaunchWithSync={onLaunchWithSync}
onDeleteProfile={(profile) => { onDeleteProfile={(profile) => {
setProfileForInfoDialog(null); setProfileForInfoDialog(null);
setProfileToDelete(profile); setProfileToDelete(profile);
+11
View File
@@ -19,6 +19,7 @@ import {
LuSettings, LuSettings,
LuShieldCheck, LuShieldCheck,
LuTrash2, LuTrash2,
LuUsers,
LuX, LuX,
} from "react-icons/lu"; } from "react-icons/lu";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -65,6 +66,7 @@ interface ProfileInfoDialogProps {
onOpenBypassRules?: (profile: BrowserProfile) => void; onOpenBypassRules?: (profile: BrowserProfile) => void;
onCloneProfile?: (profile: BrowserProfile) => void; onCloneProfile?: (profile: BrowserProfile) => void;
onDeleteProfile?: (profile: BrowserProfile) => void; onDeleteProfile?: (profile: BrowserProfile) => void;
onLaunchWithSync?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean; crossOsUnlocked?: boolean;
isRunning?: boolean; isRunning?: boolean;
isDisabled?: boolean; isDisabled?: boolean;
@@ -110,6 +112,7 @@ export function ProfileInfoDialog({
onOpenBypassRules, onOpenBypassRules,
onCloneProfile, onCloneProfile,
onDeleteProfile, onDeleteProfile,
onLaunchWithSync,
crossOsUnlocked = false, crossOsUnlocked = false,
isRunning = false, isRunning = false,
isDisabled = false, isDisabled = false,
@@ -251,6 +254,14 @@ export function ProfileInfoDialog({
runningBadge: isRunning, runningBadge: isRunning,
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox, hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
}, },
{
icon: <LuUsers className="w-4 h-4" />,
label: t("profiles.synchronizer.launchWithSync"),
onClick: () => handleAction(() => onLaunchWithSync?.(profile)),
disabled: isDisabled || isRunning || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
},
{ {
icon: <LuCopy className="w-4 h-4" />, icon: <LuCopy className="w-4 h-4" />,
label: t("profiles.actions.copyCookiesToProfile"), label: t("profiles.actions.copyCookiesToProfile"),
+27 -25
View File
@@ -186,9 +186,7 @@ export function ProxyAssignmentDialog({
const proxy = storedProxies.find( const proxy = storedProxies.find(
(p) => p.id === selectedId, (p) => p.id === selectedId,
); );
return proxy return proxy ? proxy.name : "None";
? `${proxy.name}${proxy.is_cloud_managed ? " (Included)" : ""}`
: "None";
})()} })()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
@@ -216,28 +214,32 @@ export function ProxyAssignmentDialog({
/> />
None None
</CommandItem> </CommandItem>
{storedProxies.map((proxy) => ( {storedProxies
<CommandItem .filter(
key={proxy.id} (proxy) =>
value={proxy.name} !proxy.is_cloud_managed && !proxy.is_cloud_derived,
onSelect={() => { )
handleValueChange(proxy.id); .map((proxy) => (
setProxyPopoverOpen(false); <CommandItem
}} key={proxy.id}
> value={proxy.name}
<LuCheck onSelect={() => {
className={cn( handleValueChange(proxy.id);
"mr-2 h-4 w-4", setProxyPopoverOpen(false);
selectionType === "proxy" && }}
selectedId === proxy.id >
? "opacity-100" <LuCheck
: "opacity-0", className={cn(
)} "mr-2 h-4 w-4",
/> selectionType === "proxy" &&
{proxy.name} selectedId === proxy.id
{proxy.is_cloud_managed ? " (Included)" : ""} ? "opacity-100"
</CommandItem> : "opacity-0",
))} )}
/>
{proxy.name}
</CommandItem>
))}
</CommandGroup> </CommandGroup>
{vpnConfigs.length > 0 && ( {vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs"> <CommandGroup heading="VPNs">
+3 -1
View File
@@ -50,7 +50,9 @@ export function ProxyCheckButton({
try { try {
const result = await invoke<ProxyCheckResult>("check_proxy_validity", { const result = await invoke<ProxyCheckResult>("check_proxy_validity", {
proxyId: proxy.id, proxyId: proxy.id,
proxySettings: proxy.proxy_settings, proxySettings: proxy.dynamic_proxy_url
? undefined
: proxy.proxy_settings,
}); });
setLocalResult(result); setLocalResult(result);
onCheckComplete?.(result); onCheckComplete?.(result);
+339 -147
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button"; import { LoadingButton } from "@/components/loading-button";
import { import {
@@ -20,10 +21,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } 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"; import { RippleButton } from "./ui/ripple";
interface ProxyFormData { interface RegularFormData {
name: string; name: string;
proxy_type: string; proxy_type: string;
host: string; host: string;
@@ -32,6 +34,14 @@ interface ProxyFormData {
password: string; password: string;
} }
interface DynamicFormData {
name: string;
url: string;
format: string;
}
type ProxyMode = "regular" | "dynamic";
interface ProxyFormDialogProps { interface ProxyFormDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -43,8 +53,11 @@ export function ProxyFormDialog({
onClose, onClose,
editingProxy, editingProxy,
}: ProxyFormDialogProps) { }: ProxyFormDialogProps) {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<ProxyFormData>({ const [isTesting, setIsTesting] = useState(false);
const [mode, setMode] = useState<ProxyMode>("regular");
const [regularForm, setRegularForm] = useState<RegularFormData>({
name: "", name: "",
proxy_type: "http", proxy_type: "http",
host: "", host: "",
@@ -52,9 +65,14 @@ export function ProxyFormDialog({
username: "", username: "",
password: "", password: "",
}); });
const [dynamicForm, setDynamicForm] = useState<DynamicFormData>({
name: "",
url: "",
format: "json",
});
const resetForm = useCallback(() => { const resetForm = useCallback(() => {
setFormData({ setRegularForm({
name: "", name: "",
proxy_type: "http", proxy_type: "http",
host: "", host: "",
@@ -62,62 +80,134 @@ export function ProxyFormDialog({
username: "", username: "",
password: "", password: "",
}); });
setDynamicForm({
name: "",
url: "",
format: "json",
});
setMode("regular");
}, []); }, []);
// Load editing proxy data when dialog opens
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
if (editingProxy) { if (editingProxy) {
setFormData({ if (editingProxy.dynamic_proxy_url) {
name: editingProxy.name, setMode("dynamic");
proxy_type: editingProxy.proxy_settings.proxy_type, setDynamicForm({
host: editingProxy.proxy_settings.host, name: editingProxy.name,
port: editingProxy.proxy_settings.port, url: editingProxy.dynamic_proxy_url,
username: editingProxy.proxy_settings.username || "", format: editingProxy.dynamic_proxy_format || "json",
password: editingProxy.proxy_settings.password || "", });
}); } 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 { } else {
resetForm(); resetForm();
} }
} }
}, [isOpen, editingProxy, resetForm]); }, [isOpen, editingProxy, resetForm]);
const handleSubmit = useCallback(async () => { const handleTestDynamic = useCallback(async () => {
if (!formData.name.trim()) { if (!dynamicForm.url.trim()) {
toast.error("Proxy name is required"); toast.error(t("proxies.dynamic.urlRequired"));
return; return;
} }
setIsTesting(true);
try {
const settings = await invoke<ProxySettings>("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) { const handleSubmit = useCallback(async () => {
toast.error("Host and port are required"); if (mode === "regular") {
return; 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); setIsSubmitting(true);
try { 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) { if (editingProxy) {
// Update existing proxy if (mode === "dynamic") {
await invoke("update_stored_proxy", { await invoke("update_stored_proxy", {
proxyId: editingProxy.id, proxyId: editingProxy.id,
name: formData.name.trim(), name: dynamicForm.name.trim(),
proxySettings, dynamicProxyUrl: dynamicForm.url.trim(),
}); dynamicProxyFormat: dynamicForm.format,
toast.success("Proxy updated successfully"); });
} 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 { } else {
// Create new proxy if (mode === "dynamic") {
await invoke("create_stored_proxy", { await invoke("create_stored_proxy", {
name: formData.name.trim(), name: dynamicForm.name.trim(),
proxySettings, dynamicProxyUrl: dynamicForm.url.trim(),
}); dynamicProxyFormat: dynamicForm.format,
toast.success("Proxy created successfully"); });
} 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(); onClose();
@@ -129,7 +219,7 @@ export function ProxyFormDialog({
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}, [formData, editingProxy, onClose]); }, [mode, regularForm, dynamicForm, editingProxy, onClose, t]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
if (!isSubmitting) { if (!isSubmitting) {
@@ -137,125 +227,227 @@ export function ProxyFormDialog({
} }
}, [isSubmitting, onClose]); }, [isSubmitting, onClose]);
const isFormValid = const isRegularValid =
formData.name.trim() && regularForm.name.trim() &&
formData.host.trim() && regularForm.host.trim() &&
formData.port > 0 && regularForm.port > 0 &&
formData.port <= 65535; 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 ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{editingProxy ? "Edit Proxy" : "Create New Proxy"} {editingProxy ? t("proxies.edit") : t("proxies.add")}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> {!editingProxy && (
<Label htmlFor="proxy-name">Proxy Name</Label> <Tabs value={mode} onValueChange={(v) => setMode(v as ProxyMode)}>
<Input <TabsList className="w-full">
id="proxy-name" <TabsTrigger value="regular" className="flex-1">
value={formData.name} {t("proxies.tabs.regular")}
onChange={(e) => </TabsTrigger>
setFormData({ ...formData, name: e.target.value }) <TabsTrigger value="dynamic" className="flex-1">
} {t("proxies.tabs.dynamic")}
placeholder="e.g. Office Proxy, Home VPN, etc." </TabsTrigger>
disabled={isSubmitting} </TabsList>
/> </Tabs>
</div> )}
<div className="grid gap-2"> {editingProxy && isEditingDynamic && (
<Label>Proxy Type</Label> <p className="text-xs text-muted-foreground">
<Select {t("proxies.dynamic.description")}
value={formData.proxy_type} </p>
onValueChange={(value) => )}
setFormData({ ...formData, proxy_type: value })
}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue placeholder="Select proxy type" />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5"].map((type) => (
<SelectItem key={type} value={type}>
{type.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4"> {mode === "regular" ? (
<div className="grid gap-2"> <>
<Label htmlFor="proxy-host">Host</Label> <div className="grid gap-2">
<Input <Label htmlFor="proxy-name">{t("proxies.form.name")}</Label>
id="proxy-host" <Input
value={formData.host} id="proxy-name"
onChange={(e) => value={regularForm.name}
setFormData({ ...formData, host: e.target.value }) onChange={(e) =>
} setRegularForm({ ...regularForm, name: e.target.value })
placeholder="e.g. 127.0.0.1" }
disabled={isSubmitting} placeholder="e.g. Office Proxy, Home VPN, etc."
/> disabled={isSubmitting}
</div> />
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="proxy-port">Port</Label> <Label>{t("proxies.form.type")}</Label>
<Input <Select
id="proxy-port" value={regularForm.proxy_type}
type="number" onValueChange={(value) =>
value={formData.port} setRegularForm({ ...regularForm, proxy_type: value })
onChange={(e) => }
setFormData({ disabled={isSubmitting}
...formData, >
port: parseInt(e.target.value, 10) || 0, <SelectTrigger>
}) <SelectValue placeholder="Select proxy type" />
} </SelectTrigger>
placeholder="e.g. 8080" <SelectContent>
min="1" {["http", "https", "socks4", "socks5"].map((type) => (
max="65535" <SelectItem key={type} value={type}>
disabled={isSubmitting} {type.toUpperCase()}
/> </SelectItem>
</div> ))}
</div> </SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="proxy-username">Username (optional)</Label> <Label htmlFor="proxy-host">{t("proxies.form.host")}</Label>
<Input <Input
id="proxy-username" id="proxy-host"
value={formData.username} value={regularForm.host}
onChange={(e) => onChange={(e) =>
setFormData({ setRegularForm({ ...regularForm, host: e.target.value })
...formData, }
username: e.target.value, placeholder={t("proxies.form.hostPlaceholder")}
}) disabled={isSubmitting}
} />
placeholder="Proxy username" </div>
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="proxy-password">Password (optional)</Label> <Label htmlFor="proxy-port">{t("proxies.form.port")}</Label>
<Input <Input
id="proxy-password" id="proxy-port"
type="password" type="number"
value={formData.password} value={regularForm.port}
onChange={(e) => onChange={(e) =>
setFormData({ setRegularForm({
...formData, ...regularForm,
password: e.target.value, port: parseInt(e.target.value, 10) || 0,
}) })
} }
placeholder="Proxy password" placeholder={t("proxies.form.portPlaceholder")}
disabled={isSubmitting} min="1"
/> max="65535"
</div> disabled={isSubmitting}
</div> />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-username">
{t("proxies.form.username")} (
{t("proxies.form.usernamePlaceholder")})
</Label>
<Input
id="proxy-username"
value={regularForm.username}
onChange={(e) =>
setRegularForm({
...regularForm,
username: e.target.value,
})
}
placeholder={t("proxies.form.usernamePlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">
{t("proxies.form.password")} (
{t("proxies.form.passwordPlaceholder")})
</Label>
<Input
id="proxy-password"
type="password"
value={regularForm.password}
onChange={(e) =>
setRegularForm({
...regularForm,
password: e.target.value,
})
}
placeholder={t("proxies.form.passwordPlaceholder")}
disabled={isSubmitting}
/>
</div>
</div>
</>
) : (
<>
<div className="grid gap-2">
<Label htmlFor="dynamic-name">{t("proxies.form.name")}</Label>
<Input
id="dynamic-name"
value={dynamicForm.name}
onChange={(e) =>
setDynamicForm({ ...dynamicForm, name: e.target.value })
}
placeholder="e.g. My Tunnel"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dynamic-url">{t("proxies.dynamic.url")}</Label>
<Input
id="dynamic-url"
value={dynamicForm.url}
onChange={(e) =>
setDynamicForm({ ...dynamicForm, url: e.target.value })
}
placeholder={t("proxies.dynamic.urlPlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label>{t("proxies.dynamic.format")}</Label>
<Select
value={dynamicForm.format}
onValueChange={(value) =>
setDynamicForm({ ...dynamicForm, format: value })
}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="json">
{t("proxies.dynamic.formatJson")}
</SelectItem>
<SelectItem value="text">
{t("proxies.dynamic.formatText")}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{dynamicForm.format === "json"
? t("proxies.dynamic.formatJsonHint")
: t("proxies.dynamic.formatTextHint")}
</p>
</div>
<RippleButton
variant="outline"
size="sm"
onClick={handleTestDynamic}
disabled={isSubmitting || isTesting || !dynamicForm.url.trim()}
>
{isTesting
? t("proxies.dynamic.testing")
: t("proxies.dynamic.testUrl")}
</RippleButton>
</>
)}
</div> </div>
<DialogFooter> <DialogFooter>
@@ -264,14 +456,14 @@ export function ProxyFormDialog({
onClick={handleClose} onClick={handleClose}
disabled={isSubmitting} disabled={isSubmitting}
> >
Cancel {t("common.cancel", "Cancel")}
</RippleButton> </RippleButton>
<LoadingButton <LoadingButton
isLoading={isSubmitting} isLoading={isSubmitting}
onClick={handleSubmit} onClick={handleSubmit}
disabled={!isFormValid} disabled={!isFormValid}
> >
{editingProxy ? "Update Proxy" : "Create Proxy"} {editingProxy ? t("proxies.edit") : t("proxies.add")}
</LoadingButton> </LoadingButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+41 -65
View File
@@ -3,7 +3,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event"; import { emit, listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react"; 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 { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; 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 { useVpnEvents } from "@/hooks/use-vpn-events";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types"; 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 { ProxyCheckButton } from "./proxy-check-button";
import { RippleButton } from "./ui/ripple"; import { RippleButton } from "./ui/ripple";
import { VpnCheckButton } from "./vpn-check-button"; import { VpnCheckButton } from "./vpn-check-button";
@@ -102,7 +100,6 @@ export function ProxyManagementDialog({
const [showProxyForm, setShowProxyForm] = useState(false); const [showProxyForm, setShowProxyForm] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false);
const [showExportDialog, setShowExportDialog] = useState(false); const [showExportDialog, setShowExportDialog] = useState(false);
const [showLocationDialog, setShowLocationDialog] = useState(false);
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null); const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null); const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
@@ -142,12 +139,10 @@ export function ProxyManagementDialog({
const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents(); const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents();
const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents(); const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents();
// Filter out the base cloud-managed proxy (it's an internal indicator, not user-facing) // Filter out cloud-managed and cloud-derived proxies (cloud proxies are deprecated)
// Keep cloud-derived location proxies
const storedProxies = rawProxies 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())); .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 // Listen for proxy sync status events
useEffect(() => { useEffect(() => {
@@ -412,17 +407,6 @@ export function ProxyManagementDialog({
</RippleButton> </RippleButton>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{hasCloudProxy && (
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowLocationDialog(true)}
className="flex gap-2 items-center"
>
<GoGlobe className="w-4 h-4" />
Location
</RippleButton>
)}
<RippleButton <RippleButton
size="sm" size="sm"
onClick={handleCreateProxy} onClick={handleCreateProxy}
@@ -462,34 +446,33 @@ export function ProxyManagementDialog({
proxySyncStatus[proxy.id], proxySyncStatus[proxy.id],
proxySyncErrors[proxy.id], proxySyncErrors[proxy.id],
); );
const isDerived = proxy.is_cloud_derived === true;
return ( return (
<TableRow key={proxy.id}> <TableRow key={proxy.id}>
<TableCell className="font-medium"> <TableCell className="font-medium">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isDerived && proxy.geo_country && ( <Tooltip>
<FlagIcon <TooltipTrigger asChild>
countryCode={proxy.geo_country} <div
className="shrink-0" className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
/> syncDot.animate
)} ? "animate-pulse"
{!isDerived && ( : ""
<Tooltip> }`}
<TooltipTrigger asChild> />
<div </TooltipTrigger>
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${ <TooltipContent>
syncDot.animate <p>{syncDot.tooltip}</p>
? "animate-pulse" </TooltipContent>
: "" </Tooltip>
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
)}
{proxy.name} {proxy.name}
{proxy.dynamic_proxy_url && (
<Badge
variant="outline"
className="text-[10px] px-1 py-0"
>
Dynamic
</Badge>
)}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -554,24 +537,22 @@ export function ProxyManagementDialog({
})); }));
}} }}
/> />
{!isDerived && ( <Tooltip>
<Tooltip> <TooltipTrigger asChild>
<TooltipTrigger asChild> <Button
<Button variant="ghost"
variant="ghost" size="sm"
size="sm" onClick={() =>
onClick={() => handleEditProxy(proxy)
handleEditProxy(proxy) }
} >
> <LuPencil className="w-4 h-4" />
<LuPencil className="w-4 h-4" /> </Button>
</Button> </TooltipTrigger>
</TooltipTrigger> <TooltipContent>
<TooltipContent> <p>Edit proxy</p>
<p>Edit proxy</p> </TooltipContent>
</TooltipContent> </Tooltip>
</Tooltip>
)}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span> <span>
@@ -830,11 +811,6 @@ export function ProxyManagementDialog({
isOpen={showExportDialog} isOpen={showExportDialog}
onClose={() => setShowExportDialog(false)} onClose={() => setShowExportDialog(false)}
/> />
<LocationProxyDialog
isOpen={showLocationDialog}
onClose={() => setShowLocationDialog(false)}
/>
<VpnFormDialog <VpnFormDialog
isOpen={showVpnForm} isOpen={showVpnForm}
onClose={handleVpnFormClose} onClose={handleVpnFormClose}
+217
View File
@@ -0,0 +1,217 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { isCrossOsProfile } from "@/lib/browser-utils";
import { showErrorToast } from "@/lib/toast-utils";
import type {
BrowserProfile,
SyncSessionInfo,
WayfernFingerprintConfig,
} from "@/types";
import { RippleButton } from "./ui/ripple";
function getScreenSize(
profile: BrowserProfile,
): { w: number; h: number } | null {
const fp = profile.wayfern_config?.fingerprint;
if (!fp) return null;
try {
const parsed: WayfernFingerprintConfig = JSON.parse(fp);
const w = parsed.screenWidth ?? parsed.windowInnerWidth;
const h = parsed.screenHeight ?? parsed.windowInnerHeight;
if (w && h) return { w, h };
} catch {
// ignore
}
return null;
}
interface SyncFollowerDialogProps {
isOpen: boolean;
onClose: () => void;
leaderProfile: BrowserProfile | null;
allProfiles: BrowserProfile[];
runningProfiles: Set<string>;
}
export function SyncFollowerDialog({
isOpen,
onClose,
leaderProfile,
allProfiles,
runningProfiles,
}: SyncFollowerDialogProps) {
const { t } = useTranslation();
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<SyncSessionInfo>("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 (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("profiles.synchronizer.selectFollowers")}
</DialogTitle>
<DialogDescription>
{t("profiles.synchronizer.selectFollowersDesc")}
</DialogDescription>
</DialogHeader>
{leaderProfile && (
<div className="space-y-3">
<div className="flex items-center gap-2 p-2 rounded-md bg-primary/10 border border-primary/20">
<Badge variant="default" className="text-xs">
{t("profiles.synchronizer.leader")}
</Badge>
<span className="text-sm font-medium truncate">
{leaderProfile.name}
</span>
</div>
<div className="border rounded-md">
<ScrollArea className="h-[150px]">
<div className="space-y-1 p-2">
{eligibleProfiles.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("profiles.synchronizer.wayfernOnly")}
</p>
) : (
eligibleProfiles.map((profile) => {
const followerSize = getScreenSize(profile);
const isFlaky =
leaderScreenSize &&
followerSize &&
(leaderScreenSize.w !== followerSize.w ||
leaderScreenSize.h !== followerSize.h);
return (
<div
key={profile.id}
className="flex items-center gap-3 p-2 rounded-md hover:bg-accent cursor-pointer"
onClick={() =>
handleToggle(
profile.id,
!selectedIds.has(profile.id),
)
}
onKeyDown={() => {}}
role="button"
tabIndex={0}
>
<Checkbox
checked={selectedIds.has(profile.id)}
onCheckedChange={(checked) =>
handleToggle(profile.id, checked === true)
}
/>
<span className="text-sm truncate flex-1">
{profile.name}
</span>
{isFlaky && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 text-warning border-warning/50 shrink-0"
>
{t("profiles.synchronizer.flakyBadge")}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">
{t("profiles.synchronizer.flakyTooltip")}
</TooltipContent>
</Tooltip>
)}
</div>
);
})
)}
</div>
</ScrollArea>
</div>
</div>
)}
<DialogFooter>
<RippleButton
variant="outline"
onClick={() => handleOpenChange(false)}
>
{t("common.buttons.cancel")}
</RippleButton>
<RippleButton disabled={selectedIds.size === 0} onClick={handleStart}>
{t("profiles.synchronizer.startSession")}
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+89
View File
@@ -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<SyncSessionInfo[]>([]);
const loadSessions = useCallback(async () => {
try {
const data = await invoke<SyncSessionInfo[]>("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<SyncSessionInfo>(
"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<string>("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 };
}
+36
View File
@@ -184,6 +184,22 @@
"changeFingerprint": "Change Fingerprint", "changeFingerprint": "Change Fingerprint",
"copyCookiesToProfile": "Copy Cookies to Profile" "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", "ephemeral": "Ephemeral",
"ephemeralDescription": "The browser is forced to write profile data into memory instead of disk. Data is deleted when the browser is closed.", "ephemeralDescription": "The browser is forced to write profile data into memory instead of disk. Data is deleted when the browser is closed.",
"ephemeralBadge": "Ephemeral", "ephemeralBadge": "Ephemeral",
@@ -264,6 +280,26 @@
"socks4": "SOCKS4", "socks4": "SOCKS4",
"socks5": "SOCKS5" "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": { "check": {
"checking": "Checking proxy...", "checking": "Checking proxy...",
"valid": "Proxy is valid", "valid": "Proxy is valid",
+36
View File
@@ -184,6 +184,22 @@
"changeFingerprint": "Cambiar Huella Digital", "changeFingerprint": "Cambiar Huella Digital",
"copyCookiesToProfile": "Copiar Cookies al Perfil" "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", "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.", "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", "ephemeralBadge": "Efímero",
@@ -264,6 +280,26 @@
"socks4": "SOCKS4", "socks4": "SOCKS4",
"socks5": "SOCKS5" "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": { "check": {
"checking": "Verificando proxy...", "checking": "Verificando proxy...",
"valid": "El proxy es válido", "valid": "El proxy es válido",
+36
View File
@@ -184,6 +184,22 @@
"changeFingerprint": "Changer l'Empreinte", "changeFingerprint": "Changer l'Empreinte",
"copyCookiesToProfile": "Copier les Cookies vers le Profil" "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", "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.", "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", "ephemeralBadge": "Éphémère",
@@ -264,6 +280,26 @@
"socks4": "SOCKS4", "socks4": "SOCKS4",
"socks5": "SOCKS5" "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": { "check": {
"checking": "Vérification du proxy...", "checking": "Vérification du proxy...",
"valid": "Le proxy est valide", "valid": "Le proxy est valide",
+36
View File
@@ -184,6 +184,22 @@
"changeFingerprint": "フィンガープリントを変更", "changeFingerprint": "フィンガープリントを変更",
"copyCookiesToProfile": "Cookieをプロファイルにコピー" "copyCookiesToProfile": "Cookieをプロファイルにコピー"
}, },
"synchronizer": {
"launchWithSync": "シンクロナイザーで起動",
"stopLeader": "このプロフィールとすべてのフォロワーを停止",
"stopFollower": "{{leaderName}}のアクションを追従中",
"desyncedTooltip": "{{url}}で同期に失敗しました",
"paidFeature": "シンクロナイザーは有料機能です",
"wayfernOnly": "Wayfernプロフィールのみ同期可能です",
"selectFollowers": "フォロワープロフィールを選択",
"selectFollowersDesc": "リーダープロフィールのアクションを複製するプロフィールを選択してください。停止中のWayfernプロフィールのみ選択できます。",
"leader": "リーダー",
"follower": "フォロワー",
"startSession": "同期セッションを開始",
"noFollowers": "少なくとも1つのフォロワープロフィールを選択してください",
"flakyBadge": "FLAKY",
"flakyTooltip": "このプロフィールはリーダーと画面解像度が異なります。ページレイアウトが異なる可能性があり、クリックや操作が正しく動作しない場合があります。"
},
"ephemeral": "一時的", "ephemeral": "一時的",
"ephemeralDescription": "ブラウザはプロファイルデータをディスクではなくメモリに書き込むよう強制されます。ブラウザを閉じるとデータは削除されます。", "ephemeralDescription": "ブラウザはプロファイルデータをディスクではなくメモリに書き込むよう強制されます。ブラウザを閉じるとデータは削除されます。",
"ephemeralBadge": "一時的", "ephemeralBadge": "一時的",
@@ -264,6 +280,26 @@
"socks4": "SOCKS4", "socks4": "SOCKS4",
"socks5": "SOCKS5" "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": { "check": {
"checking": "プロキシを確認中...", "checking": "プロキシを確認中...",
"valid": "プロキシは有効です", "valid": "プロキシは有効です",
+36
View File
@@ -184,6 +184,22 @@
"changeFingerprint": "Alterar Impressão Digital", "changeFingerprint": "Alterar Impressão Digital",
"copyCookiesToProfile": "Copiar Cookies para o Perfil" "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", "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.", "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", "ephemeralBadge": "Efêmero",
@@ -264,6 +280,26 @@
"socks4": "SOCKS4", "socks4": "SOCKS4",
"socks5": "SOCKS5" "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": { "check": {
"checking": "Verificando proxy...", "checking": "Verificando proxy...",
"valid": "O proxy é válido", "valid": "O proxy é válido",
+36
View File
@@ -184,6 +184,22 @@
"changeFingerprint": "Изменить отпечаток", "changeFingerprint": "Изменить отпечаток",
"copyCookiesToProfile": "Копировать Cookie в профиль" "copyCookiesToProfile": "Копировать Cookie в профиль"
}, },
"synchronizer": {
"launchWithSync": "Запустить с синхронизатором",
"stopLeader": "Остановить этот профиль и всех его последователей",
"stopFollower": "Повторяет действия {{leaderName}}",
"desyncedTooltip": "Синхронизация не удалась на {{url}}",
"paidFeature": "Синхронизатор — платная функция",
"wayfernOnly": "Синхронизировать можно только профили Wayfern",
"selectFollowers": "Выберите профили-последователи",
"selectFollowersDesc": "Выберите профили, которые будут повторять действия профиля-лидера. Можно выбрать только остановленные профили Wayfern.",
"leader": "Лидер",
"follower": "Последователь",
"startSession": "Начать сессию синхронизации",
"noFollowers": "Выберите хотя бы один профиль-последователь",
"flakyBadge": "FLAKY",
"flakyTooltip": "У этого профиля разрешение экрана отличается от лидера. Макет страниц может отличаться, что может привести к неправильным кликам и взаимодействиям."
},
"ephemeral": "Временный", "ephemeral": "Временный",
"ephemeralDescription": "Браузер принудительно записывает данные профиля в память вместо диска. Данные удаляются при закрытии браузера.", "ephemeralDescription": "Браузер принудительно записывает данные профиля в память вместо диска. Данные удаляются при закрытии браузера.",
"ephemeralBadge": "Временный", "ephemeralBadge": "Временный",
@@ -264,6 +280,26 @@
"socks4": "SOCKS4", "socks4": "SOCKS4",
"socks5": "SOCKS5" "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": { "check": {
"checking": "Проверка прокси...", "checking": "Проверка прокси...",
"valid": "Прокси действителен", "valid": "Прокси действителен",
+36
View File
@@ -184,6 +184,22 @@
"changeFingerprint": "更改指纹", "changeFingerprint": "更改指纹",
"copyCookiesToProfile": "复制 Cookies 到配置文件" "copyCookiesToProfile": "复制 Cookies 到配置文件"
}, },
"synchronizer": {
"launchWithSync": "使用同步器启动",
"stopLeader": "停止此配置文件及其所有跟随者",
"stopFollower": "正在跟随 {{leaderName}} 的操作",
"desyncedTooltip": "在 {{url}} 同步失败",
"paidFeature": "同步器是付费功能",
"wayfernOnly": "只有 Wayfern 配置文件可以同步",
"selectFollowers": "选择跟随者配置文件",
"selectFollowersDesc": "选择将复制领导者配置文件操作的配置文件。只能选择已停止的 Wayfern 配置文件。",
"leader": "领导者",
"follower": "跟随者",
"startSession": "开始同步会话",
"noFollowers": "请至少选择一个跟随者配置文件",
"flakyBadge": "FLAKY",
"flakyTooltip": "此配置文件的屏幕分辨率与领导者不同。页面布局可能不同,导致点击和交互可能命中错误的元素。"
},
"ephemeral": "临时", "ephemeral": "临时",
"ephemeralDescription": "浏览器被强制将配置数据写入内存而非磁盘。关闭浏览器时数据将被删除。", "ephemeralDescription": "浏览器被强制将配置数据写入内存而非磁盘。关闭浏览器时数据将被删除。",
"ephemeralBadge": "临时", "ephemeralBadge": "临时",
@@ -264,6 +280,26 @@
"socks4": "SOCKS4", "socks4": "SOCKS4",
"socks5": "SOCKS5" "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": { "check": {
"checking": "检查代理中...", "checking": "检查代理中...",
"valid": "代理有效", "valid": "代理有效",
+16
View File
@@ -134,6 +134,8 @@ export interface StoredProxy {
geo_region?: string; geo_region?: string;
geo_city?: string; geo_city?: string;
geo_isp?: string; geo_isp?: string;
dynamic_proxy_url?: string;
dynamic_proxy_format?: string;
} }
export interface LocationItem { export interface LocationItem {
@@ -510,6 +512,20 @@ export interface WayfernLaunchResult {
cdp_port?: number; 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 // Traffic stats types
export interface BandwidthDataPoint { export interface BandwidthDataPoint {
timestamp: number; timestamp: number;
+10
View File
@@ -8,6 +8,16 @@ export default {
backgroundColor: { backgroundColor: {
dark: "#000000", 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",
},
}, },
}, },
}; };