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