mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-07 02:46:42 +02:00
feat: synchronizer
This commit is contained in:
Vendored
+7
@@ -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",
|
||||
|
||||
Generated
+23
-16
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "プロキシは有効です",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Прокси действителен",
|
||||
|
||||
@@ -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": "代理有效",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user