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",
"autologin",
"biomejs",
"boringtun",
"breezedark",
"browserforge",
"busctl",
@@ -42,6 +43,7 @@
"DBAPI",
"dconf",
"debuginfo",
"desynced",
"devedition",
"direnv",
"distro",
@@ -170,9 +172,11 @@
"repogen",
"reportingpolicy",
"reqwest",
"resvg",
"ridedott",
"rlib",
"rsplit",
"rusqlite",
"rustc",
"rwxr",
"SARIF",
@@ -192,6 +196,7 @@
"signon",
"signum",
"sklearn",
"smoltcp",
"SMTO",
"sonner",
"splitn",
@@ -212,6 +217,7 @@
"TERX",
"testpass",
"testuser",
"thiserror",
"timedatectl",
"titlebar",
"tkinter",
@@ -219,6 +225,7 @@
"tqdm",
"trackingprotection",
"trailhead",
"tungstenite",
"turbopack",
"turtledemo",
"typer",
+23 -16
View File
@@ -652,6 +652,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -932,6 +941,15 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.57"
@@ -1690,6 +1708,7 @@ dependencies = [
name = "donutbrowser"
version = "0.16.1"
dependencies = [
"aes",
"aes-gcm",
"argon2",
"async-socks5",
@@ -1699,6 +1718,7 @@ dependencies = [
"blake3",
"boringtun",
"bzip2 0.6.1",
"cbc",
"chrono",
"chrono-tz",
"clap",
@@ -1717,7 +1737,6 @@ dependencies = [
"lazy_static",
"libc",
"log",
"lz4_flex",
"lzma-rs",
"maxminddb",
"mime_guess",
@@ -1727,6 +1746,7 @@ dependencies = [
"objc2",
"objc2-app-kit",
"once_cell",
"pbkdf2",
"playwright",
"quick-xml 0.39.2",
"rand 0.9.2",
@@ -1738,6 +1758,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"serial_test",
"sha1",
"smoltcp",
"sys-locale",
"sysinfo",
@@ -3167,6 +3188,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@@ -3571,15 +3593,6 @@ dependencies = [
"imgref",
]
[[package]]
name = "lz4_flex"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
dependencies = [
"twox-hash",
]
[[package]]
name = "lzma-rs"
version = "0.3.0"
@@ -7420,12 +7433,6 @@ dependencies = [
"utf-8",
]
[[package]]
name = "twox-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
[[package]]
name = "typed-path"
version = "0.12.3"
+4 -1
View File
@@ -81,6 +81,10 @@ utoipa = { version = "5", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.2"
argon2 = "0.5"
aes-gcm = "0.10"
aes = "0.8"
cbc = "0.1"
pbkdf2 = "0.12"
sha1 = "0.10"
hyper = { version = "1.8", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
@@ -101,7 +105,6 @@ maxminddb = "0.27"
quick-xml = { version = "0.39", features = ["serialize"] }
# VPN support
lz4_flex = "0.11"
boringtun = "0.7"
smoltcp = { version = "0.11", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
+62 -24
View File
@@ -111,13 +111,17 @@ struct ApiProxyResponse {
name: String,
#[schema(value_type = Object)]
proxy_settings: ProxySettings,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
struct CreateProxyRequest {
name: String,
#[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)]
@@ -125,6 +129,8 @@ struct UpdateProxyRequest {
name: Option<String>,
#[schema(value_type = Object)]
proxy_settings: Option<ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
@@ -1028,6 +1034,8 @@ async fn get_proxies(
.map(|p| ApiProxyResponse {
id: p.id,
name: p.name,
dynamic_proxy_url: p.dynamic_proxy_url,
dynamic_proxy_format: p.dynamic_proxy_format,
proxy_settings: p.proxy_settings,
})
.collect(),
@@ -1061,6 +1069,8 @@ async fn get_proxy(
id: proxy.id,
name: proxy.name,
proxy_settings: proxy.proxy_settings,
dynamic_proxy_url: proxy.dynamic_proxy_url,
dynamic_proxy_format: proxy.dynamic_proxy_format,
}))
} else {
Err(StatusCode::NOT_FOUND)
@@ -1086,14 +1096,27 @@ async fn create_proxy(
State(state): State<ApiServerState>,
Json(request): Json<CreateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, StatusCode> {
match PROXY_MANAGER.create_stored_proxy(
&state.app_handle,
request.name.clone(),
request.proxy_settings,
) {
let result = if let (Some(url), Some(format)) =
(&request.dynamic_proxy_url, &request.dynamic_proxy_format)
{
PROXY_MANAGER.create_dynamic_proxy(
&state.app_handle,
request.name.clone(),
url.clone(),
format.clone(),
)
} else if let Some(settings) = request.proxy_settings {
PROXY_MANAGER.create_stored_proxy(&state.app_handle, request.name.clone(), settings)
} else {
return Err(StatusCode::BAD_REQUEST);
};
match result {
Ok(proxy) => Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
dynamic_proxy_url: proxy.dynamic_proxy_url,
dynamic_proxy_format: proxy.dynamic_proxy_format,
proxy_settings: proxy.proxy_settings,
})),
Err(_) => Err(StatusCode::BAD_REQUEST),
@@ -1124,28 +1147,29 @@ async fn update_proxy(
State(state): State<ApiServerState>,
Json(request): Json<UpdateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, StatusCode> {
let proxies = PROXY_MANAGER.get_stored_proxies();
if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) {
let new_name = request.name.unwrap_or(proxy.name.clone());
let new_proxy_settings = request
.proxy_settings
.unwrap_or(proxy.proxy_settings.clone());
let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(&id) || request.dynamic_proxy_url.is_some();
match PROXY_MANAGER.update_stored_proxy(
let result = if is_dynamic {
PROXY_MANAGER.update_dynamic_proxy(
&state.app_handle,
&id,
Some(new_name.clone()),
Some(new_proxy_settings.clone()),
) {
Ok(_) => Ok(Json(ApiProxyResponse {
id,
name: new_name,
proxy_settings: new_proxy_settings,
})),
Err(_) => Err(StatusCode::BAD_REQUEST),
}
request.name,
request.dynamic_proxy_url,
request.dynamic_proxy_format,
)
} else {
Err(StatusCode::NOT_FOUND)
PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings)
};
match result {
Ok(proxy) => Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
dynamic_proxy_url: proxy.dynamic_proxy_url,
dynamic_proxy_format: proxy.dynamic_proxy_format,
proxy_settings: proxy.proxy_settings,
})),
Err(_) => Err(StatusCode::NOT_FOUND),
}
}
@@ -1289,6 +1313,13 @@ async fn run_profile(
State(state): State<ApiServerState>,
Json(request): Json<RunProfileRequest>,
) -> 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 url = request.url;
@@ -1357,6 +1388,13 @@ async fn open_url_in_profile(
State(state): State<ApiServerState>,
Json(request): Json<OpenUrlRequest>,
) -> 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();
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,
/// then resolve the proxy settings with profile-specific sid for sticky sessions.
/// Resolve proxy settings for a profile, returning an error for dynamic proxy failures.
/// Returns Ok(None) when no proxy is configured, Ok(Some) on success, Err on dynamic fetch failure.
async fn resolve_proxy_with_refresh(
&self,
proxy_id: Option<&String>,
profile_id: Option<&str>,
) -> Option<ProxySettings> {
let proxy_id = proxy_id?;
) -> Result<Option<ProxySettings>, String> {
let proxy_id = match proxy_id {
Some(id) => id,
None => return Ok(None),
};
// Handle dynamic proxies: fetch from URL at launch time
if PROXY_MANAGER.is_dynamic_proxy(proxy_id) {
log::info!("Fetching dynamic proxy settings for proxy {proxy_id}");
let settings = PROXY_MANAGER.resolve_dynamic_proxy(proxy_id).await?;
return Ok(Some(settings));
}
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
log::info!("Refreshing cloud proxy credentials before launch for proxy {proxy_id}");
CLOUD_AUTH.sync_cloud_proxy().await;
@@ -53,10 +66,10 @@ impl BrowserRunner {
// For cloud-derived proxies, inject profile-specific sid for sticky sessions
if let Some(pid) = profile_id {
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
return PROXY_MANAGER.resolve_proxy_for_profile(proxy_id, pid);
return Ok(PROXY_MANAGER.resolve_proxy_for_profile(proxy_id, pid));
}
}
PROXY_MANAGER.get_proxy_settings_by_id(proxy_id)
Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))
}
/// Get the executable path for a browser profile
@@ -117,7 +130,8 @@ impl BrowserRunner {
// Refresh cloud proxy credentials if needed before resolving
let mut upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await;
.await
.map_err(|e| -> Box<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 upstream_proxy.is_none() {
@@ -375,7 +389,8 @@ impl BrowserRunner {
// Refresh cloud proxy credentials if needed before resolving
let mut upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await;
.await
.map_err(|e| -> Box<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 upstream_proxy.is_none() {
@@ -728,7 +743,8 @@ impl BrowserRunner {
// Refresh cloud proxy credentials before resolving
let upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await;
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
@@ -2231,7 +2247,7 @@ pub async fn launch_browser_profile(
profile_for_launch.proxy_id.as_ref(),
Some(&profile_for_launch.id.to_string()),
)
.await;
.await?;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
-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
.launch_camoufox(
&app_handle,
@@ -701,77 +698,6 @@ impl CamoufoxManager {
}
}
fn write_default_search_config(profile_path: &std::path::Path) {
let search_file = profile_path.join("search.json.mozlz4");
if search_file.exists() {
return;
}
let json = serde_json::json!({
"version": 6,
"engines": [
{
"_name": "DuckDuckGo",
"_isAppProvided": false,
"_metaData": { "order": 1 },
"_urls": [
{
"template": "https://duckduckgo.com/?q={searchTerms}",
"type": "text/html",
"params": []
},
{
"template": "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
"type": "application/x-suggestions+json",
"params": []
}
],
"_iconURL": "https://duckduckgo.com/favicon.ico"
},
{
"_name": "Google",
"_isAppProvided": false,
"_metaData": { "order": 2 },
"_urls": [
{
"template": "https://www.google.com/search?q={searchTerms}",
"type": "text/html",
"params": []
},
{
"template": "https://www.google.com/complete/search?client=firefox&q={searchTerms}",
"type": "application/x-suggestions+json",
"params": []
}
],
"_iconURL": "https://www.google.com/favicon.ico"
}
],
"metaData": {
"useSavedOrder": false,
"defaultEngineId": "DuckDuckGo"
}
});
let json_bytes = match serde_json::to_vec(&json) {
Ok(bytes) => bytes,
Err(e) => {
log::warn!("Failed to serialize search config: {e}");
return;
}
};
let magic = b"mozLz40\0";
let compressed = lz4_flex::block::compress_prepend_size(&json_bytes);
let mut output = Vec::with_capacity(magic.len() + compressed.len());
output.extend_from_slice(magic);
output.extend_from_slice(&compressed);
if let Err(e) = std::fs::write(&search_file, &output) {
log::warn!("Failed to write search.json.mozlz4: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
+3 -3
View File
@@ -1080,8 +1080,8 @@ impl CloudAuthManager {
// Sync cloud proxy credentials
CLOUD_AUTH.sync_cloud_proxy().await;
// Refresh wayfern token every 12 hours (72 iterations of 10-minute loop)
if wayfern_refresh_counter >= 72 {
// Refresh wayfern token every 10 hours (60 iterations of 10-minute loop)
if wayfern_refresh_counter >= 60 {
wayfern_refresh_counter = 0;
if CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
@@ -1390,7 +1390,7 @@ pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), St
}
}
Err(e) => {
log::debug!("Sync not configured, skipping missing profile check: {}", e);
log::warn!("Sync not configured, skipping missing profile check: {}", e);
}
}
+186 -25
View File
@@ -7,6 +7,112 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tauri::AppHandle;
/// Chromium cookie encryption/decryption support.
/// On macOS: uses "Chromium Safe Storage" key from Keychain with PBKDF2 + AES-128-CBC.
/// On Linux: uses os_crypt_key file from profile directory with PBKDF2 + AES-128-CBC.
mod chrome_decrypt {
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
use std::path::Path;
type Aes128CbcDec = cbc::Decryptor<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
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnifiedCookie {
@@ -77,6 +183,12 @@ impl CookieManager {
/// Windows epoch offset: seconds between 1601-01-01 and 1970-01-01
const WINDOWS_EPOCH_DIFF: i64 = 11644473600;
/// Get the Chrome cookie encryption key for a Wayfern profile
fn get_chrome_encryption_key(profile: &BrowserProfile, profiles_dir: &Path) -> Option<[u8; 16]> {
let profile_data_path = profile.get_profile_data_path(profiles_dir);
chrome_decrypt::get_encryption_key(&profile_data_path)
}
/// Get the cookie database path for a profile
fn get_cookie_db_path(profile: &BrowserProfile, profiles_dir: &Path) -> Result<PathBuf, String> {
let profile_data_path = profile.get_profile_data_path(profiles_dir);
@@ -155,31 +267,58 @@ impl CookieManager {
Ok(cookies)
}
/// Read cookies from a Chrome/Wayfern profile
fn read_chrome_cookies(db_path: &Path) -> Result<Vec<UnifiedCookie>, String> {
/// Read cookies from a Chrome/Wayfern profile.
/// Handles encrypted cookies by decrypting encrypted_value using the profile's encryption key.
fn read_chrome_cookies(
db_path: &Path,
encryption_key: Option<&[u8; 16]>,
) -> Result<Vec<UnifiedCookie>, String> {
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
let mut stmt = conn
.prepare(
"SELECT name, value, host_key, path, expires_utc, is_secure,
is_httponly, samesite, creation_utc, last_access_utc
FROM cookies",
is_httponly, samesite, creation_utc, last_access_utc, encrypted_value
FROM cookies",
)
.map_err(|e| format!("Failed to prepare statement: {e}"))?;
let cookies = stmt
.query_map([], |row| {
let name: String = row.get(0)?;
let plaintext_value: String = row.get(1)?;
let domain: String = row.get(2)?;
let path: String = row.get(3)?;
let expires_utc: i64 = row.get(4)?;
let is_secure: i32 = row.get(5)?;
let is_httponly: i32 = row.get(6)?;
let samesite: i32 = row.get(7)?;
let creation_utc: i64 = row.get(8)?;
let last_access_utc: i64 = row.get(9)?;
let encrypted_value: Vec<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 {
name: row.get(0)?,
value: row.get(1)?,
domain: row.get(2)?,
path: row.get(3)?,
expires: Self::chrome_time_to_unix(row.get(4)?),
is_secure: row.get::<_, i32>(5)? != 0,
is_http_only: row.get::<_, i32>(6)? != 0,
same_site: row.get(7)?,
creation_time: Self::chrome_time_to_unix(row.get(8)?),
last_accessed: Self::chrome_time_to_unix(row.get(9)?),
name,
value,
domain,
path,
expires: Self::chrome_time_to_unix(expires_utc),
is_secure: is_secure != 0,
is_http_only: is_httponly != 0,
same_site: samesite,
creation_time: Self::chrome_time_to_unix(creation_utc),
last_accessed: Self::chrome_time_to_unix(last_access_utc),
})
})
.map_err(|e| format!("Failed to query cookies: {e}"))?
@@ -256,10 +395,12 @@ impl CookieManager {
Ok((copied, replaced))
}
/// Write cookies to a Chrome/Wayfern profile
/// Write cookies to a Chrome/Wayfern profile.
/// If an encryption key is available, stores cookies encrypted in encrypted_value.
fn write_chrome_cookies(
db_path: &Path,
cookies: &[UnifiedCookie],
encryption_key: Option<&[u8; 16]>,
) -> Result<(usize, usize), String> {
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
@@ -272,6 +413,12 @@ impl CookieManager {
.as_secs() as i64;
for cookie in cookies {
// Prepare value/encrypted_value based on whether we have an encryption key
let (value_str, encrypted_bytes): (&str, Vec<u8>) = match encryption_key {
Some(key) => ("", chrome_decrypt::encrypt(&cookie.value, key)),
None => (cookie.value.as_str(), Vec::new()),
};
let existing: Option<i64> = conn
.query_row(
"SELECT rowid FROM cookies WHERE host_key = ?1 AND name = ?2 AND path = ?3",
@@ -283,11 +430,12 @@ impl CookieManager {
if existing.is_some() {
conn
.execute(
"UPDATE cookies SET value = ?1, expires_utc = ?2, is_secure = ?3,
is_httponly = ?4, samesite = ?5, last_access_utc = ?6, last_update_utc = ?7
WHERE host_key = ?8 AND name = ?9 AND path = ?10",
"UPDATE cookies SET value = ?1, encrypted_value = ?2, expires_utc = ?3, is_secure = ?4,
is_httponly = ?5, samesite = ?6, last_access_utc = ?7, last_update_utc = ?8
WHERE host_key = ?9 AND name = ?10 AND path = ?11",
params![
&cookie.value,
value_str,
encrypted_bytes,
Self::unix_to_chrome_time(cookie.expires),
cookie.is_secure as i32,
cookie.is_http_only as i32,
@@ -308,12 +456,13 @@ impl CookieManager {
path, expires_utc, is_secure, is_httponly, last_access_utc, has_expires,
is_persistent, priority, samesite, source_scheme, source_port, source_type,
has_cross_site_ancestor, last_update_utc)
VALUES (?1, ?2, '', ?3, ?4, X'', ?5, ?6, ?7, ?8, ?9, 1, 1, 1, ?10, 2, -1, 0, 0, ?11)",
VALUES (?1, ?2, '', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 1, 1, 1, ?11, 2, -1, 0, 0, ?12)",
params![
Self::unix_to_chrome_time(cookie.creation_time),
&cookie.domain,
&cookie.name,
&cookie.value,
value_str,
encrypted_bytes,
&cookie.path,
Self::unix_to_chrome_time(cookie.expires),
cookie.is_secure as i32,
@@ -348,7 +497,10 @@ impl CookieManager {
let cookies = match profile.browser.as_str() {
"camoufox" => Self::read_firefox_cookies(&db_path)?,
"wayfern" => Self::read_chrome_cookies(&db_path)?,
"wayfern" => {
let key = Self::get_chrome_encryption_key(profile, &profiles_dir);
Self::read_chrome_cookies(&db_path, key.as_ref())?
}
_ => return Err(format!("Unsupported browser type: {}", profile.browser)),
};
@@ -401,7 +553,10 @@ impl CookieManager {
let source_db_path = Self::get_cookie_db_path(source, &profiles_dir)?;
let all_cookies = match source.browser.as_str() {
"camoufox" => Self::read_firefox_cookies(&source_db_path)?,
"wayfern" => Self::read_chrome_cookies(&source_db_path)?,
"wayfern" => {
let key = Self::get_chrome_encryption_key(source, &profiles_dir);
Self::read_chrome_cookies(&source_db_path, key.as_ref())?
}
_ => return Err(format!("Unsupported browser type: {}", source.browser)),
};
@@ -468,7 +623,10 @@ impl CookieManager {
let write_result = match target.browser.as_str() {
"camoufox" => Self::write_firefox_cookies(&target_db_path, &cookies_to_copy),
"wayfern" => Self::write_chrome_cookies(&target_db_path, &cookies_to_copy),
"wayfern" => {
let key = Self::get_chrome_encryption_key(target, &profiles_dir);
Self::write_chrome_cookies(&target_db_path, &cookies_to_copy, key.as_ref())
}
_ => {
results.push(CookieCopyResult {
target_profile_id: target_id.clone(),
@@ -733,7 +891,10 @@ impl CookieManager {
let write_result = match profile.browser.as_str() {
"camoufox" => Self::write_firefox_cookies(&db_path, &cookies),
"wayfern" => Self::write_chrome_cookies(&db_path, &cookies),
"wayfern" => {
let key = Self::get_chrome_encryption_key(profile, &profiles_dir);
Self::write_chrome_cookies(&db_path, &cookies, key.as_ref())
}
_ => return Err(format!("Unsupported browser type: {}", profile.browser)),
};
-173
View File
@@ -292,13 +292,6 @@ impl Downloader {
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>(
&self,
_app_handle: &tauri::AppHandle<R>,
@@ -850,10 +843,6 @@ impl Downloader {
{
log::warn!("Failed to create version.json for Camoufox: {e}");
}
if let Err(e) = self.configure_camoufox_search_engine(&browser_dir) {
log::warn!("Failed to configure Camoufox search engine: {e}");
}
}
// Emit completion
@@ -948,168 +937,6 @@ pub async fn cancel_download(browser_str: String, version: String) -> Result<(),
}
}
/// Find all candidate `distribution/` directories inside the Camoufox browser dir.
/// On macOS: `<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)]
mod tests {
use super::*;
+65 -10
View File
@@ -37,6 +37,7 @@ pub mod proxy_server;
pub mod proxy_storage;
mod settings_manager;
pub mod sync;
mod synchronizer;
pub mod traffic_stats;
mod wayfern_manager;
mod wayfern_terms;
@@ -208,11 +209,21 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
async fn create_stored_proxy(
app_handle: tauri::AppHandle,
name: String,
proxy_settings: crate::browser::ProxySettings,
proxy_settings: Option<crate::browser::ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
) -> Result<crate::proxy_manager::StoredProxy, String> {
crate::proxy_manager::PROXY_MANAGER
.create_stored_proxy(&app_handle, name, proxy_settings)
.map_err(|e| format!("Failed to create stored proxy: {e}"))
if let (Some(url), Some(format)) = (&dynamic_proxy_url, &dynamic_proxy_format) {
crate::proxy_manager::PROXY_MANAGER
.create_dynamic_proxy(&app_handle, name, url.clone(), format.clone())
.map_err(|e| format!("Failed to create dynamic proxy: {e}"))
} else if let Some(settings) = proxy_settings {
crate::proxy_manager::PROXY_MANAGER
.create_stored_proxy(&app_handle, name, settings)
.map_err(|e| format!("Failed to create stored proxy: {e}"))
} else {
Err("Either proxy_settings or dynamic proxy URL and format are required".to_string())
}
}
#[tauri::command]
@@ -226,10 +237,26 @@ async fn update_stored_proxy(
proxy_id: String,
name: Option<String>,
proxy_settings: Option<crate::browser::ProxySettings>,
dynamic_proxy_url: Option<String>,
dynamic_proxy_format: Option<String>,
) -> Result<crate::proxy_manager::StoredProxy, String> {
crate::proxy_manager::PROXY_MANAGER
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
.map_err(|e| format!("Failed to update stored proxy: {e}"))
// Check if this is a dynamic proxy update
let is_dynamic = crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id);
if is_dynamic || dynamic_proxy_url.is_some() {
crate::proxy_manager::PROXY_MANAGER
.update_dynamic_proxy(
&app_handle,
&proxy_id,
name,
dynamic_proxy_url,
dynamic_proxy_format,
)
.map_err(|e| format!("Failed to update dynamic proxy: {e}"))
} else {
crate::proxy_manager::PROXY_MANAGER
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
.map_err(|e| format!("Failed to update stored proxy: {e}"))
}
}
#[tauri::command]
@@ -242,10 +269,32 @@ async fn delete_stored_proxy(app_handle: tauri::AppHandle, proxy_id: String) ->
#[tauri::command]
async fn check_proxy_validity(
proxy_id: String,
proxy_settings: crate::browser::ProxySettings,
proxy_settings: Option<crate::browser::ProxySettings>,
) -> 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
.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
}
@@ -1477,7 +1526,7 @@ pub fn run() {
}
}
Err(e) => {
log::debug!("Sync not configured, skipping missing profile check: {}", e);
log::warn!("Sync not configured, skipping missing profile check: {}", e);
}
}
@@ -1572,6 +1621,7 @@ pub fn run() {
update_stored_proxy,
delete_stored_proxy,
check_proxy_validity,
fetch_dynamic_proxy,
get_cached_proxy_check,
export_proxies,
import_proxies_json,
@@ -1669,6 +1719,11 @@ pub fn run() {
// Team lock commands
team_lock::get_team_locks,
team_lock::get_team_lock_status,
// Synchronizer commands
synchronizer::start_sync_session,
synchronizer::stop_sync_session,
synchronizer::remove_sync_follower,
synchronizer::get_sync_sessions,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
+381 -77
View File
@@ -96,6 +96,16 @@ impl McpServer {
self.is_running.load(Ordering::SeqCst)
}
async fn require_paid_subscription(feature: &str) -> Result<(), McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: format!("{feature} requires an active paid subscription"),
});
}
Ok(())
}
pub fn get_port(&self) -> Option<u16> {
let port = self.port.load(Ordering::SeqCst);
if port > 0 {
@@ -561,7 +571,7 @@ impl McpServer {
},
McpTool {
name: "create_proxy".to_string(),
description: "Create a new proxy configuration".to_string(),
description: "Create a new proxy configuration. For regular proxies, provide proxy_type/host/port. For dynamic proxies, provide dynamic_proxy_url and dynamic_proxy_format instead.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -572,26 +582,35 @@ impl McpServer {
"proxy_type": {
"type": "string",
"enum": ["http", "https", "socks4", "socks5"],
"description": "The type of proxy"
"description": "The type of proxy (for regular proxies)"
},
"host": {
"type": "string",
"description": "The proxy host address"
"description": "The proxy host address (for regular proxies)"
},
"port": {
"type": "integer",
"description": "The proxy port number"
"description": "The proxy port number (for regular proxies)"
},
"username": {
"type": "string",
"description": "Optional username for authentication"
"description": "Optional username for authentication (for regular proxies)"
},
"password": {
"type": "string",
"description": "Optional password for authentication"
"description": "Optional password for authentication (for regular proxies)"
},
"dynamic_proxy_url": {
"type": "string",
"description": "URL to fetch proxy settings from (for dynamic proxies)"
},
"dynamic_proxy_format": {
"type": "string",
"enum": ["json", "text"],
"description": "Format of the dynamic proxy response: 'json' for JSON object or 'text' for text like host:port:user:pass (for dynamic proxies)"
}
},
"required": ["name", "proxy_type", "host", "port"]
"required": ["name"]
}),
},
McpTool {
@@ -611,23 +630,32 @@ impl McpServer {
"proxy_type": {
"type": "string",
"enum": ["http", "https", "socks4", "socks5"],
"description": "The type of proxy"
"description": "The type of proxy (for regular proxies)"
},
"host": {
"type": "string",
"description": "The proxy host address"
"description": "The proxy host address (for regular proxies)"
},
"port": {
"type": "integer",
"description": "The proxy port number"
"description": "The proxy port number (for regular proxies)"
},
"username": {
"type": "string",
"description": "Optional username for authentication"
"description": "Optional username for authentication (for regular proxies)"
},
"password": {
"type": "string",
"description": "Optional password for authentication"
"description": "Optional password for authentication (for regular proxies)"
},
"dynamic_proxy_url": {
"type": "string",
"description": "URL to fetch proxy settings from (for dynamic proxies)"
},
"dynamic_proxy_format": {
"type": "string",
"enum": ["json", "text"],
"description": "Format of the dynamic proxy response (for dynamic proxies)"
}
},
"required": ["proxy_id"]
@@ -926,6 +954,66 @@ impl McpServer {
"required": ["profile_id"]
}),
},
// Synchronizer tools
McpTool {
name: "start_sync_session".to_string(),
description: "Start a synchronizer session. Launches a leader profile and follower profiles, then mirrors all actions from the leader to the followers in real time. Only Wayfern profiles are supported. Requires paid subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"leader_profile_id": {
"type": "string",
"description": "The UUID of the leader profile"
},
"follower_profile_ids": {
"type": "array",
"items": { "type": "string" },
"description": "UUIDs of follower profiles"
}
},
"required": ["leader_profile_id", "follower_profile_ids"]
}),
},
McpTool {
name: "stop_sync_session".to_string(),
description: "Stop an active synchronizer session. Kills all follower profiles and the leader.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "The sync session ID"
}
},
"required": ["session_id"]
}),
},
McpTool {
name: "get_sync_sessions".to_string(),
description: "List all active synchronizer sessions.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
McpTool {
name: "remove_sync_follower".to_string(),
description: "Remove a follower from an active synchronizer session.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "The sync session ID"
},
"follower_profile_id": {
"type": "string",
"description": "The UUID of the follower to remove"
}
},
"required": ["session_id", "follower_profile_id"]
}),
},
// Browser interaction tools
McpTool {
name: "navigate".to_string(),
@@ -1165,7 +1253,10 @@ impl McpServer {
match tool_name {
"list_profiles" => self.handle_list_profiles().await,
"get_profile" => self.handle_get_profile(&arguments).await,
"run_profile" => self.handle_run_profile(&arguments).await,
"run_profile" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_run_profile(&arguments).await
}
"kill_profile" => self.handle_kill_profile(&arguments).await,
"create_profile" => self.handle_create_profile(&arguments).await,
"update_profile" => self.handle_update_profile(&arguments).await,
@@ -1217,14 +1308,43 @@ impl McpServer {
// Team lock tools
"get_team_locks" => self.handle_get_team_locks().await,
"get_team_lock_status" => self.handle_get_team_lock_status(&arguments).await,
// Browser interaction tools
"navigate" => self.handle_navigate(&arguments).await,
"screenshot" => self.handle_screenshot(&arguments).await,
"evaluate_javascript" => self.handle_evaluate_javascript(&arguments).await,
"click_element" => self.handle_click_element(&arguments).await,
"type_text" => self.handle_type_text(&arguments).await,
"get_page_content" => self.handle_get_page_content(&arguments).await,
"get_page_info" => self.handle_get_page_info(&arguments).await,
// Synchronizer tools
"start_sync_session" => {
Self::require_paid_subscription("Synchronizer").await?;
self.handle_start_sync_session(&arguments).await
}
"stop_sync_session" => self.handle_stop_sync_session(&arguments).await,
"get_sync_sessions" => self.handle_get_sync_sessions().await,
"remove_sync_follower" => self.handle_remove_sync_follower(&arguments).await,
// Browser interaction tools (require paid subscription)
"navigate" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_navigate(&arguments).await
}
"screenshot" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_screenshot(&arguments).await
}
"evaluate_javascript" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_evaluate_javascript(&arguments).await
}
"click_element" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_click_element(&arguments).await
}
"type_text" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_type_text(&arguments).await
}
"get_page_content" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_get_page_content(&arguments).await
}
"get_page_info" => {
Self::require_paid_subscription("Browser automation").await?;
self.handle_get_page_info(&arguments).await
}
_ => Err(McpError {
code: -32602,
message: format!("Unknown tool: {tool_name}"),
@@ -2013,59 +2133,79 @@ impl McpServer {
message: "Missing name".to_string(),
})?;
let proxy_type = arguments
.get("proxy_type")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing proxy_type".to_string(),
})?;
let host = arguments
.get("host")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing host".to_string(),
})?;
let port = arguments
.get("port")
.and_then(|v| v.as_u64())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing port".to_string(),
})? as u16;
let username = arguments
.get("username")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let password = arguments
.get("password")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let proxy_settings = ProxySettings {
proxy_type: proxy_type.to_string(),
host: host.to_string(),
port,
username,
password,
};
let inner = self.inner.lock().await;
let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
})?;
let proxy = PROXY_MANAGER
.create_stored_proxy(app_handle, name.to_string(), proxy_settings)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to create proxy: {e}"),
})?;
// Check if this is a dynamic proxy creation
let dynamic_url = arguments.get("dynamic_proxy_url").and_then(|v| v.as_str());
let dynamic_format = arguments
.get("dynamic_proxy_format")
.and_then(|v| v.as_str());
let proxy = if let (Some(url), Some(format)) = (dynamic_url, dynamic_format) {
PROXY_MANAGER
.create_dynamic_proxy(
app_handle,
name.to_string(),
url.to_string(),
format.to_string(),
)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to create dynamic proxy: {e}"),
})?
} else {
let proxy_type = arguments
.get("proxy_type")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing proxy_type (required for regular proxies)".to_string(),
})?;
let host = arguments
.get("host")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing host (required for regular proxies)".to_string(),
})?;
let port = arguments
.get("port")
.and_then(|v| v.as_u64())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing port (required for regular proxies)".to_string(),
})? as u16;
let username = arguments
.get("username")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let password = arguments
.get("password")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let proxy_settings = ProxySettings {
proxy_type: proxy_type.to_string(),
host: host.to_string(),
port,
username,
password,
};
PROXY_MANAGER
.create_stored_proxy(app_handle, name.to_string(), proxy_settings)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to create proxy: {e}"),
})?
};
Ok(serde_json::json!({
"content": [{
@@ -2155,12 +2295,32 @@ impl McpServer {
message: "MCP server not properly initialized".to_string(),
})?;
let proxy = PROXY_MANAGER
.update_stored_proxy(app_handle, proxy_id, name, proxy_settings)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update proxy: {e}"),
})?;
// Check for dynamic proxy fields
let dynamic_url = arguments
.get("dynamic_proxy_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let dynamic_format = arguments
.get("dynamic_proxy_format")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(proxy_id) || dynamic_url.is_some();
let proxy = if is_dynamic {
PROXY_MANAGER
.update_dynamic_proxy(app_handle, proxy_id, name, dynamic_url, dynamic_format)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update dynamic proxy: {e}"),
})?
} else {
PROXY_MANAGER
.update_stored_proxy(app_handle, proxy_id, name, proxy_settings)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update proxy: {e}"),
})?
};
Ok(serde_json::json!({
"content": [{
@@ -3030,9 +3190,8 @@ impl McpServer {
let url = format!("http://127.0.0.1:{port}/json");
let client = reqwest::Client::new();
// Retry connecting to CDP endpoint — Wayfern closes the debugging port
// briefly after launch for anti-detection and reopens it after ~30s.
let max_attempts = 45;
// Retry connecting to CDP endpoint (browser may still be starting up)
let max_attempts = 15;
let mut last_err = String::new();
for attempt in 0..max_attempts {
if attempt > 0 {
@@ -3900,6 +4059,146 @@ impl McpServer {
}]
}))
}
// --- Synchronizer handlers ---
async fn handle_start_sync_session(
&self,
arguments: &serde_json::Value,
) -> Result<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! {
@@ -3963,6 +4262,11 @@ mod tests {
// Team lock tools
assert!(tool_names.contains(&"get_team_locks"));
assert!(tool_names.contains(&"get_team_lock_status"));
// Synchronizer tools
assert!(tool_names.contains(&"start_sync_session"));
assert!(tool_names.contains(&"stop_sync_session"));
assert!(tool_names.contains(&"get_sync_sessions"));
assert!(tool_names.contains(&"remove_sync_follower"));
// Browser interaction tools
assert!(tool_names.contains(&"navigate"));
assert!(tool_names.contains(&"screenshot"));
+279
View File
@@ -117,6 +117,10 @@ pub struct StoredProxy {
pub geo_city: Option<String>,
#[serde(default)]
pub geo_isp: Option<String>,
#[serde(default)]
pub dynamic_proxy_url: Option<String>,
#[serde(default)]
pub dynamic_proxy_format: Option<String>,
}
impl StoredProxy {
@@ -135,9 +139,15 @@ impl StoredProxy {
geo_region: None,
geo_city: None,
geo_isp: None,
dynamic_proxy_url: None,
dynamic_proxy_format: None,
}
}
pub fn is_dynamic(&self) -> bool {
self.dynamic_proxy_url.is_some()
}
/// Migrate legacy geo_state to geo_region
pub fn migrate_geo_fields(&mut self) {
if self.geo_region.is_none() && self.geo_state.is_some() {
@@ -450,6 +460,8 @@ impl ProxyManager {
geo_region: None,
geo_city: None,
geo_isp: None,
dynamic_proxy_url: None,
dynamic_proxy_format: None,
};
stored_proxies.insert(CLOUD_PROXY_ID.to_string(), cloud_proxy.clone());
drop(stored_proxies);
@@ -639,6 +651,8 @@ impl ProxyManager {
geo_region: region,
geo_city: city,
geo_isp: isp,
dynamic_proxy_url: None,
dynamic_proxy_format: None,
};
{
@@ -965,6 +979,269 @@ impl ProxyManager {
self.load_proxy_check_cache(proxy_id)
}
// Check if a stored proxy is dynamic
pub fn is_dynamic_proxy(&self, proxy_id: &str) -> bool {
let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.get(proxy_id).is_some_and(|p| p.is_dynamic())
}
// Fetch proxy settings from a dynamic proxy URL
pub async fn fetch_dynamic_proxy(
&self,
url: &str,
format: &str,
) -> Result<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
pub fn export_proxies_json(&self) -> Result<String, String> {
let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -2835,6 +3112,8 @@ mod tests {
geo_region: None,
geo_city: None,
geo_isp: None,
dynamic_proxy_url: None,
dynamic_proxy_format: None,
};
// Before migration
+30 -1
View File
@@ -127,6 +127,14 @@ impl SyncClient {
}
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
.client
.post(self.url("list"))
@@ -134,7 +142,7 @@ impl SyncClient {
.json(&ListRequest {
prefix: prefix.to_string(),
max_keys: Some(1000),
continuation_token: None,
continuation_token,
})
.send()
.await
@@ -152,6 +160,27 @@ impl SyncClient {
.map_err(|e| SyncError::SerializationError(e.to_string()))
}
/// List all objects under a prefix, paginating through all results
pub async fn list_all(&self, prefix: &str) -> SyncResult<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(
&self,
presigned_url: &str,
+224 -8
View File
@@ -9,7 +9,7 @@ use crate::settings_manager::SettingsManager;
use chrono::{DateTime, Utc};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
@@ -49,6 +49,70 @@ fn is_critical_file(path: &str) -> bool {
.any(|pattern| path.contains(pattern))
}
/// Checkpoint all SQLite WAL files in a profile directory.
///
/// When a browser crashes or is killed, SQLite WAL files may contain
/// uncommitted data (e.g. cookies, login data). Since WAL files are
/// excluded from sync, we must checkpoint them into the main database
/// files before generating the manifest to avoid data loss.
fn checkpoint_sqlite_wal_files(profile_dir: &Path) {
fn find_wal_files(dir: &Path, wal_files: &mut Vec<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
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct SyncResumeState {
@@ -362,6 +426,10 @@ impl SyncEngine {
))
})?;
// Checkpoint any SQLite WAL files to ensure all data is in the main DB
// before we generate the manifest (WAL files are excluded from sync)
checkpoint_sqlite_wal_files(&profile_dir);
// Load or create hash cache
let cache_path = get_cache_path(&profile_dir);
let mut hash_cache = HashCache::load(&cache_path);
@@ -488,9 +556,22 @@ impl SyncEngine {
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
// If we recovered from an empty local state (downloaded everything from remote),
// regenerate the manifest from the actual files now on disk so we don't
// overwrite the remote manifest with an empty one.
let final_manifest = if local_manifest.files.is_empty() && !diff.files_to_download.is_empty() {
let mut new_cache = HashCache::load(&cache_path);
let mut regenerated = generate_manifest(&profile_id, &profile_dir, &mut new_cache)?;
new_cache.save(&cache_path)?;
regenerated.encrypted = encryption_key.is_some();
regenerated
} else {
let mut m = local_manifest;
m.encrypted = encryption_key.is_some();
m
};
// Upload manifest.json last for atomicity
let mut final_manifest = local_manifest;
final_manifest.encrypted = encryption_key.is_some();
self
.upload_manifest(&profile_id, &final_manifest, &key_prefix)
.await?;
@@ -2165,14 +2246,14 @@ impl SyncEngine {
) -> SyncResult<Vec<String>> {
log::info!("Checking for missing synced profiles...");
// List personal profiles from S3
let list_response = self.client.list("profiles/").await?;
// List all personal profiles from S3 (paginated)
let all_objects = self.client.list_all("profiles/").await?;
let mut downloaded: Vec<String> = Vec::new();
// Extract unique profile IDs with their key prefix
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 let Some(profile_id) = obj
.key
@@ -2189,8 +2270,8 @@ impl SyncEngine {
if let Some(team_id) = &auth.user.team_id {
let team_prefix = format!("teams/{}/", team_id);
let team_list_key = format!("{}profiles/", team_prefix);
if let Ok(team_list) = self.client.list(&team_list_key).await {
for obj in team_list.objects {
if let Ok(team_objects) = self.client.list_all(&team_list_key).await {
for obj in team_objects {
if obj.key.starts_with("profiles/") && obj.key.ends_with("/manifest.json") {
if let Some(profile_id) = obj
.key
@@ -3341,3 +3422,138 @@ pub async fn set_extension_group_sync_enabled(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_checkpoint_sqlite_wal_files() {
let temp_dir = tempfile::TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
// Create a SQLite database in WAL mode and insert data.
// Use std::mem::forget to prevent the connection destructor from running,
// which simulates a browser crash where WAL is not checkpointed.
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn.pragma_update(None, "wal_autocheckpoint", "0").unwrap();
conn
.execute(
"CREATE TABLE cookies (id INTEGER PRIMARY KEY, value TEXT)",
[],
)
.unwrap();
conn
.execute(
"INSERT INTO cookies (value) VALUES ('session_token_123')",
[],
)
.unwrap();
// Leak the connection to prevent auto-checkpoint on drop
std::mem::forget(conn);
}
// Verify WAL file exists and has data
let wal_path = temp_dir.path().join("test.db-wal");
assert!(wal_path.exists(), "WAL file should exist");
let wal_size = fs::metadata(&wal_path).unwrap().len();
assert!(wal_size > 0, "WAL file should be non-empty");
// Run checkpoint
checkpoint_sqlite_wal_files(temp_dir.path());
// After checkpoint, WAL should be truncated (empty)
let wal_size_after = fs::metadata(&wal_path).map(|m| m.len()).unwrap_or(0);
assert_eq!(
wal_size_after, 0,
"WAL should be truncated after checkpoint"
);
// Verify data is still accessible from the main database
let conn = rusqlite::Connection::open(&db_path).unwrap();
let value: String = conn
.query_row("SELECT value FROM cookies WHERE id = 1", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(value, "session_token_123");
}
#[test]
fn test_checkpoint_handles_missing_db() {
let temp_dir = tempfile::TempDir::new().unwrap();
// Create a WAL file without a corresponding database
let wal_path = temp_dir.path().join("missing.db-wal");
fs::write(&wal_path, b"fake wal data").unwrap();
// Should not panic
checkpoint_sqlite_wal_files(temp_dir.path());
}
#[test]
fn test_checkpoint_skips_empty_wal() {
let temp_dir = tempfile::TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
// Create a database and checkpoint immediately (WAL is empty)
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn
.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)", [])
.unwrap();
}
// Create an empty WAL file
let wal_path = temp_dir.path().join("test.db-wal");
fs::write(&wal_path, b"").unwrap();
// Should skip empty WAL without error
checkpoint_sqlite_wal_files(temp_dir.path());
}
#[test]
fn test_checkpoint_nested_directories() {
let temp_dir = tempfile::TempDir::new().unwrap();
let nested_dir = temp_dir.path().join("profile").join("Default");
fs::create_dir_all(&nested_dir).unwrap();
let db_path = nested_dir.join("Cookies");
// Create a database with WAL data, leak connection to simulate crash
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn.pragma_update(None, "wal_autocheckpoint", "0").unwrap();
conn
.execute(
"CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT)",
[],
)
.unwrap();
conn
.execute(
"INSERT INTO cookies VALUES ('.example.com', 'session', 'abc')",
[],
)
.unwrap();
std::mem::forget(conn);
}
let wal_path = nested_dir.join("Cookies-wal");
assert!(wal_path.exists());
// Checkpoint from the top-level directory
checkpoint_sqlite_wal_files(temp_dir.path());
// Verify data is in the main database
let conn = rusqlite::Connection::open(&db_path).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM cookies", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 1);
}
}
+59
View File
@@ -408,6 +408,19 @@ pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> Mani
let remote_files: HashMap<&str, &ManifestFileEntry> =
remote.files.iter().map(|f| (f.path.as_str(), f)).collect();
// Safety: if local is empty but remote has files, always download from remote.
// This prevents data loss when profile data files are deleted but metadata
// survives — the newly generated manifest would have updated_at=NOW, which
// would appear "newer" and cause all remote files to be deleted.
if local.files.is_empty() && !remote.files.is_empty() {
log::info!(
"Local manifest is empty but remote has {} files — downloading from remote to recover",
remote.files.len()
);
diff.files_to_download = remote.files.clone();
return diff;
}
// Compare timestamps to determine direction
let local_updated = local.updated_at_datetime();
let remote_updated = remote.updated_at_datetime();
@@ -738,4 +751,50 @@ mod tests {
let deserialized: SyncManifest = serde_json::from_str(&serialized).unwrap();
assert!(deserialized.encrypted);
}
#[test]
fn test_compute_diff_empty_local_downloads_from_remote() {
// When local has no files but remote does, always download from remote.
// This prevents data loss when profile data is deleted but metadata survives.
let local = SyncManifest {
version: 1,
profile_id: "test".to_string(),
generated_at: Utc::now().to_rfc3339(),
updated_at: Utc::now().to_rfc3339(), // NOW — appears newer than remote
exclude_globs: vec![],
files: vec![],
encrypted: false,
};
let remote = SyncManifest {
version: 1,
profile_id: "test".to_string(),
generated_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
exclude_globs: vec![],
files: vec![
ManifestFileEntry {
path: "Cookies".to_string(),
size: 100,
mtime: 1000,
hash: "abc".to_string(),
},
ManifestFileEntry {
path: "Local State".to_string(),
size: 200,
mtime: 1000,
hash: "def".to_string(),
},
],
encrypted: false,
};
let diff = compute_diff(&local, Some(&remote));
// Must download all remote files, NOT delete them
assert_eq!(diff.files_to_download.len(), 2);
assert!(diff.files_to_upload.is_empty());
assert!(diff.files_to_delete_remote.is_empty());
assert!(diff.files_to_delete_local.is_empty());
}
}
+101 -86
View File
@@ -396,97 +396,112 @@ impl SyncScheduler {
ready
};
// Mark all profiles as in-flight and filter out duplicates
let mut to_sync = Vec::new();
for profile_id in profiles_to_sync {
// Mark as in-flight to prevent duplicate syncs
{
let mut in_flight = self.in_flight_profiles.lock().await;
if in_flight.contains(&profile_id) {
log::debug!("Profile {} already in-flight, skipping", profile_id);
continue;
}
in_flight.insert(profile_id.clone());
}
log::info!("Executing queued sync for profile {}", profile_id);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "syncing"
}),
);
let profile_to_sync = {
let profile_manager = ProfileManager::instance();
profile_manager.list_profiles().ok().and_then(|profiles| {
profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id && p.is_sync_enabled() && !p.is_cross_os())
})
};
let Some(profile) = profile_to_sync else {
// Remove from in-flight
let mut in_flight = self.in_flight_profiles.lock().await;
in_flight.remove(&profile_id);
let mut in_flight = self.in_flight_profiles.lock().await;
if in_flight.contains(&profile_id) {
log::debug!("Profile {} already in-flight, skipping", profile_id);
continue;
};
let result = match SyncEngine::create_from_settings(app_handle).await {
Ok(engine) => engine.sync_profile(app_handle, &profile).await,
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
Err(super::types::SyncError::NotConfigured)
}
};
// Remove from in-flight and check if sync just completed
let sync_just_completed = {
let mut in_flight = self.in_flight_profiles.lock().await;
in_flight.remove(&profile_id);
// If this was the last in-flight profile and there are no pending profiles, sync just completed
in_flight.is_empty()
&& self.pending_profiles.lock().await.is_empty()
&& self.pending_proxies.lock().await.is_empty()
&& self.pending_groups.lock().await.is_empty()
&& self.pending_vpns.lock().await.is_empty()
&& self.pending_extensions.lock().await.is_empty()
&& self.pending_extension_groups.lock().await.is_empty()
};
match result {
Ok(()) => {
log::info!("Profile {} synced successfully", profile_id);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "synced"
}),
);
}
Err(e) => {
log::error!("Failed to sync profile {}: {}", profile_id, e);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "error",
"error": e.to_string()
}),
);
}
}
in_flight.insert(profile_id.clone());
to_sync.push(profile_id);
}
// Trigger cleanup after sync completes if this was the last profile
if sync_just_completed {
log::debug!("All profile syncs completed, triggering cleanup");
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Cleanup after sync failed: {e}");
} else {
log::debug!("Cleanup after sync completed successfully");
// Sync all profiles in parallel
let mut sync_set = tokio::task::JoinSet::new();
for profile_id in to_sync {
let app = app_handle.clone();
let in_flight = self.in_flight_profiles.clone();
sync_set.spawn(async move {
log::info!("Executing queued sync for profile {}", profile_id);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "syncing"
}),
);
let profile_to_sync = {
let profile_manager = ProfileManager::instance();
profile_manager.list_profiles().ok().and_then(|profiles| {
profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id && p.is_sync_enabled() && !p.is_cross_os())
})
};
let Some(profile) = profile_to_sync else {
let mut inf = in_flight.lock().await;
inf.remove(&profile_id);
return;
};
let result = match SyncEngine::create_from_settings(&app).await {
Ok(engine) => engine.sync_profile(&app, &profile).await,
Err(e) => {
log::error!("Failed to create sync engine: {}", e);
Err(super::types::SyncError::NotConfigured)
}
};
{
let mut inf = in_flight.lock().await;
inf.remove(&profile_id);
}
match result {
Ok(()) => {
log::info!("Profile {} synced successfully", profile_id);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "synced"
}),
);
}
Err(e) => {
log::error!("Failed to sync profile {}: {}", profile_id, e);
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": "error",
"error": e.to_string()
}),
);
}
}
});
}
// Wait for all parallel syncs to finish
while let Some(result) = sync_set.join_next().await {
if let Err(e) = result {
log::error!("Profile sync task panicked: {e}");
}
}
// Trigger cleanup if everything is done
let all_done = {
let in_flight = self.in_flight_profiles.lock().await;
in_flight.is_empty()
&& self.pending_profiles.lock().await.is_empty()
&& self.pending_proxies.lock().await.is_empty()
&& self.pending_groups.lock().await.is_empty()
&& self.pending_vpns.lock().await.is_empty()
&& self.pending_extensions.lock().await.is_empty()
&& self.pending_extension_groups.lock().await.is_empty()
};
if all_done {
log::debug!("All profile syncs completed, triggering cleanup");
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::warn!("Cleanup after sync failed: {e}");
} else {
log::debug!("Cleanup after sync completed successfully");
}
}
}
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(",")));
}
// Pass wayfern token as CLI flag so the browser can gate CDP features
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
if let Some(ref token) = wayfern_token {
args.push(format!("--wayfern-token={token}"));
log::info!("Wayfern token passed as CLI flag (length: {})", token.len());
} else {
log::warn!("No wayfern token available — CDP gated methods will be blocked");
}
// Don't add URL to args - we'll navigate via CDP after setting fingerprint
// This ensures fingerprint is applied at navigation commit time
@@ -674,25 +683,6 @@ impl WayfernManager {
}
}
// Close the debugging port to prevent localhost port-scan detection.
// Reopen on a random high port after 5s so we can still manage the browser.
let reopen_port = port; // Reopen on same port for find_wayfern_by_profile recovery
if let Some(target) = page_targets.first() {
if let Some(ws_url) = &target.websocket_debugger_url {
match self
.send_cdp_command(
ws_url,
"Wayfern.closeDebuggingPort",
json!({ "reopenPort": reopen_port, "reopenDelayMs": 30000 }),
)
.await
{
Ok(_) => log::info!("Closed debugging port, will reopen on {reopen_port} after 30s"),
Err(e) => log::warn!("Failed to close debugging port: {e}"),
}
}
}
let id = uuid::Uuid::new_v4().to_string();
let instance = WayfernInstance {
id: id.clone(),
+25 -10
View File
@@ -29,6 +29,7 @@ import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { SettingsDialog } from "@/components/settings-dialog";
import { SyncAllDialog } from "@/components/sync-all-dialog";
import { SyncConfigDialog } from "@/components/sync-config-dialog";
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
@@ -39,6 +40,7 @@ import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { useProfileEvents } from "@/hooks/use-profile-events";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useSyncSessions } from "@/hooks/use-sync-session";
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
@@ -90,6 +92,11 @@ export default function Home() {
const { vpnConfigs } = useVpnEvents();
// Synchronizer sessions
const { getProfileSyncInfo } = useSyncSessions();
const [syncLeaderProfile, setSyncLeaderProfile] =
useState<BrowserProfile | null>(null);
// Wayfern terms and commercial trial hooks
const {
termsAccepted,
@@ -802,6 +809,7 @@ export default function Home() {
useEffect(() => {
let unlistenStatus: (() => void) | undefined;
let unlistenProgress: (() => void) | undefined;
const profilesWithTransfer = new Set<string>();
(async () => {
try {
unlistenStatus = await listen<{
@@ -815,19 +823,15 @@ export default function Home() {
const profile = profiles.find((p) => p.id === profile_id);
const name = profile_name || profile?.name || "Unknown";
if (status === "syncing") {
showToast({
type: "loading",
title: `Syncing profile '${name}'...`,
id: toastId,
duration: Number.POSITIVE_INFINITY,
onCancel: () => dismissToast(toastId),
});
} else if (status === "synced") {
if (status === "synced") {
dismissToast(toastId);
showSuccessToast(`Profile '${name}' synced successfully`);
if (profilesWithTransfer.has(profile_id)) {
profilesWithTransfer.delete(profile_id);
showSuccessToast(`Profile '${name}' synced successfully`);
}
} else if (status === "error") {
dismissToast(toastId);
profilesWithTransfer.delete(profile_id);
showErrorToast(
`Failed to sync profile '${name}'${error ? `: ${error}` : ""}`,
);
@@ -856,6 +860,7 @@ export default function Home() {
payload.phase === "uploading" ||
payload.phase === "downloading"
) {
profilesWithTransfer.add(payload.profile_id);
showSyncProgressToast(
name,
{
@@ -1088,6 +1093,8 @@ export default function Home() {
onToggleProfileSync={handleToggleProfileSync}
crossOsUnlocked={crossOsUnlocked}
syncUnlocked={syncUnlocked}
getProfileSyncInfo={getProfileSyncInfo}
onLaunchWithSync={(profile) => setSyncLeaderProfile(profile)}
/>
</div>
</main>
@@ -1319,6 +1326,14 @@ export default function Home() {
windowResizeWarningResolver.current = null;
}}
/>
<SyncFollowerDialog
isOpen={syncLeaderProfile !== null}
onClose={() => setSyncLeaderProfile(null)}
leaderProfile={syncLeaderProfile}
allProfiles={profiles}
runningProfiles={runningProfiles}
/>
</div>
);
}
+136 -138
View File
@@ -240,152 +240,150 @@ export function GroupManagementDialog({
</DialogDescription>
</DialogHeader>
<ScrollArea className="overflow-y-auto flex-1">
<div className="space-y-4">
{/* Create new group button */}
<div className="flex justify-between items-center">
<Label>Groups</Label>
<RippleButton
size="sm"
onClick={() => setCreateDialogOpen(true)}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
<div className="space-y-4">
{/* Create new group button */}
<div className="flex justify-between items-center">
<Label>Groups</Label>
<RippleButton
size="sm"
onClick={() => setCreateDialogOpen(true)}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
</div>
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
{/* Groups list */}
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading groups...
</div>
) : groups.length === 0 ? (
<div className="text-sm text-muted-foreground">
No groups created yet. Create your first group using the
button above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-20">Profiles</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => {
const syncDot = getSyncStatusDot(
group,
groupSyncStatus[group.id],
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>
{/* Groups list */}
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading groups...
</div>
) : groups.length === 0 ? (
<div className="text-sm text-muted-foreground">
No groups created yet. Create your first group using the button
above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-20">Profiles</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => {
const syncDot = getSyncStatusDot(
group,
groupSyncStatus[group.id],
groupSyncErrors[group.id],
);
return (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={group.sync_enabled}
onCheckedChange={() =>
handleToggleSync(group)
}
disabled={
isTogglingSync[group.id] ||
groupInUse[group.id]
}
/>
</div>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate ? "animate-pulse" : ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
{groupInUse[group.id] ? (
<p>
Sync cannot be disabled while this group
is used by synced profiles
</p>
) : (
<p>
{group.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<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>
</ScrollArea>
{group.name}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">{group.count}</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={group.sync_enabled}
onCheckedChange={() =>
handleToggleSync(group)
}
disabled={
isTogglingSync[group.id] ||
groupInUse[group.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{groupInUse[group.id] ? (
<p>
Sync cannot be disabled while this group
is used by synced profiles
</p>
) : (
<p>
{group.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<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>
<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 { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
@@ -24,6 +25,148 @@ import { Input } from "./ui/input";
import { ProBadge } from "./ui/pro-badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const CLICK_THRESHOLD = 5;
const CLICK_WINDOW_MS = 2000;
const GRAVITY = 2200;
const BOUNCE_DAMPING = 0.6;
const INITIAL_HORIZONTAL_SPEED = 350;
const SPIN_SPEED = 720;
const MIN_BOUNCE_VELOCITY = 60;
const LOGO_HIDDEN_KEY = "donut-logo-hidden";
function useLogoEasterEgg() {
const clickTimestamps = useRef<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 = {
onSettingsDialogOpen: (open: boolean) => void;
onProxyManagementDialogOpen: (open: boolean) => void;
@@ -52,24 +195,44 @@ const HomeHeader = ({
crossOsUnlocked = false,
}: Props) => {
const { t } = useTranslation();
const handleLogoClick = () => {
// Trigger the same URL handling logic as if the URL came from the system
const event = new CustomEvent("url-open-request", {
detail: "https://donutbrowser.com",
});
window.dispatchEvent(event);
};
const {
logoRef,
isPressed,
setIsPressed,
wobbleKey,
isFalling,
isHidden,
handleClick,
} = useLogoEasterEgg();
return (
<div className="flex justify-between items-center mt-6">
<div className="flex gap-3 items-center">
<button
type="button"
className="p-1 cursor-pointer"
title="Open donutbrowser.com"
onClick={handleLogoClick}
>
<Logo className="w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110" />
</button>
{!isHidden ? (
<button
ref={logoRef}
type="button"
className="p-1 cursor-pointer select-none"
onClick={handleClick}
onPointerDown={() => setIsPressed(true)}
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>
</div>
<div className="flex gap-2 items-center">
+125 -31
View File
@@ -25,6 +25,7 @@ import {
LuLock,
LuPuzzle,
LuTrash2,
LuTriangleAlert,
LuUsers,
} from "react-icons/lu";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
@@ -83,6 +84,7 @@ import type {
LocationItem,
ProxyCheckResult,
StoredProxy,
SyncSessionInfo,
TrafficSnapshot,
VpnConfig,
} from "@/types";
@@ -204,6 +206,16 @@ type TableMeta = {
// Team locks
isProfileLockedByAnother: (profileId: string) => boolean;
getProfileLockEmail: (profileId: string) => string | undefined;
// Synchronizer
getProfileSyncInfo: (profileId: string) =>
| {
session: SyncSessionInfo;
isLeader: boolean;
failedAtUrl: string | null;
}
| undefined;
onLaunchWithSync: (profile: BrowserProfile) => void;
};
type SyncStatusDot = {
@@ -242,7 +254,7 @@ function getProfileSyncStatusDot(
case "waiting":
return {
color: "bg-warning",
tooltip: "Waiting to sync",
tooltip: "Close the profile to sync",
animate: false,
encrypted,
};
@@ -801,6 +813,14 @@ interface ProfilesDataTableProps {
onToggleProfileSync?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
syncUnlocked?: boolean;
getProfileSyncInfo?: (profileId: string) =>
| {
session: SyncSessionInfo;
isLeader: boolean;
failedAtUrl: string | null;
}
| undefined;
onLaunchWithSync?: (profile: BrowserProfile) => void;
}
export function ProfilesDataTable({
@@ -828,6 +848,8 @@ export function ProfilesDataTable({
onToggleProfileSync,
crossOsUnlocked = false,
syncUnlocked = false,
getProfileSyncInfo,
onLaunchWithSync,
}: ProfilesDataTableProps) {
const { t } = useTranslation();
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
@@ -951,8 +973,7 @@ export function ProfilesDataTable({
// Country proxy creation state (for inline proxy creation in dropdown)
const [countries, setCountries] = React.useState<LocationItem[]>([]);
const [countriesLoaded, setCountriesLoaded] = React.useState(false);
const hasCloudProxy = storedProxies.some((p) => p.is_cloud_managed);
const canCreateLocationProxy = hasCloudProxy || crossOsUnlocked;
const canCreateLocationProxy = false;
const loadCountries = React.useCallback(async () => {
if (countriesLoaded || !canCreateLocationProxy) return;
@@ -963,7 +984,7 @@ export function ProfilesDataTable({
} catch (e) {
console.error("Failed to load countries:", e);
}
}, [countriesLoaded, canCreateLocationProxy]);
}, [countriesLoaded]);
// Load cached check results for proxies
React.useEffect(() => {
@@ -1528,6 +1549,10 @@ export function ProfilesDataTable({
isProfileLockedByAnother: isProfileLocked,
getProfileLockEmail: (profileId: string) =>
getLockInfo(profileId)?.lockedByEmail,
// Synchronizer
getProfileSyncInfo: getProfileSyncInfo ?? (() => undefined),
onLaunchWithSync: onLaunchWithSync ?? (() => {}),
}),
[
t,
@@ -1577,11 +1602,12 @@ export function ProfilesDataTable({
crossOsUnlocked,
syncUnlocked,
countries,
canCreateLocationProxy,
loadCountries,
handleCreateCountryProxy,
isProfileLocked,
getLockInfo,
getProfileSyncInfo,
onLaunchWithSync,
],
);
@@ -1806,23 +1832,81 @@ export function ProfilesDataTable({
}
};
const syncInfo = meta.getProfileSyncInfo(profile.id);
const isLeader = syncInfo?.isLeader === true;
const isFollower = syncInfo?.isLeader === false;
const isDesynced = isFollower && syncInfo?.failedAtUrl != null;
const stopTooltip = isLeader
? meta.t("profiles.synchronizer.stopLeader")
: isFollower
? meta.t("profiles.synchronizer.stopFollower", {
leaderName: syncInfo?.session.leader_profile_name ?? "",
})
: tooltipContent;
const handleStop = async () => {
if (isLeader && syncInfo) {
// Stop leader: invoke stop_sync_session which kills leader + all followers
try {
await invoke("stop_sync_session", {
sessionId: syncInfo.session.id,
});
} catch (error) {
console.error("Failed to stop sync session:", error);
}
} else if (isFollower && syncInfo) {
// Stop follower: remove from session
try {
await invoke("remove_sync_follower", {
sessionId: syncInfo.session.id,
followerProfileId: profile.id,
});
} catch (error) {
console.error("Failed to remove sync follower:", error);
}
} else {
await handleProfileStop(profile);
}
};
const buttonVariant = isRunning
? isFollower
? "secondary"
: "destructive"
: "default";
return (
<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>
<TooltipTrigger asChild>
<span className="inline-flex">
<RippleButton
variant={isRunning ? "destructive" : "default"}
variant={buttonVariant}
size="sm"
disabled={!canLaunch || isLaunching || isStopping}
className={cn(
"min-w-[70px] h-7",
!canLaunch && "opacity-50 cursor-not-allowed",
canLaunch && "cursor-pointer",
isFollower && "border-accent",
)}
onClick={() =>
isRunning
? handleProfileStop(profile)
? void handleStop()
: handleProfileLaunch(profile)
}
>
@@ -1838,8 +1922,10 @@ export function ProfilesDataTable({
</RippleButton>
</span>
</TooltipTrigger>
{tooltipContent && (
<TooltipContent>{tooltipContent}</TooltipContent>
{(stopTooltip || tooltipContent) && (
<TooltipContent>
{isRunning ? stopTooltip : tooltipContent}
</TooltipContent>
)}
</Tooltip>
</div>
@@ -2188,28 +2274,35 @@ export function ProfilesDataTable({
/>
None
</CommandItem>
{meta.storedProxies.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() =>
void meta.handleProxySelection(
profile.id,
proxy.id,
)
}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
effectiveProxyId === proxy.id && !effectiveVpn
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
{meta.storedProxies
.filter(
(proxy: StoredProxy) =>
!proxy.is_cloud_managed &&
!proxy.is_cloud_derived,
)
.map((proxy: StoredProxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() =>
void meta.handleProxySelection(
profile.id,
proxy.id,
)
}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
effectiveProxyId === proxy.id &&
!effectiveVpn
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
</CommandGroup>
{meta.vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs">
@@ -2519,6 +2612,7 @@ export function ProfilesDataTable({
onAssignExtensionGroup={onAssignExtensionGroup}
onOpenBypassRules={(profile) => setBypassRulesProfile(profile)}
onCloneProfile={onCloneProfile}
onLaunchWithSync={onLaunchWithSync}
onDeleteProfile={(profile) => {
setProfileForInfoDialog(null);
setProfileToDelete(profile);
+11
View File
@@ -19,6 +19,7 @@ import {
LuSettings,
LuShieldCheck,
LuTrash2,
LuUsers,
LuX,
} from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
@@ -65,6 +66,7 @@ interface ProfileInfoDialogProps {
onOpenBypassRules?: (profile: BrowserProfile) => void;
onCloneProfile?: (profile: BrowserProfile) => void;
onDeleteProfile?: (profile: BrowserProfile) => void;
onLaunchWithSync?: (profile: BrowserProfile) => void;
crossOsUnlocked?: boolean;
isRunning?: boolean;
isDisabled?: boolean;
@@ -110,6 +112,7 @@ export function ProfileInfoDialog({
onOpenBypassRules,
onCloneProfile,
onDeleteProfile,
onLaunchWithSync,
crossOsUnlocked = false,
isRunning = false,
isDisabled = false,
@@ -251,6 +254,14 @@ export function ProfileInfoDialog({
runningBadge: isRunning,
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
},
{
icon: <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" />,
label: t("profiles.actions.copyCookiesToProfile"),
+27 -25
View File
@@ -186,9 +186,7 @@ export function ProxyAssignmentDialog({
const proxy = storedProxies.find(
(p) => p.id === selectedId,
);
return proxy
? `${proxy.name}${proxy.is_cloud_managed ? " (Included)" : ""}`
: "None";
return proxy ? proxy.name : "None";
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -216,28 +214,32 @@ export function ProxyAssignmentDialog({
/>
None
</CommandItem>
{storedProxies.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() => {
handleValueChange(proxy.id);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectionType === "proxy" &&
selectedId === proxy.id
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
{proxy.is_cloud_managed ? " (Included)" : ""}
</CommandItem>
))}
{storedProxies
.filter(
(proxy) =>
!proxy.is_cloud_managed && !proxy.is_cloud_derived,
)
.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() => {
handleValueChange(proxy.id);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectionType === "proxy" &&
selectedId === proxy.id
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
</CommandGroup>
{vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs">
+3 -1
View File
@@ -50,7 +50,9 @@ export function ProxyCheckButton({
try {
const result = await invoke<ProxyCheckResult>("check_proxy_validity", {
proxyId: proxy.id,
proxySettings: proxy.proxy_settings,
proxySettings: proxy.dynamic_proxy_url
? undefined
: proxy.proxy_settings,
});
setLocalResult(result);
onCheckComplete?.(result);
+339 -147
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import {
@@ -20,10 +21,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { StoredProxy } from "@/types";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { ProxySettings, StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyFormData {
interface RegularFormData {
name: string;
proxy_type: string;
host: string;
@@ -32,6 +34,14 @@ interface ProxyFormData {
password: string;
}
interface DynamicFormData {
name: string;
url: string;
format: string;
}
type ProxyMode = "regular" | "dynamic";
interface ProxyFormDialogProps {
isOpen: boolean;
onClose: () => void;
@@ -43,8 +53,11 @@ export function ProxyFormDialog({
onClose,
editingProxy,
}: ProxyFormDialogProps) {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<ProxyFormData>({
const [isTesting, setIsTesting] = useState(false);
const [mode, setMode] = useState<ProxyMode>("regular");
const [regularForm, setRegularForm] = useState<RegularFormData>({
name: "",
proxy_type: "http",
host: "",
@@ -52,9 +65,14 @@ export function ProxyFormDialog({
username: "",
password: "",
});
const [dynamicForm, setDynamicForm] = useState<DynamicFormData>({
name: "",
url: "",
format: "json",
});
const resetForm = useCallback(() => {
setFormData({
setRegularForm({
name: "",
proxy_type: "http",
host: "",
@@ -62,62 +80,134 @@ export function ProxyFormDialog({
username: "",
password: "",
});
setDynamicForm({
name: "",
url: "",
format: "json",
});
setMode("regular");
}, []);
// Load editing proxy data when dialog opens
useEffect(() => {
if (isOpen) {
if (editingProxy) {
setFormData({
name: editingProxy.name,
proxy_type: editingProxy.proxy_settings.proxy_type,
host: editingProxy.proxy_settings.host,
port: editingProxy.proxy_settings.port,
username: editingProxy.proxy_settings.username || "",
password: editingProxy.proxy_settings.password || "",
});
if (editingProxy.dynamic_proxy_url) {
setMode("dynamic");
setDynamicForm({
name: editingProxy.name,
url: editingProxy.dynamic_proxy_url,
format: editingProxy.dynamic_proxy_format || "json",
});
} else {
setMode("regular");
setRegularForm({
name: editingProxy.name,
proxy_type: editingProxy.proxy_settings.proxy_type,
host: editingProxy.proxy_settings.host,
port: editingProxy.proxy_settings.port,
username: editingProxy.proxy_settings.username || "",
password: editingProxy.proxy_settings.password || "",
});
}
} else {
resetForm();
}
}
}, [isOpen, editingProxy, resetForm]);
const handleSubmit = useCallback(async () => {
if (!formData.name.trim()) {
toast.error("Proxy name is required");
const handleTestDynamic = useCallback(async () => {
if (!dynamicForm.url.trim()) {
toast.error(t("proxies.dynamic.urlRequired"));
return;
}
setIsTesting(true);
try {
const settings = await invoke<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) {
toast.error("Host and port are required");
return;
const handleSubmit = useCallback(async () => {
if (mode === "regular") {
if (!regularForm.name.trim()) {
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
return;
}
if (!regularForm.host.trim() || !regularForm.port) {
toast.error(
t("proxies.form.hostPortRequired", "Host and port are required"),
);
return;
}
} else {
if (!dynamicForm.name.trim()) {
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
return;
}
if (!dynamicForm.url.trim()) {
toast.error(t("proxies.dynamic.urlRequired"));
return;
}
}
setIsSubmitting(true);
try {
const proxySettings = {
proxy_type: formData.proxy_type,
host: formData.host.trim(),
port: formData.port,
username: formData.username.trim() || undefined,
password: formData.password.trim() || undefined,
};
if (editingProxy) {
// Update existing proxy
await invoke("update_stored_proxy", {
proxyId: editingProxy.id,
name: formData.name.trim(),
proxySettings,
});
toast.success("Proxy updated successfully");
if (mode === "dynamic") {
await invoke("update_stored_proxy", {
proxyId: editingProxy.id,
name: dynamicForm.name.trim(),
dynamicProxyUrl: dynamicForm.url.trim(),
dynamicProxyFormat: dynamicForm.format,
});
} else {
await invoke("update_stored_proxy", {
proxyId: editingProxy.id,
name: regularForm.name.trim(),
proxySettings: {
proxy_type: regularForm.proxy_type,
host: regularForm.host.trim(),
port: regularForm.port,
username: regularForm.username.trim() || undefined,
password: regularForm.password.trim() || undefined,
},
});
}
toast.success(t("toasts.success.proxyUpdated"));
} else {
// Create new proxy
await invoke("create_stored_proxy", {
name: formData.name.trim(),
proxySettings,
});
toast.success("Proxy created successfully");
if (mode === "dynamic") {
await invoke("create_stored_proxy", {
name: dynamicForm.name.trim(),
dynamicProxyUrl: dynamicForm.url.trim(),
dynamicProxyFormat: dynamicForm.format,
});
} else {
await invoke("create_stored_proxy", {
name: regularForm.name.trim(),
proxySettings: {
proxy_type: regularForm.proxy_type,
host: regularForm.host.trim(),
port: regularForm.port,
username: regularForm.username.trim() || undefined,
password: regularForm.password.trim() || undefined,
},
});
}
toast.success(t("toasts.success.proxyCreated"));
}
onClose();
@@ -129,7 +219,7 @@ export function ProxyFormDialog({
} finally {
setIsSubmitting(false);
}
}, [formData, editingProxy, onClose]);
}, [mode, regularForm, dynamicForm, editingProxy, onClose, t]);
const handleClose = useCallback(() => {
if (!isSubmitting) {
@@ -137,125 +227,227 @@ export function ProxyFormDialog({
}
}, [isSubmitting, onClose]);
const isFormValid =
formData.name.trim() &&
formData.host.trim() &&
formData.port > 0 &&
formData.port <= 65535;
const isRegularValid =
regularForm.name.trim() &&
regularForm.host.trim() &&
regularForm.port > 0 &&
regularForm.port <= 65535;
const isDynamicValid = dynamicForm.name.trim() && dynamicForm.url.trim();
const isFormValid = mode === "regular" ? isRegularValid : isDynamicValid;
const isEditingDynamic = editingProxy?.dynamic_proxy_url != null;
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editingProxy ? "Edit Proxy" : "Create New Proxy"}
{editingProxy ? t("proxies.edit") : t("proxies.add")}
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="proxy-name">Proxy Name</Label>
<Input
id="proxy-name"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="e.g. Office Proxy, Home VPN, etc."
disabled={isSubmitting}
/>
</div>
{!editingProxy && (
<Tabs value={mode} onValueChange={(v) => setMode(v as ProxyMode)}>
<TabsList className="w-full">
<TabsTrigger value="regular" className="flex-1">
{t("proxies.tabs.regular")}
</TabsTrigger>
<TabsTrigger value="dynamic" className="flex-1">
{t("proxies.tabs.dynamic")}
</TabsTrigger>
</TabsList>
</Tabs>
)}
<div className="grid gap-2">
<Label>Proxy Type</Label>
<Select
value={formData.proxy_type}
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>
{editingProxy && isEditingDynamic && (
<p className="text-xs text-muted-foreground">
{t("proxies.dynamic.description")}
</p>
)}
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-host">Host</Label>
<Input
id="proxy-host"
value={formData.host}
onChange={(e) =>
setFormData({ ...formData, host: e.target.value })
}
placeholder="e.g. 127.0.0.1"
disabled={isSubmitting}
/>
</div>
{mode === "regular" ? (
<>
<div className="grid gap-2">
<Label htmlFor="proxy-name">{t("proxies.form.name")}</Label>
<Input
id="proxy-name"
value={regularForm.name}
onChange={(e) =>
setRegularForm({ ...regularForm, name: e.target.value })
}
placeholder="e.g. Office Proxy, Home VPN, etc."
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">Port</Label>
<Input
id="proxy-port"
type="number"
value={formData.port}
onChange={(e) =>
setFormData({
...formData,
port: parseInt(e.target.value, 10) || 0,
})
}
placeholder="e.g. 8080"
min="1"
max="65535"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid gap-2">
<Label>{t("proxies.form.type")}</Label>
<Select
value={regularForm.proxy_type}
onValueChange={(value) =>
setRegularForm({ ...regularForm, 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">
<div className="grid gap-2">
<Label htmlFor="proxy-username">Username (optional)</Label>
<Input
id="proxy-username"
value={formData.username}
onChange={(e) =>
setFormData({
...formData,
username: e.target.value,
})
}
placeholder="Proxy username"
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-host">{t("proxies.form.host")}</Label>
<Input
id="proxy-host"
value={regularForm.host}
onChange={(e) =>
setRegularForm({ ...regularForm, host: e.target.value })
}
placeholder={t("proxies.form.hostPlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">Password (optional)</Label>
<Input
id="proxy-password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({
...formData,
password: e.target.value,
})
}
placeholder="Proxy password"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">{t("proxies.form.port")}</Label>
<Input
id="proxy-port"
type="number"
value={regularForm.port}
onChange={(e) =>
setRegularForm({
...regularForm,
port: parseInt(e.target.value, 10) || 0,
})
}
placeholder={t("proxies.form.portPlaceholder")}
min="1"
max="65535"
disabled={isSubmitting}
/>
</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>
<DialogFooter>
@@ -264,14 +456,14 @@ export function ProxyFormDialog({
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
{t("common.cancel", "Cancel")}
</RippleButton>
<LoadingButton
isLoading={isSubmitting}
onClick={handleSubmit}
disabled={!isFormValid}
>
{editingProxy ? "Update Proxy" : "Create Proxy"}
{editingProxy ? t("proxies.edit") : t("proxies.add")}
</LoadingButton>
</DialogFooter>
</DialogContent>
+41 -65
View File
@@ -3,7 +3,7 @@
import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { GoGlobe, GoPlus } from "react-icons/go";
import { GoPlus } from "react-icons/go";
import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu";
import { toast } from "sonner";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
@@ -40,8 +40,6 @@ import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
import { FlagIcon } from "./flag-icon";
import { LocationProxyDialog } from "./location-proxy-dialog";
import { ProxyCheckButton } from "./proxy-check-button";
import { RippleButton } from "./ui/ripple";
import { VpnCheckButton } from "./vpn-check-button";
@@ -102,7 +100,6 @@ export function ProxyManagementDialog({
const [showProxyForm, setShowProxyForm] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [showExportDialog, setShowExportDialog] = useState(false);
const [showLocationDialog, setShowLocationDialog] = useState(false);
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
@@ -142,12 +139,10 @@ export function ProxyManagementDialog({
const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents();
const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents();
// Filter out the base cloud-managed proxy (it's an internal indicator, not user-facing)
// Keep cloud-derived location proxies
// Filter out cloud-managed and cloud-derived proxies (cloud proxies are deprecated)
const storedProxies = rawProxies
.filter((p) => !p.is_cloud_managed)
.filter((p) => !p.is_cloud_managed && !p.is_cloud_derived)
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
const hasCloudProxy = rawProxies.some((p) => p.is_cloud_managed);
// Listen for proxy sync status events
useEffect(() => {
@@ -412,17 +407,6 @@ export function ProxyManagementDialog({
</RippleButton>
</div>
<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
size="sm"
onClick={handleCreateProxy}
@@ -462,34 +446,33 @@ export function ProxyManagementDialog({
proxySyncStatus[proxy.id],
proxySyncErrors[proxy.id],
);
const isDerived = proxy.is_cloud_derived === true;
return (
<TableRow key={proxy.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{isDerived && proxy.geo_country && (
<FlagIcon
countryCode={proxy.geo_country}
className="shrink-0"
/>
)}
{!isDerived && (
<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>
)}
<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>
{proxy.name}
{proxy.dynamic_proxy_url && (
<Badge
variant="outline"
className="text-[10px] px-1 py-0"
>
Dynamic
</Badge>
)}
</div>
</TableCell>
<TableCell>
@@ -554,24 +537,22 @@ export function ProxyManagementDialog({
}));
}}
/>
{!isDerived && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleEditProxy(proxy)
}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleEditProxy(proxy)
}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
@@ -830,11 +811,6 @@ export function ProxyManagementDialog({
isOpen={showExportDialog}
onClose={() => setShowExportDialog(false)}
/>
<LocationProxyDialog
isOpen={showLocationDialog}
onClose={() => setShowLocationDialog(false)}
/>
<VpnFormDialog
isOpen={showVpnForm}
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",
"copyCookiesToProfile": "Copy Cookies to Profile"
},
"synchronizer": {
"launchWithSync": "Launch with Synchronizer",
"stopLeader": "Stop this profile and all its followers",
"stopFollower": "Following actions of {{leaderName}}",
"desyncedTooltip": "Synchronization failed at {{url}}",
"paidFeature": "Synchronizer is a paid feature",
"wayfernOnly": "Only Wayfern profiles can be synchronized",
"selectFollowers": "Select Follower Profiles",
"selectFollowersDesc": "Choose profiles that will mirror the actions of the leader profile. Only stopped Wayfern profiles can be selected.",
"leader": "Leader",
"follower": "Follower",
"startSession": "Start Sync Session",
"noFollowers": "Select at least one follower profile",
"flakyBadge": "FLAKY",
"flakyTooltip": "This profile has a different screen resolution than the leader. Page layouts may differ, causing clicks and interactions to hit the wrong elements."
},
"ephemeral": "Ephemeral",
"ephemeralDescription": "The browser is forced to write profile data into memory instead of disk. Data is deleted when the browser is closed.",
"ephemeralBadge": "Ephemeral",
@@ -264,6 +280,26 @@
"socks4": "SOCKS4",
"socks5": "SOCKS5"
},
"tabs": {
"regular": "Regular",
"dynamic": "Dynamic"
},
"dynamic": {
"description": "Dynamic proxy fetches connection details from a URL each time a profile is launched.",
"url": "Proxy URL",
"urlPlaceholder": "https://api.example.com/proxy",
"urlRequired": "Dynamic proxy URL is required",
"format": "Response Format",
"formatJson": "JSON",
"formatText": "Text",
"formatJsonHint": "Expects JSON: {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}",
"formatTextHint": "Expects text like: host:port:username:password or protocol://user:pass@host:port",
"testUrl": "Test URL",
"testing": "Testing...",
"testSuccess": "Dynamic proxy resolved to {{host}}:{{port}}",
"testFailed": "Failed to fetch proxy: {{error}}",
"fetchFailed": "Failed to fetch dynamic proxy: {{error}}"
},
"check": {
"checking": "Checking proxy...",
"valid": "Proxy is valid",
+36
View File
@@ -184,6 +184,22 @@
"changeFingerprint": "Cambiar Huella Digital",
"copyCookiesToProfile": "Copiar Cookies al Perfil"
},
"synchronizer": {
"launchWithSync": "Lanzar con Sincronizador",
"stopLeader": "Detener este perfil y todos sus seguidores",
"stopFollower": "Siguiendo las acciones de {{leaderName}}",
"desyncedTooltip": "La sincronización falló en {{url}}",
"paidFeature": "El sincronizador es una función de pago",
"wayfernOnly": "Solo los perfiles Wayfern pueden sincronizarse",
"selectFollowers": "Seleccionar perfiles seguidores",
"selectFollowersDesc": "Elige los perfiles que replicarán las acciones del perfil líder. Solo se pueden seleccionar perfiles Wayfern detenidos.",
"leader": "Líder",
"follower": "Seguidor",
"startSession": "Iniciar sesión de sincronización",
"noFollowers": "Selecciona al menos un perfil seguidor",
"flakyBadge": "FLAKY",
"flakyTooltip": "Este perfil tiene una resolución de pantalla diferente a la del líder. El diseño de las páginas puede variar, lo que puede causar que los clics e interacciones fallen."
},
"ephemeral": "Efímero",
"ephemeralDescription": "El navegador es forzado a escribir los datos del perfil en memoria en lugar del disco. Los datos se eliminan al cerrar el navegador.",
"ephemeralBadge": "Efímero",
@@ -264,6 +280,26 @@
"socks4": "SOCKS4",
"socks5": "SOCKS5"
},
"tabs": {
"regular": "Regular",
"dynamic": "Dinámico"
},
"dynamic": {
"description": "El proxy dinámico obtiene los detalles de conexión desde una URL cada vez que se inicia un perfil.",
"url": "URL del Proxy",
"urlPlaceholder": "https://api.example.com/proxy",
"urlRequired": "La URL del proxy dinámico es obligatoria",
"format": "Formato de Respuesta",
"formatJson": "JSON",
"formatText": "Texto",
"formatJsonHint": "Espera JSON: {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}",
"formatTextHint": "Espera texto como: host:port:username:password o protocol://user:pass@host:port",
"testUrl": "Probar URL",
"testing": "Probando...",
"testSuccess": "El proxy dinámico se resolvió a {{host}}:{{port}}",
"testFailed": "Error al obtener el proxy: {{error}}",
"fetchFailed": "Error al obtener el proxy dinámico: {{error}}"
},
"check": {
"checking": "Verificando proxy...",
"valid": "El proxy es válido",
+36
View File
@@ -184,6 +184,22 @@
"changeFingerprint": "Changer l'Empreinte",
"copyCookiesToProfile": "Copier les Cookies vers le Profil"
},
"synchronizer": {
"launchWithSync": "Lancer avec le synchroniseur",
"stopLeader": "Arrêter ce profil et tous ses suiveurs",
"stopFollower": "Suit les actions de {{leaderName}}",
"desyncedTooltip": "La synchronisation a échoué à {{url}}",
"paidFeature": "Le synchroniseur est une fonctionnalité payante",
"wayfernOnly": "Seuls les profils Wayfern peuvent être synchronisés",
"selectFollowers": "Sélectionner les profils suiveurs",
"selectFollowersDesc": "Choisissez les profils qui reproduiront les actions du profil leader. Seuls les profils Wayfern arrêtés peuvent être sélectionnés.",
"leader": "Leader",
"follower": "Suiveur",
"startSession": "Démarrer la session de synchronisation",
"noFollowers": "Sélectionnez au moins un profil suiveur",
"flakyBadge": "FLAKY",
"flakyTooltip": "Ce profil a une résolution d'écran différente de celle du leader. La mise en page des pages peut différer, ce qui peut causer des clics et interactions erronés."
},
"ephemeral": "Éphémère",
"ephemeralDescription": "Le navigateur est forcé d'écrire les données du profil en mémoire au lieu du disque. Les données sont supprimées à la fermeture du navigateur.",
"ephemeralBadge": "Éphémère",
@@ -264,6 +280,26 @@
"socks4": "SOCKS4",
"socks5": "SOCKS5"
},
"tabs": {
"regular": "Standard",
"dynamic": "Dynamique"
},
"dynamic": {
"description": "Le proxy dynamique récupère les détails de connexion depuis une URL à chaque lancement d'un profil.",
"url": "URL du Proxy",
"urlPlaceholder": "https://api.example.com/proxy",
"urlRequired": "L'URL du proxy dynamique est requise",
"format": "Format de Réponse",
"formatJson": "JSON",
"formatText": "Texte",
"formatJsonHint": "Attend du JSON : {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}",
"formatTextHint": "Attend du texte comme : host:port:username:password ou protocol://user:pass@host:port",
"testUrl": "Tester l'URL",
"testing": "Test en cours...",
"testSuccess": "Le proxy dynamique a été résolu en {{host}}:{{port}}",
"testFailed": "Échec de la récupération du proxy : {{error}}",
"fetchFailed": "Échec de la récupération du proxy dynamique : {{error}}"
},
"check": {
"checking": "Vérification du proxy...",
"valid": "Le proxy est valide",
+36
View File
@@ -184,6 +184,22 @@
"changeFingerprint": "フィンガープリントを変更",
"copyCookiesToProfile": "Cookieをプロファイルにコピー"
},
"synchronizer": {
"launchWithSync": "シンクロナイザーで起動",
"stopLeader": "このプロフィールとすべてのフォロワーを停止",
"stopFollower": "{{leaderName}}のアクションを追従中",
"desyncedTooltip": "{{url}}で同期に失敗しました",
"paidFeature": "シンクロナイザーは有料機能です",
"wayfernOnly": "Wayfernプロフィールのみ同期可能です",
"selectFollowers": "フォロワープロフィールを選択",
"selectFollowersDesc": "リーダープロフィールのアクションを複製するプロフィールを選択してください。停止中のWayfernプロフィールのみ選択できます。",
"leader": "リーダー",
"follower": "フォロワー",
"startSession": "同期セッションを開始",
"noFollowers": "少なくとも1つのフォロワープロフィールを選択してください",
"flakyBadge": "FLAKY",
"flakyTooltip": "このプロフィールはリーダーと画面解像度が異なります。ページレイアウトが異なる可能性があり、クリックや操作が正しく動作しない場合があります。"
},
"ephemeral": "一時的",
"ephemeralDescription": "ブラウザはプロファイルデータをディスクではなくメモリに書き込むよう強制されます。ブラウザを閉じるとデータは削除されます。",
"ephemeralBadge": "一時的",
@@ -264,6 +280,26 @@
"socks4": "SOCKS4",
"socks5": "SOCKS5"
},
"tabs": {
"regular": "通常",
"dynamic": "ダイナミック"
},
"dynamic": {
"description": "ダイナミックプロキシは、プロファイルが起動されるたびにURLから接続情報を取得します。",
"url": "プロキシURL",
"urlPlaceholder": "https://api.example.com/proxy",
"urlRequired": "ダイナミックプロキシのURLは必須です",
"format": "レスポンス形式",
"formatJson": "JSON",
"formatText": "テキスト",
"formatJsonHint": "JSON形式: {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}",
"formatTextHint": "テキスト形式: host:port:username:password または protocol://user:pass@host:port",
"testUrl": "URLをテスト",
"testing": "テスト中...",
"testSuccess": "ダイナミックプロキシは {{host}}:{{port}} に解決されました",
"testFailed": "プロキシの取得に失敗しました: {{error}}",
"fetchFailed": "ダイナミックプロキシの取得に失敗しました: {{error}}"
},
"check": {
"checking": "プロキシを確認中...",
"valid": "プロキシは有効です",
+36
View File
@@ -184,6 +184,22 @@
"changeFingerprint": "Alterar Impressão Digital",
"copyCookiesToProfile": "Copiar Cookies para o Perfil"
},
"synchronizer": {
"launchWithSync": "Iniciar com Sincronizador",
"stopLeader": "Parar este perfil e todos os seus seguidores",
"stopFollower": "Seguindo as ações de {{leaderName}}",
"desyncedTooltip": "A sincronização falhou em {{url}}",
"paidFeature": "O sincronizador é um recurso pago",
"wayfernOnly": "Apenas perfis Wayfern podem ser sincronizados",
"selectFollowers": "Selecionar perfis seguidores",
"selectFollowersDesc": "Escolha os perfis que replicarão as ações do perfil líder. Apenas perfis Wayfern parados podem ser selecionados.",
"leader": "Líder",
"follower": "Seguidor",
"startSession": "Iniciar sessão de sincronização",
"noFollowers": "Selecione pelo menos um perfil seguidor",
"flakyBadge": "FLAKY",
"flakyTooltip": "Este perfil tem uma resolução de tela diferente do líder. O layout das páginas pode variar, fazendo com que cliques e interações atinjam elementos errados."
},
"ephemeral": "Efêmero",
"ephemeralDescription": "O navegador é forçado a gravar os dados do perfil na memória em vez do disco. Os dados são excluídos ao fechar o navegador.",
"ephemeralBadge": "Efêmero",
@@ -264,6 +280,26 @@
"socks4": "SOCKS4",
"socks5": "SOCKS5"
},
"tabs": {
"regular": "Regular",
"dynamic": "Dinâmico"
},
"dynamic": {
"description": "O proxy dinâmico obtém os detalhes de conexão de uma URL cada vez que um perfil é iniciado.",
"url": "URL do Proxy",
"urlPlaceholder": "https://api.example.com/proxy",
"urlRequired": "A URL do proxy dinâmico é obrigatória",
"format": "Formato de Resposta",
"formatJson": "JSON",
"formatText": "Texto",
"formatJsonHint": "Espera JSON: {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}",
"formatTextHint": "Espera texto como: host:port:username:password ou protocol://user:pass@host:port",
"testUrl": "Testar URL",
"testing": "Testando...",
"testSuccess": "O proxy dinâmico foi resolvido para {{host}}:{{port}}",
"testFailed": "Falha ao obter o proxy: {{error}}",
"fetchFailed": "Falha ao obter o proxy dinâmico: {{error}}"
},
"check": {
"checking": "Verificando proxy...",
"valid": "O proxy é válido",
+36
View File
@@ -184,6 +184,22 @@
"changeFingerprint": "Изменить отпечаток",
"copyCookiesToProfile": "Копировать Cookie в профиль"
},
"synchronizer": {
"launchWithSync": "Запустить с синхронизатором",
"stopLeader": "Остановить этот профиль и всех его последователей",
"stopFollower": "Повторяет действия {{leaderName}}",
"desyncedTooltip": "Синхронизация не удалась на {{url}}",
"paidFeature": "Синхронизатор — платная функция",
"wayfernOnly": "Синхронизировать можно только профили Wayfern",
"selectFollowers": "Выберите профили-последователи",
"selectFollowersDesc": "Выберите профили, которые будут повторять действия профиля-лидера. Можно выбрать только остановленные профили Wayfern.",
"leader": "Лидер",
"follower": "Последователь",
"startSession": "Начать сессию синхронизации",
"noFollowers": "Выберите хотя бы один профиль-последователь",
"flakyBadge": "FLAKY",
"flakyTooltip": "У этого профиля разрешение экрана отличается от лидера. Макет страниц может отличаться, что может привести к неправильным кликам и взаимодействиям."
},
"ephemeral": "Временный",
"ephemeralDescription": "Браузер принудительно записывает данные профиля в память вместо диска. Данные удаляются при закрытии браузера.",
"ephemeralBadge": "Временный",
@@ -264,6 +280,26 @@
"socks4": "SOCKS4",
"socks5": "SOCKS5"
},
"tabs": {
"regular": "Обычный",
"dynamic": "Динамический"
},
"dynamic": {
"description": "Динамический прокси получает данные подключения по URL при каждом запуске профиля.",
"url": "URL прокси",
"urlPlaceholder": "https://api.example.com/proxy",
"urlRequired": "URL динамического прокси обязателен",
"format": "Формат ответа",
"formatJson": "JSON",
"formatText": "Текст",
"formatJsonHint": "Ожидается JSON: {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}",
"formatTextHint": "Ожидается текст вида: host:port:username:password или protocol://user:pass@host:port",
"testUrl": "Проверить URL",
"testing": "Проверка...",
"testSuccess": "Динамический прокси разрешён в {{host}}:{{port}}",
"testFailed": "Не удалось получить прокси: {{error}}",
"fetchFailed": "Не удалось получить динамический прокси: {{error}}"
},
"check": {
"checking": "Проверка прокси...",
"valid": "Прокси действителен",
+36
View File
@@ -184,6 +184,22 @@
"changeFingerprint": "更改指纹",
"copyCookiesToProfile": "复制 Cookies 到配置文件"
},
"synchronizer": {
"launchWithSync": "使用同步器启动",
"stopLeader": "停止此配置文件及其所有跟随者",
"stopFollower": "正在跟随 {{leaderName}} 的操作",
"desyncedTooltip": "在 {{url}} 同步失败",
"paidFeature": "同步器是付费功能",
"wayfernOnly": "只有 Wayfern 配置文件可以同步",
"selectFollowers": "选择跟随者配置文件",
"selectFollowersDesc": "选择将复制领导者配置文件操作的配置文件。只能选择已停止的 Wayfern 配置文件。",
"leader": "领导者",
"follower": "跟随者",
"startSession": "开始同步会话",
"noFollowers": "请至少选择一个跟随者配置文件",
"flakyBadge": "FLAKY",
"flakyTooltip": "此配置文件的屏幕分辨率与领导者不同。页面布局可能不同,导致点击和交互可能命中错误的元素。"
},
"ephemeral": "临时",
"ephemeralDescription": "浏览器被强制将配置数据写入内存而非磁盘。关闭浏览器时数据将被删除。",
"ephemeralBadge": "临时",
@@ -264,6 +280,26 @@
"socks4": "SOCKS4",
"socks5": "SOCKS5"
},
"tabs": {
"regular": "常规",
"dynamic": "动态"
},
"dynamic": {
"description": "动态代理在每次启动配置文件时从URL获取连接详情。",
"url": "代理URL",
"urlPlaceholder": "https://api.example.com/proxy",
"urlRequired": "动态代理URL为必填项",
"format": "响应格式",
"formatJson": "JSON",
"formatText": "文本",
"formatJsonHint": "期望 JSON: {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}",
"formatTextHint": "期望文本格式: host:port:username:password 或 protocol://user:pass@host:port",
"testUrl": "测试URL",
"testing": "测试中...",
"testSuccess": "动态代理已解析为 {{host}}:{{port}}",
"testFailed": "获取代理失败: {{error}}",
"fetchFailed": "获取动态代理失败: {{error}}"
},
"check": {
"checking": "检查代理中...",
"valid": "代理有效",
+16
View File
@@ -134,6 +134,8 @@ export interface StoredProxy {
geo_region?: string;
geo_city?: string;
geo_isp?: string;
dynamic_proxy_url?: string;
dynamic_proxy_format?: string;
}
export interface LocationItem {
@@ -510,6 +512,20 @@ export interface WayfernLaunchResult {
cdp_port?: number;
}
// Synchronizer types
export interface SyncFollowerState {
profile_id: string;
profile_name: string;
failed_at_url: string | null;
}
export interface SyncSessionInfo {
id: string;
leader_profile_id: string;
leader_profile_name: string;
followers: SyncFollowerState[];
}
// Traffic stats types
export interface BandwidthDataPoint {
timestamp: number;
+10
View File
@@ -8,6 +8,16 @@ export default {
backgroundColor: {
dark: "#000000",
},
keyframes: {
wiggle: {
"0%, 100%": { transform: "rotate(0deg)" },
"25%": { transform: "rotate(-12deg)" },
"75%": { transform: "rotate(12deg)" },
},
},
animation: {
wiggle: "wiggle 0.3s ease-in-out",
},
},
},
};