mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-06 23:13:58 +02:00
feat: synchronizer
This commit is contained in:
Generated
+23
-16
@@ -652,6 +652,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
@@ -932,6 +941,15 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.57"
|
||||
@@ -1690,6 +1708,7 @@ dependencies = [
|
||||
name = "donutbrowser"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"async-socks5",
|
||||
@@ -1699,6 +1718,7 @@ dependencies = [
|
||||
"blake3",
|
||||
"boringtun",
|
||||
"bzip2 0.6.1",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"clap",
|
||||
@@ -1717,7 +1737,6 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"lzma-rs",
|
||||
"maxminddb",
|
||||
"mime_guess",
|
||||
@@ -1727,6 +1746,7 @@ dependencies = [
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"once_cell",
|
||||
"pbkdf2",
|
||||
"playwright",
|
||||
"quick-xml 0.39.2",
|
||||
"rand 0.9.2",
|
||||
@@ -1738,6 +1758,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha1",
|
||||
"smoltcp",
|
||||
"sys-locale",
|
||||
"sysinfo",
|
||||
@@ -3167,6 +3188,7 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -3571,15 +3593,6 @@ dependencies = [
|
||||
"imgref",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.11.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rs"
|
||||
version = "0.3.0"
|
||||
@@ -7420,12 +7433,6 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "twox-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
|
||||
@@ -81,6 +81,10 @@ utoipa = { version = "5", features = ["axum_extras", "chrono"] }
|
||||
utoipa-axum = "0.2"
|
||||
argon2 = "0.5"
|
||||
aes-gcm = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
pbkdf2 = "0.12"
|
||||
sha1 = "0.10"
|
||||
hyper = { version = "1.8", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["full"] }
|
||||
http-body-util = "0.1"
|
||||
@@ -101,7 +105,6 @@ maxminddb = "0.27"
|
||||
quick-xml = { version = "0.39", features = ["serialize"] }
|
||||
|
||||
# VPN support
|
||||
lz4_flex = "0.11"
|
||||
boringtun = "0.7"
|
||||
smoltcp = { version = "0.11", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
|
||||
|
||||
|
||||
+62
-24
@@ -111,13 +111,17 @@ struct ApiProxyResponse {
|
||||
name: String,
|
||||
#[schema(value_type = Object)]
|
||||
proxy_settings: ProxySettings,
|
||||
dynamic_proxy_url: Option<String>,
|
||||
dynamic_proxy_format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct CreateProxyRequest {
|
||||
name: String,
|
||||
#[schema(value_type = Object)]
|
||||
proxy_settings: ProxySettings,
|
||||
proxy_settings: Option<ProxySettings>,
|
||||
dynamic_proxy_url: Option<String>,
|
||||
dynamic_proxy_format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
@@ -125,6 +129,8 @@ struct UpdateProxyRequest {
|
||||
name: Option<String>,
|
||||
#[schema(value_type = Object)]
|
||||
proxy_settings: Option<ProxySettings>,
|
||||
dynamic_proxy_url: Option<String>,
|
||||
dynamic_proxy_format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
@@ -1028,6 +1034,8 @@ async fn get_proxies(
|
||||
.map(|p| ApiProxyResponse {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
dynamic_proxy_url: p.dynamic_proxy_url,
|
||||
dynamic_proxy_format: p.dynamic_proxy_format,
|
||||
proxy_settings: p.proxy_settings,
|
||||
})
|
||||
.collect(),
|
||||
@@ -1061,6 +1069,8 @@ async fn get_proxy(
|
||||
id: proxy.id,
|
||||
name: proxy.name,
|
||||
proxy_settings: proxy.proxy_settings,
|
||||
dynamic_proxy_url: proxy.dynamic_proxy_url,
|
||||
dynamic_proxy_format: proxy.dynamic_proxy_format,
|
||||
}))
|
||||
} else {
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
@@ -1086,14 +1096,27 @@ async fn create_proxy(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<CreateProxyRequest>,
|
||||
) -> Result<Json<ApiProxyResponse>, StatusCode> {
|
||||
match PROXY_MANAGER.create_stored_proxy(
|
||||
&state.app_handle,
|
||||
request.name.clone(),
|
||||
request.proxy_settings,
|
||||
) {
|
||||
let result = if let (Some(url), Some(format)) =
|
||||
(&request.dynamic_proxy_url, &request.dynamic_proxy_format)
|
||||
{
|
||||
PROXY_MANAGER.create_dynamic_proxy(
|
||||
&state.app_handle,
|
||||
request.name.clone(),
|
||||
url.clone(),
|
||||
format.clone(),
|
||||
)
|
||||
} else if let Some(settings) = request.proxy_settings {
|
||||
PROXY_MANAGER.create_stored_proxy(&state.app_handle, request.name.clone(), settings)
|
||||
} else {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(proxy) => Ok(Json(ApiProxyResponse {
|
||||
id: proxy.id,
|
||||
name: proxy.name,
|
||||
dynamic_proxy_url: proxy.dynamic_proxy_url,
|
||||
dynamic_proxy_format: proxy.dynamic_proxy_format,
|
||||
proxy_settings: proxy.proxy_settings,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
@@ -1124,28 +1147,29 @@ async fn update_proxy(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateProxyRequest>,
|
||||
) -> Result<Json<ApiProxyResponse>, StatusCode> {
|
||||
let proxies = PROXY_MANAGER.get_stored_proxies();
|
||||
if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) {
|
||||
let new_name = request.name.unwrap_or(proxy.name.clone());
|
||||
let new_proxy_settings = request
|
||||
.proxy_settings
|
||||
.unwrap_or(proxy.proxy_settings.clone());
|
||||
let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(&id) || request.dynamic_proxy_url.is_some();
|
||||
|
||||
match PROXY_MANAGER.update_stored_proxy(
|
||||
let result = if is_dynamic {
|
||||
PROXY_MANAGER.update_dynamic_proxy(
|
||||
&state.app_handle,
|
||||
&id,
|
||||
Some(new_name.clone()),
|
||||
Some(new_proxy_settings.clone()),
|
||||
) {
|
||||
Ok(_) => Ok(Json(ApiProxyResponse {
|
||||
id,
|
||||
name: new_name,
|
||||
proxy_settings: new_proxy_settings,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
request.name,
|
||||
request.dynamic_proxy_url,
|
||||
request.dynamic_proxy_format,
|
||||
)
|
||||
} else {
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(proxy) => Ok(Json(ApiProxyResponse {
|
||||
id: proxy.id,
|
||||
name: proxy.name,
|
||||
dynamic_proxy_url: proxy.dynamic_proxy_url,
|
||||
dynamic_proxy_format: proxy.dynamic_proxy_format,
|
||||
proxy_settings: proxy.proxy_settings,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1289,6 +1313,13 @@ async fn run_profile(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<RunProfileRequest>,
|
||||
) -> Result<Json<RunProfileResponse>, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
let headless = request.headless.unwrap_or(false);
|
||||
let url = request.url;
|
||||
|
||||
@@ -1357,6 +1388,13 @@ async fn open_url_in_profile(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<OpenUrlRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
|
||||
browser_runner
|
||||
|
||||
@@ -40,12 +40,25 @@ impl BrowserRunner {
|
||||
|
||||
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
|
||||
/// then resolve the proxy settings with profile-specific sid for sticky sessions.
|
||||
/// Resolve proxy settings for a profile, returning an error for dynamic proxy failures.
|
||||
/// Returns Ok(None) when no proxy is configured, Ok(Some) on success, Err on dynamic fetch failure.
|
||||
async fn resolve_proxy_with_refresh(
|
||||
&self,
|
||||
proxy_id: Option<&String>,
|
||||
profile_id: Option<&str>,
|
||||
) -> Option<ProxySettings> {
|
||||
let proxy_id = proxy_id?;
|
||||
) -> Result<Option<ProxySettings>, String> {
|
||||
let proxy_id = match proxy_id {
|
||||
Some(id) => id,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
// Handle dynamic proxies: fetch from URL at launch time
|
||||
if PROXY_MANAGER.is_dynamic_proxy(proxy_id) {
|
||||
log::info!("Fetching dynamic proxy settings for proxy {proxy_id}");
|
||||
let settings = PROXY_MANAGER.resolve_dynamic_proxy(proxy_id).await?;
|
||||
return Ok(Some(settings));
|
||||
}
|
||||
|
||||
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
|
||||
log::info!("Refreshing cloud proxy credentials before launch for proxy {proxy_id}");
|
||||
CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
@@ -53,10 +66,10 @@ impl BrowserRunner {
|
||||
// For cloud-derived proxies, inject profile-specific sid for sticky sessions
|
||||
if let Some(pid) = profile_id {
|
||||
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
|
||||
return PROXY_MANAGER.resolve_proxy_for_profile(proxy_id, pid);
|
||||
return Ok(PROXY_MANAGER.resolve_proxy_for_profile(proxy_id, pid));
|
||||
}
|
||||
}
|
||||
PROXY_MANAGER.get_proxy_settings_by_id(proxy_id)
|
||||
Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))
|
||||
}
|
||||
|
||||
/// Get the executable path for a browser profile
|
||||
@@ -117,7 +130,8 @@ impl BrowserRunner {
|
||||
// Refresh cloud proxy credentials if needed before resolving
|
||||
let mut upstream_proxy = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.await;
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
@@ -375,7 +389,8 @@ impl BrowserRunner {
|
||||
// Refresh cloud proxy credentials if needed before resolving
|
||||
let mut upstream_proxy = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.await;
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
@@ -728,7 +743,8 @@ impl BrowserRunner {
|
||||
// Refresh cloud proxy credentials before resolving
|
||||
let upstream_proxy = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.await;
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
|
||||
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
|
||||
let temp_pid = 1u32;
|
||||
@@ -2231,7 +2247,7 @@ pub async fn launch_browser_profile(
|
||||
profile_for_launch.proxy_id.as_ref(),
|
||||
Some(&profile_for_launch.id.to_string()),
|
||||
)
|
||||
.await;
|
||||
.await?;
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
|
||||
@@ -685,9 +685,6 @@ impl CamoufoxManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Write search.json.mozlz4 with default search engines (DuckDuckGo + Google)
|
||||
write_default_search_config(&profile_path);
|
||||
|
||||
self
|
||||
.launch_camoufox(
|
||||
&app_handle,
|
||||
@@ -701,77 +698,6 @@ impl CamoufoxManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_default_search_config(profile_path: &std::path::Path) {
|
||||
let search_file = profile_path.join("search.json.mozlz4");
|
||||
if search_file.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let json = serde_json::json!({
|
||||
"version": 6,
|
||||
"engines": [
|
||||
{
|
||||
"_name": "DuckDuckGo",
|
||||
"_isAppProvided": false,
|
||||
"_metaData": { "order": 1 },
|
||||
"_urls": [
|
||||
{
|
||||
"template": "https://duckduckgo.com/?q={searchTerms}",
|
||||
"type": "text/html",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"template": "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
|
||||
"type": "application/x-suggestions+json",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"_iconURL": "https://duckduckgo.com/favicon.ico"
|
||||
},
|
||||
{
|
||||
"_name": "Google",
|
||||
"_isAppProvided": false,
|
||||
"_metaData": { "order": 2 },
|
||||
"_urls": [
|
||||
{
|
||||
"template": "https://www.google.com/search?q={searchTerms}",
|
||||
"type": "text/html",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"template": "https://www.google.com/complete/search?client=firefox&q={searchTerms}",
|
||||
"type": "application/x-suggestions+json",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"_iconURL": "https://www.google.com/favicon.ico"
|
||||
}
|
||||
],
|
||||
"metaData": {
|
||||
"useSavedOrder": false,
|
||||
"defaultEngineId": "DuckDuckGo"
|
||||
}
|
||||
});
|
||||
|
||||
let json_bytes = match serde_json::to_vec(&json) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to serialize search config: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let magic = b"mozLz40\0";
|
||||
let compressed = lz4_flex::block::compress_prepend_size(&json_bytes);
|
||||
let mut output = Vec::with_capacity(magic.len() + compressed.len());
|
||||
output.extend_from_slice(magic);
|
||||
output.extend_from_slice(&compressed);
|
||||
|
||||
if let Err(e) = std::fs::write(&search_file, &output) {
|
||||
log::warn!("Failed to write search.json.mozlz4: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1080,8 +1080,8 @@ impl CloudAuthManager {
|
||||
// Sync cloud proxy credentials
|
||||
CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
|
||||
// Refresh wayfern token every 12 hours (72 iterations of 10-minute loop)
|
||||
if wayfern_refresh_counter >= 72 {
|
||||
// Refresh wayfern token every 10 hours (60 iterations of 10-minute loop)
|
||||
if wayfern_refresh_counter >= 60 {
|
||||
wayfern_refresh_counter = 0;
|
||||
if CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
|
||||
@@ -1390,7 +1390,7 @@ pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), St
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Sync not configured, skipping missing profile check: {}", e);
|
||||
log::warn!("Sync not configured, skipping missing profile check: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+186
-25
@@ -7,6 +7,112 @@ use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::AppHandle;
|
||||
|
||||
/// Chromium cookie encryption/decryption support.
|
||||
/// On macOS: uses "Chromium Safe Storage" key from Keychain with PBKDF2 + AES-128-CBC.
|
||||
/// On Linux: uses os_crypt_key file from profile directory with PBKDF2 + AES-128-CBC.
|
||||
mod chrome_decrypt {
|
||||
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
|
||||
use std::path::Path;
|
||||
|
||||
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
|
||||
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
|
||||
|
||||
const PBKDF2_ITERATIONS: u32 = 1;
|
||||
const KEY_LEN: usize = 16; // AES-128
|
||||
const SALT: &[u8] = b"saltysalt";
|
||||
const IV: [u8; 16] = [b' '; 16]; // 16 spaces
|
||||
|
||||
fn derive_key(password: &[u8]) -> [u8; KEY_LEN] {
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
pbkdf2::pbkdf2_hmac::<sha1::Sha1>(password, SALT, PBKDF2_ITERATIONS, &mut key);
|
||||
key
|
||||
}
|
||||
|
||||
/// Get the encryption key for Chrome cookies.
|
||||
/// Wayfern stores os_crypt_key as a file inside the profile's user-data-dir on all platforms.
|
||||
/// On macOS/Linux the key is a base64 string used as PBKDF2 password.
|
||||
/// On Windows the key is raw bytes (32 bytes) used directly.
|
||||
pub fn get_encryption_key(profile_data_path: &Path) -> Option<[u8; KEY_LEN]> {
|
||||
let key_file = profile_data_path.join("os_crypt_key");
|
||||
if let Ok(contents) = std::fs::read_to_string(&key_file) {
|
||||
let contents = contents.trim();
|
||||
if !contents.is_empty() {
|
||||
return Some(derive_key(contents.as_bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for macOS: try system Keychain (for profiles created before file-based keys)
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let output = std::process::Command::new("security")
|
||||
.args([
|
||||
"find-generic-password",
|
||||
"-w",
|
||||
"-s",
|
||||
"Chromium Safe Storage",
|
||||
"-a",
|
||||
"Chromium",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
let password = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !password.is_empty() {
|
||||
return Some(derive_key(password.as_bytes()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Decrypt a Chrome encrypted cookie value.
|
||||
/// Chromium prefixes encrypted values with "v10" (macOS) or "v11" (Linux).
|
||||
pub fn decrypt(encrypted: &[u8], key: &[u8; KEY_LEN]) -> Option<String> {
|
||||
if encrypted.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
// Check for v10/v11 prefix
|
||||
let prefix = &encrypted[..3];
|
||||
if prefix != b"v10" && prefix != b"v11" {
|
||||
return None;
|
||||
}
|
||||
let ciphertext = &encrypted[3..];
|
||||
if ciphertext.is_empty() {
|
||||
return Some(String::new());
|
||||
}
|
||||
|
||||
let mut buf = ciphertext.to_vec();
|
||||
let decrypted = Aes128CbcDec::new(key.into(), &IV.into())
|
||||
.decrypt_padded_mut::<Pkcs7>(&mut buf)
|
||||
.ok()?;
|
||||
|
||||
String::from_utf8(decrypted.to_vec()).ok()
|
||||
}
|
||||
|
||||
/// Encrypt a cookie value in Chrome format (v10/v11 prefix + AES-128-CBC).
|
||||
pub fn encrypt(plaintext: &str, key: &[u8; KEY_LEN]) -> Vec<u8> {
|
||||
let pt = plaintext.as_bytes();
|
||||
let block_size = 16usize;
|
||||
// Allocate buffer with space for PKCS7 padding (up to one extra block)
|
||||
let padded_len = pt.len() + (block_size - pt.len() % block_size);
|
||||
let mut buf = vec![0u8; padded_len];
|
||||
buf[..pt.len()].copy_from_slice(pt);
|
||||
|
||||
let encrypted = Aes128CbcEnc::new(key.into(), &IV.into())
|
||||
.encrypt_padded_mut::<Pkcs7>(&mut buf, pt.len())
|
||||
.expect("encryption buffer too small");
|
||||
|
||||
let mut result = Vec::with_capacity(3 + encrypted.len());
|
||||
#[cfg(target_os = "macos")]
|
||||
result.extend_from_slice(b"v10");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
result.extend_from_slice(b"v11");
|
||||
result.extend_from_slice(encrypted);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified cookie representation that works across both browser types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UnifiedCookie {
|
||||
@@ -77,6 +183,12 @@ impl CookieManager {
|
||||
/// Windows epoch offset: seconds between 1601-01-01 and 1970-01-01
|
||||
const WINDOWS_EPOCH_DIFF: i64 = 11644473600;
|
||||
|
||||
/// Get the Chrome cookie encryption key for a Wayfern profile
|
||||
fn get_chrome_encryption_key(profile: &BrowserProfile, profiles_dir: &Path) -> Option<[u8; 16]> {
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
chrome_decrypt::get_encryption_key(&profile_data_path)
|
||||
}
|
||||
|
||||
/// Get the cookie database path for a profile
|
||||
fn get_cookie_db_path(profile: &BrowserProfile, profiles_dir: &Path) -> Result<PathBuf, String> {
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
@@ -155,31 +267,58 @@ impl CookieManager {
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
/// Read cookies from a Chrome/Wayfern profile
|
||||
fn read_chrome_cookies(db_path: &Path) -> Result<Vec<UnifiedCookie>, String> {
|
||||
/// Read cookies from a Chrome/Wayfern profile.
|
||||
/// Handles encrypted cookies by decrypting encrypted_value using the profile's encryption key.
|
||||
fn read_chrome_cookies(
|
||||
db_path: &Path,
|
||||
encryption_key: Option<&[u8; 16]>,
|
||||
) -> Result<Vec<UnifiedCookie>, String> {
|
||||
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT name, value, host_key, path, expires_utc, is_secure,
|
||||
is_httponly, samesite, creation_utc, last_access_utc
|
||||
FROM cookies",
|
||||
is_httponly, samesite, creation_utc, last_access_utc, encrypted_value
|
||||
FROM cookies",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare statement: {e}"))?;
|
||||
|
||||
let cookies = stmt
|
||||
.query_map([], |row| {
|
||||
let name: String = row.get(0)?;
|
||||
let plaintext_value: String = row.get(1)?;
|
||||
let domain: String = row.get(2)?;
|
||||
let path: String = row.get(3)?;
|
||||
let expires_utc: i64 = row.get(4)?;
|
||||
let is_secure: i32 = row.get(5)?;
|
||||
let is_httponly: i32 = row.get(6)?;
|
||||
let samesite: i32 = row.get(7)?;
|
||||
let creation_utc: i64 = row.get(8)?;
|
||||
let last_access_utc: i64 = row.get(9)?;
|
||||
let encrypted_value: Vec<u8> = row.get(10)?;
|
||||
|
||||
// Use plaintext value if available, otherwise decrypt encrypted_value
|
||||
let value = if !plaintext_value.is_empty() {
|
||||
plaintext_value
|
||||
} else if !encrypted_value.is_empty() {
|
||||
encryption_key
|
||||
.and_then(|key| chrome_decrypt::decrypt(&encrypted_value, key))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
Ok(UnifiedCookie {
|
||||
name: row.get(0)?,
|
||||
value: row.get(1)?,
|
||||
domain: row.get(2)?,
|
||||
path: row.get(3)?,
|
||||
expires: Self::chrome_time_to_unix(row.get(4)?),
|
||||
is_secure: row.get::<_, i32>(5)? != 0,
|
||||
is_http_only: row.get::<_, i32>(6)? != 0,
|
||||
same_site: row.get(7)?,
|
||||
creation_time: Self::chrome_time_to_unix(row.get(8)?),
|
||||
last_accessed: Self::chrome_time_to_unix(row.get(9)?),
|
||||
name,
|
||||
value,
|
||||
domain,
|
||||
path,
|
||||
expires: Self::chrome_time_to_unix(expires_utc),
|
||||
is_secure: is_secure != 0,
|
||||
is_http_only: is_httponly != 0,
|
||||
same_site: samesite,
|
||||
creation_time: Self::chrome_time_to_unix(creation_utc),
|
||||
last_accessed: Self::chrome_time_to_unix(last_access_utc),
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query cookies: {e}"))?
|
||||
@@ -256,10 +395,12 @@ impl CookieManager {
|
||||
Ok((copied, replaced))
|
||||
}
|
||||
|
||||
/// Write cookies to a Chrome/Wayfern profile
|
||||
/// Write cookies to a Chrome/Wayfern profile.
|
||||
/// If an encryption key is available, stores cookies encrypted in encrypted_value.
|
||||
fn write_chrome_cookies(
|
||||
db_path: &Path,
|
||||
cookies: &[UnifiedCookie],
|
||||
encryption_key: Option<&[u8; 16]>,
|
||||
) -> Result<(usize, usize), String> {
|
||||
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
|
||||
|
||||
@@ -272,6 +413,12 @@ impl CookieManager {
|
||||
.as_secs() as i64;
|
||||
|
||||
for cookie in cookies {
|
||||
// Prepare value/encrypted_value based on whether we have an encryption key
|
||||
let (value_str, encrypted_bytes): (&str, Vec<u8>) = match encryption_key {
|
||||
Some(key) => ("", chrome_decrypt::encrypt(&cookie.value, key)),
|
||||
None => (cookie.value.as_str(), Vec::new()),
|
||||
};
|
||||
|
||||
let existing: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT rowid FROM cookies WHERE host_key = ?1 AND name = ?2 AND path = ?3",
|
||||
@@ -283,11 +430,12 @@ impl CookieManager {
|
||||
if existing.is_some() {
|
||||
conn
|
||||
.execute(
|
||||
"UPDATE cookies SET value = ?1, expires_utc = ?2, is_secure = ?3,
|
||||
is_httponly = ?4, samesite = ?5, last_access_utc = ?6, last_update_utc = ?7
|
||||
WHERE host_key = ?8 AND name = ?9 AND path = ?10",
|
||||
"UPDATE cookies SET value = ?1, encrypted_value = ?2, expires_utc = ?3, is_secure = ?4,
|
||||
is_httponly = ?5, samesite = ?6, last_access_utc = ?7, last_update_utc = ?8
|
||||
WHERE host_key = ?9 AND name = ?10 AND path = ?11",
|
||||
params![
|
||||
&cookie.value,
|
||||
value_str,
|
||||
encrypted_bytes,
|
||||
Self::unix_to_chrome_time(cookie.expires),
|
||||
cookie.is_secure as i32,
|
||||
cookie.is_http_only as i32,
|
||||
@@ -308,12 +456,13 @@ impl CookieManager {
|
||||
path, expires_utc, is_secure, is_httponly, last_access_utc, has_expires,
|
||||
is_persistent, priority, samesite, source_scheme, source_port, source_type,
|
||||
has_cross_site_ancestor, last_update_utc)
|
||||
VALUES (?1, ?2, '', ?3, ?4, X'', ?5, ?6, ?7, ?8, ?9, 1, 1, 1, ?10, 2, -1, 0, 0, ?11)",
|
||||
VALUES (?1, ?2, '', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 1, 1, 1, ?11, 2, -1, 0, 0, ?12)",
|
||||
params![
|
||||
Self::unix_to_chrome_time(cookie.creation_time),
|
||||
&cookie.domain,
|
||||
&cookie.name,
|
||||
&cookie.value,
|
||||
value_str,
|
||||
encrypted_bytes,
|
||||
&cookie.path,
|
||||
Self::unix_to_chrome_time(cookie.expires),
|
||||
cookie.is_secure as i32,
|
||||
@@ -348,7 +497,10 @@ impl CookieManager {
|
||||
|
||||
let cookies = match profile.browser.as_str() {
|
||||
"camoufox" => Self::read_firefox_cookies(&db_path)?,
|
||||
"wayfern" => Self::read_chrome_cookies(&db_path)?,
|
||||
"wayfern" => {
|
||||
let key = Self::get_chrome_encryption_key(profile, &profiles_dir);
|
||||
Self::read_chrome_cookies(&db_path, key.as_ref())?
|
||||
}
|
||||
_ => return Err(format!("Unsupported browser type: {}", profile.browser)),
|
||||
};
|
||||
|
||||
@@ -401,7 +553,10 @@ impl CookieManager {
|
||||
let source_db_path = Self::get_cookie_db_path(source, &profiles_dir)?;
|
||||
let all_cookies = match source.browser.as_str() {
|
||||
"camoufox" => Self::read_firefox_cookies(&source_db_path)?,
|
||||
"wayfern" => Self::read_chrome_cookies(&source_db_path)?,
|
||||
"wayfern" => {
|
||||
let key = Self::get_chrome_encryption_key(source, &profiles_dir);
|
||||
Self::read_chrome_cookies(&source_db_path, key.as_ref())?
|
||||
}
|
||||
_ => return Err(format!("Unsupported browser type: {}", source.browser)),
|
||||
};
|
||||
|
||||
@@ -468,7 +623,10 @@ impl CookieManager {
|
||||
|
||||
let write_result = match target.browser.as_str() {
|
||||
"camoufox" => Self::write_firefox_cookies(&target_db_path, &cookies_to_copy),
|
||||
"wayfern" => Self::write_chrome_cookies(&target_db_path, &cookies_to_copy),
|
||||
"wayfern" => {
|
||||
let key = Self::get_chrome_encryption_key(target, &profiles_dir);
|
||||
Self::write_chrome_cookies(&target_db_path, &cookies_to_copy, key.as_ref())
|
||||
}
|
||||
_ => {
|
||||
results.push(CookieCopyResult {
|
||||
target_profile_id: target_id.clone(),
|
||||
@@ -733,7 +891,10 @@ impl CookieManager {
|
||||
|
||||
let write_result = match profile.browser.as_str() {
|
||||
"camoufox" => Self::write_firefox_cookies(&db_path, &cookies),
|
||||
"wayfern" => Self::write_chrome_cookies(&db_path, &cookies),
|
||||
"wayfern" => {
|
||||
let key = Self::get_chrome_encryption_key(profile, &profiles_dir);
|
||||
Self::write_chrome_cookies(&db_path, &cookies, key.as_ref())
|
||||
}
|
||||
_ => return Err(format!("Unsupported browser type: {}", profile.browser)),
|
||||
};
|
||||
|
||||
|
||||
@@ -292,13 +292,6 @@ impl Downloader {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure_camoufox_search_engine(
|
||||
&self,
|
||||
browser_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
configure_camoufox_search_engine(browser_dir)
|
||||
}
|
||||
|
||||
pub async fn download_browser<R: tauri::Runtime>(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle<R>,
|
||||
@@ -850,10 +843,6 @@ impl Downloader {
|
||||
{
|
||||
log::warn!("Failed to create version.json for Camoufox: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = self.configure_camoufox_search_engine(&browser_dir) {
|
||||
log::warn!("Failed to configure Camoufox search engine: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Emit completion
|
||||
@@ -948,168 +937,6 @@ pub async fn cancel_download(browser_str: String, version: String) -> Result<(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find all candidate `distribution/` directories inside the Camoufox browser dir.
|
||||
/// On macOS: `<browser_dir>/<app>.app/Contents/Resources/distribution/`
|
||||
/// On Linux: `<browser_dir>/camoufox/distribution/`
|
||||
/// On Windows: `<browser_dir>/distribution/`
|
||||
/// Also includes `<browser_dir>/distribution/` as a fallback for all platforms.
|
||||
#[allow(clippy::vec_init_then_push)]
|
||||
fn find_camoufox_distribution_dirs(browser_dir: &Path) -> Vec<std::path::PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(entries) = std::fs::read_dir(browser_dir) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.path().extension().is_some_and(|ext| ext == "app") {
|
||||
dirs.push(
|
||||
entry
|
||||
.path()
|
||||
.join("Contents")
|
||||
.join("Resources")
|
||||
.join("distribution"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
dirs.push(browser_dir.join("camoufox").join("distribution"));
|
||||
}
|
||||
|
||||
// Fallback for all platforms
|
||||
dirs.push(browser_dir.join("distribution"));
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Set DuckDuckGo as the default search engine in Camoufox.
|
||||
/// Creates or updates distribution/policies.json with a proper DuckDuckGo engine definition.
|
||||
/// Called both at download time and at launch time to cover existing installations.
|
||||
pub fn configure_camoufox_search_engine(
|
||||
browser_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let distribution_dirs = find_camoufox_distribution_dirs(browser_dir);
|
||||
|
||||
// Find an existing policies.json, or pick the first candidate dir to create one
|
||||
let (policies_path, mut policies) = {
|
||||
let mut found = None;
|
||||
for dir in &distribution_dirs {
|
||||
let path = dir.join("policies.json");
|
||||
if path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
found = Some((path, val));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match found {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
// Pick the first candidate directory that exists (or can be created)
|
||||
let target_dir = distribution_dirs
|
||||
.iter()
|
||||
.find(|d| d.parent().is_some_and(|p| p.exists()))
|
||||
.or(distribution_dirs.first())
|
||||
.ok_or("No suitable distribution directory found")?;
|
||||
std::fs::create_dir_all(target_dir)?;
|
||||
(
|
||||
target_dir.join("policies.json"),
|
||||
serde_json::json!({"policies": {}}),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check if already configured
|
||||
let has_ddg_default = policies
|
||||
.get("policies")
|
||||
.and_then(|p| p.get("SearchEngines"))
|
||||
.and_then(|se| se.get("Default"))
|
||||
.and_then(|d| d.as_str())
|
||||
== Some("DuckDuckGo");
|
||||
|
||||
let has_ddg_engine = policies
|
||||
.get("policies")
|
||||
.and_then(|p| p.get("SearchEngines"))
|
||||
.and_then(|se| se.get("Add"))
|
||||
.and_then(|a| a.as_array())
|
||||
.is_some_and(|arr| {
|
||||
arr
|
||||
.iter()
|
||||
.any(|e| e.get("Name").and_then(|n| n.as_str()) == Some("DuckDuckGo"))
|
||||
});
|
||||
|
||||
if has_ddg_default && has_ddg_engine {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ddg_engine = serde_json::json!({
|
||||
"Name": "DuckDuckGo",
|
||||
"URLTemplate": "https://duckduckgo.com/?q={searchTerms}",
|
||||
"SuggestURLTemplate": "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
|
||||
"Method": "GET",
|
||||
"IconURL": "https://duckduckgo.com/favicon.ico",
|
||||
"Alias": "ddg"
|
||||
});
|
||||
|
||||
// Ensure policies.SearchEngines exists
|
||||
let policies_obj = policies
|
||||
.as_object_mut()
|
||||
.ok_or("Invalid policies.json")?
|
||||
.entry("policies")
|
||||
.or_insert(serde_json::json!({}));
|
||||
let se = policies_obj
|
||||
.as_object_mut()
|
||||
.ok_or("Invalid policies object")?
|
||||
.entry("SearchEngines")
|
||||
.or_insert(serde_json::json!({}));
|
||||
|
||||
if let Some(se_obj) = se.as_object_mut() {
|
||||
// Set DuckDuckGo as default
|
||||
se_obj.insert(
|
||||
"Default".to_string(),
|
||||
serde_json::Value::String("DuckDuckGo".to_string()),
|
||||
);
|
||||
|
||||
// Add DuckDuckGo engine definition if not present
|
||||
let add_arr = se_obj
|
||||
.entry("Add")
|
||||
.or_insert(serde_json::json!([]))
|
||||
.as_array_mut()
|
||||
.ok_or("SearchEngines.Add is not an array")?;
|
||||
|
||||
// Remove fake "None" engine
|
||||
add_arr.retain(|entry| entry.get("Name").and_then(|n| n.as_str()) != Some("None"));
|
||||
|
||||
// Add DuckDuckGo if not already present
|
||||
if !add_arr
|
||||
.iter()
|
||||
.any(|e| e.get("Name").and_then(|n| n.as_str()) == Some("DuckDuckGo"))
|
||||
{
|
||||
add_arr.push(ddg_engine);
|
||||
}
|
||||
|
||||
// Ensure DuckDuckGo is not in the Remove list
|
||||
if let Some(remove_arr) = se_obj.get_mut("Remove").and_then(|r| r.as_array_mut()) {
|
||||
remove_arr.retain(|v| v.as_str() != Some("DuckDuckGo"));
|
||||
}
|
||||
}
|
||||
|
||||
let updated = serde_json::to_string_pretty(&policies)?;
|
||||
std::fs::write(&policies_path, updated)?;
|
||||
log::info!(
|
||||
"Configured DuckDuckGo search engine in {}",
|
||||
policies_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+65
-10
@@ -37,6 +37,7 @@ pub mod proxy_server;
|
||||
pub mod proxy_storage;
|
||||
mod settings_manager;
|
||||
pub mod sync;
|
||||
mod synchronizer;
|
||||
pub mod traffic_stats;
|
||||
mod wayfern_manager;
|
||||
mod wayfern_terms;
|
||||
@@ -208,11 +209,21 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
||||
async fn create_stored_proxy(
|
||||
app_handle: tauri::AppHandle,
|
||||
name: String,
|
||||
proxy_settings: crate::browser::ProxySettings,
|
||||
proxy_settings: Option<crate::browser::ProxySettings>,
|
||||
dynamic_proxy_url: Option<String>,
|
||||
dynamic_proxy_format: Option<String>,
|
||||
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.create_stored_proxy(&app_handle, name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to create stored proxy: {e}"))
|
||||
if let (Some(url), Some(format)) = (&dynamic_proxy_url, &dynamic_proxy_format) {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.create_dynamic_proxy(&app_handle, name, url.clone(), format.clone())
|
||||
.map_err(|e| format!("Failed to create dynamic proxy: {e}"))
|
||||
} else if let Some(settings) = proxy_settings {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.create_stored_proxy(&app_handle, name, settings)
|
||||
.map_err(|e| format!("Failed to create stored proxy: {e}"))
|
||||
} else {
|
||||
Err("Either proxy_settings or dynamic proxy URL and format are required".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -226,10 +237,26 @@ async fn update_stored_proxy(
|
||||
proxy_id: String,
|
||||
name: Option<String>,
|
||||
proxy_settings: Option<crate::browser::ProxySettings>,
|
||||
dynamic_proxy_url: Option<String>,
|
||||
dynamic_proxy_format: Option<String>,
|
||||
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to update stored proxy: {e}"))
|
||||
// Check if this is a dynamic proxy update
|
||||
let is_dynamic = crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id);
|
||||
if is_dynamic || dynamic_proxy_url.is_some() {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.update_dynamic_proxy(
|
||||
&app_handle,
|
||||
&proxy_id,
|
||||
name,
|
||||
dynamic_proxy_url,
|
||||
dynamic_proxy_format,
|
||||
)
|
||||
.map_err(|e| format!("Failed to update dynamic proxy: {e}"))
|
||||
} else {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to update stored proxy: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -242,10 +269,32 @@ async fn delete_stored_proxy(app_handle: tauri::AppHandle, proxy_id: String) ->
|
||||
#[tauri::command]
|
||||
async fn check_proxy_validity(
|
||||
proxy_id: String,
|
||||
proxy_settings: crate::browser::ProxySettings,
|
||||
proxy_settings: Option<crate::browser::ProxySettings>,
|
||||
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
|
||||
// For dynamic proxies, fetch settings first
|
||||
let settings = if let Some(s) = proxy_settings {
|
||||
s
|
||||
} else if crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id) {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.resolve_dynamic_proxy(&proxy_id)
|
||||
.await?
|
||||
} else {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.get_proxy_settings_by_id(&proxy_id)
|
||||
.ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?
|
||||
};
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.check_proxy_validity(&proxy_id, &proxy_settings)
|
||||
.check_proxy_validity(&proxy_id, &settings)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_dynamic_proxy(
|
||||
url: String,
|
||||
format: String,
|
||||
) -> Result<crate::browser::ProxySettings, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.fetch_dynamic_proxy(&url, &format)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1477,7 +1526,7 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Sync not configured, skipping missing profile check: {}", e);
|
||||
log::warn!("Sync not configured, skipping missing profile check: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1572,6 +1621,7 @@ pub fn run() {
|
||||
update_stored_proxy,
|
||||
delete_stored_proxy,
|
||||
check_proxy_validity,
|
||||
fetch_dynamic_proxy,
|
||||
get_cached_proxy_check,
|
||||
export_proxies,
|
||||
import_proxies_json,
|
||||
@@ -1669,6 +1719,11 @@ pub fn run() {
|
||||
// Team lock commands
|
||||
team_lock::get_team_locks,
|
||||
team_lock::get_team_lock_status,
|
||||
// Synchronizer commands
|
||||
synchronizer::start_sync_session,
|
||||
synchronizer::stop_sync_session,
|
||||
synchronizer::remove_sync_follower,
|
||||
synchronizer::get_sync_sessions,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
+381
-77
@@ -96,6 +96,16 @@ impl McpServer {
|
||||
self.is_running.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
async fn require_paid_subscription(feature: &str) -> Result<(), McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: format!("{feature} requires an active paid subscription"),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_port(&self) -> Option<u16> {
|
||||
let port = self.port.load(Ordering::SeqCst);
|
||||
if port > 0 {
|
||||
@@ -561,7 +571,7 @@ impl McpServer {
|
||||
},
|
||||
McpTool {
|
||||
name: "create_proxy".to_string(),
|
||||
description: "Create a new proxy configuration".to_string(),
|
||||
description: "Create a new proxy configuration. For regular proxies, provide proxy_type/host/port. For dynamic proxies, provide dynamic_proxy_url and dynamic_proxy_format instead.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -572,26 +582,35 @@ impl McpServer {
|
||||
"proxy_type": {
|
||||
"type": "string",
|
||||
"enum": ["http", "https", "socks4", "socks5"],
|
||||
"description": "The type of proxy"
|
||||
"description": "The type of proxy (for regular proxies)"
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "The proxy host address"
|
||||
"description": "The proxy host address (for regular proxies)"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"description": "The proxy port number"
|
||||
"description": "The proxy port number (for regular proxies)"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "Optional username for authentication"
|
||||
"description": "Optional username for authentication (for regular proxies)"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "Optional password for authentication"
|
||||
"description": "Optional password for authentication (for regular proxies)"
|
||||
},
|
||||
"dynamic_proxy_url": {
|
||||
"type": "string",
|
||||
"description": "URL to fetch proxy settings from (for dynamic proxies)"
|
||||
},
|
||||
"dynamic_proxy_format": {
|
||||
"type": "string",
|
||||
"enum": ["json", "text"],
|
||||
"description": "Format of the dynamic proxy response: 'json' for JSON object or 'text' for text like host:port:user:pass (for dynamic proxies)"
|
||||
}
|
||||
},
|
||||
"required": ["name", "proxy_type", "host", "port"]
|
||||
"required": ["name"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
@@ -611,23 +630,32 @@ impl McpServer {
|
||||
"proxy_type": {
|
||||
"type": "string",
|
||||
"enum": ["http", "https", "socks4", "socks5"],
|
||||
"description": "The type of proxy"
|
||||
"description": "The type of proxy (for regular proxies)"
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "The proxy host address"
|
||||
"description": "The proxy host address (for regular proxies)"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"description": "The proxy port number"
|
||||
"description": "The proxy port number (for regular proxies)"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "Optional username for authentication"
|
||||
"description": "Optional username for authentication (for regular proxies)"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "Optional password for authentication"
|
||||
"description": "Optional password for authentication (for regular proxies)"
|
||||
},
|
||||
"dynamic_proxy_url": {
|
||||
"type": "string",
|
||||
"description": "URL to fetch proxy settings from (for dynamic proxies)"
|
||||
},
|
||||
"dynamic_proxy_format": {
|
||||
"type": "string",
|
||||
"enum": ["json", "text"],
|
||||
"description": "Format of the dynamic proxy response (for dynamic proxies)"
|
||||
}
|
||||
},
|
||||
"required": ["proxy_id"]
|
||||
@@ -926,6 +954,66 @@ impl McpServer {
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
// Synchronizer tools
|
||||
McpTool {
|
||||
name: "start_sync_session".to_string(),
|
||||
description: "Start a synchronizer session. Launches a leader profile and follower profiles, then mirrors all actions from the leader to the followers in real time. Only Wayfern profiles are supported. Requires paid subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"leader_profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the leader profile"
|
||||
},
|
||||
"follower_profile_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "UUIDs of follower profiles"
|
||||
}
|
||||
},
|
||||
"required": ["leader_profile_id", "follower_profile_ids"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "stop_sync_session".to_string(),
|
||||
description: "Stop an active synchronizer session. Kills all follower profiles and the leader.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": "The sync session ID"
|
||||
}
|
||||
},
|
||||
"required": ["session_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "get_sync_sessions".to_string(),
|
||||
description: "List all active synchronizer sessions.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "remove_sync_follower".to_string(),
|
||||
description: "Remove a follower from an active synchronizer session.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": "The sync session ID"
|
||||
},
|
||||
"follower_profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the follower to remove"
|
||||
}
|
||||
},
|
||||
"required": ["session_id", "follower_profile_id"]
|
||||
}),
|
||||
},
|
||||
// Browser interaction tools
|
||||
McpTool {
|
||||
name: "navigate".to_string(),
|
||||
@@ -1165,7 +1253,10 @@ impl McpServer {
|
||||
match tool_name {
|
||||
"list_profiles" => self.handle_list_profiles().await,
|
||||
"get_profile" => self.handle_get_profile(&arguments).await,
|
||||
"run_profile" => self.handle_run_profile(&arguments).await,
|
||||
"run_profile" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_run_profile(&arguments).await
|
||||
}
|
||||
"kill_profile" => self.handle_kill_profile(&arguments).await,
|
||||
"create_profile" => self.handle_create_profile(&arguments).await,
|
||||
"update_profile" => self.handle_update_profile(&arguments).await,
|
||||
@@ -1217,14 +1308,43 @@ impl McpServer {
|
||||
// Team lock tools
|
||||
"get_team_locks" => self.handle_get_team_locks().await,
|
||||
"get_team_lock_status" => self.handle_get_team_lock_status(&arguments).await,
|
||||
// Browser interaction tools
|
||||
"navigate" => self.handle_navigate(&arguments).await,
|
||||
"screenshot" => self.handle_screenshot(&arguments).await,
|
||||
"evaluate_javascript" => self.handle_evaluate_javascript(&arguments).await,
|
||||
"click_element" => self.handle_click_element(&arguments).await,
|
||||
"type_text" => self.handle_type_text(&arguments).await,
|
||||
"get_page_content" => self.handle_get_page_content(&arguments).await,
|
||||
"get_page_info" => self.handle_get_page_info(&arguments).await,
|
||||
// Synchronizer tools
|
||||
"start_sync_session" => {
|
||||
Self::require_paid_subscription("Synchronizer").await?;
|
||||
self.handle_start_sync_session(&arguments).await
|
||||
}
|
||||
"stop_sync_session" => self.handle_stop_sync_session(&arguments).await,
|
||||
"get_sync_sessions" => self.handle_get_sync_sessions().await,
|
||||
"remove_sync_follower" => self.handle_remove_sync_follower(&arguments).await,
|
||||
// Browser interaction tools (require paid subscription)
|
||||
"navigate" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_navigate(&arguments).await
|
||||
}
|
||||
"screenshot" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_screenshot(&arguments).await
|
||||
}
|
||||
"evaluate_javascript" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_evaluate_javascript(&arguments).await
|
||||
}
|
||||
"click_element" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_click_element(&arguments).await
|
||||
}
|
||||
"type_text" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_type_text(&arguments).await
|
||||
}
|
||||
"get_page_content" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_get_page_content(&arguments).await
|
||||
}
|
||||
"get_page_info" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_get_page_info(&arguments).await
|
||||
}
|
||||
_ => Err(McpError {
|
||||
code: -32602,
|
||||
message: format!("Unknown tool: {tool_name}"),
|
||||
@@ -2013,59 +2133,79 @@ impl McpServer {
|
||||
message: "Missing name".to_string(),
|
||||
})?;
|
||||
|
||||
let proxy_type = arguments
|
||||
.get("proxy_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing proxy_type".to_string(),
|
||||
})?;
|
||||
|
||||
let host = arguments
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing host".to_string(),
|
||||
})?;
|
||||
|
||||
let port = arguments
|
||||
.get("port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing port".to_string(),
|
||||
})? as u16;
|
||||
|
||||
let username = arguments
|
||||
.get("username")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let password = arguments
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let proxy_settings = ProxySettings {
|
||||
proxy_type: proxy_type.to_string(),
|
||||
host: host.to_string(),
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
};
|
||||
|
||||
let inner = self.inner.lock().await;
|
||||
let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?;
|
||||
|
||||
let proxy = PROXY_MANAGER
|
||||
.create_stored_proxy(app_handle, name.to_string(), proxy_settings)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to create proxy: {e}"),
|
||||
})?;
|
||||
// Check if this is a dynamic proxy creation
|
||||
let dynamic_url = arguments.get("dynamic_proxy_url").and_then(|v| v.as_str());
|
||||
let dynamic_format = arguments
|
||||
.get("dynamic_proxy_format")
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
let proxy = if let (Some(url), Some(format)) = (dynamic_url, dynamic_format) {
|
||||
PROXY_MANAGER
|
||||
.create_dynamic_proxy(
|
||||
app_handle,
|
||||
name.to_string(),
|
||||
url.to_string(),
|
||||
format.to_string(),
|
||||
)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to create dynamic proxy: {e}"),
|
||||
})?
|
||||
} else {
|
||||
let proxy_type = arguments
|
||||
.get("proxy_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing proxy_type (required for regular proxies)".to_string(),
|
||||
})?;
|
||||
|
||||
let host = arguments
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing host (required for regular proxies)".to_string(),
|
||||
})?;
|
||||
|
||||
let port = arguments
|
||||
.get("port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing port (required for regular proxies)".to_string(),
|
||||
})? as u16;
|
||||
|
||||
let username = arguments
|
||||
.get("username")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let password = arguments
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let proxy_settings = ProxySettings {
|
||||
proxy_type: proxy_type.to_string(),
|
||||
host: host.to_string(),
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
};
|
||||
|
||||
PROXY_MANAGER
|
||||
.create_stored_proxy(app_handle, name.to_string(), proxy_settings)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to create proxy: {e}"),
|
||||
})?
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
@@ -2155,12 +2295,32 @@ impl McpServer {
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?;
|
||||
|
||||
let proxy = PROXY_MANAGER
|
||||
.update_stored_proxy(app_handle, proxy_id, name, proxy_settings)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update proxy: {e}"),
|
||||
})?;
|
||||
// Check for dynamic proxy fields
|
||||
let dynamic_url = arguments
|
||||
.get("dynamic_proxy_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let dynamic_format = arguments
|
||||
.get("dynamic_proxy_format")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(proxy_id) || dynamic_url.is_some();
|
||||
|
||||
let proxy = if is_dynamic {
|
||||
PROXY_MANAGER
|
||||
.update_dynamic_proxy(app_handle, proxy_id, name, dynamic_url, dynamic_format)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update dynamic proxy: {e}"),
|
||||
})?
|
||||
} else {
|
||||
PROXY_MANAGER
|
||||
.update_stored_proxy(app_handle, proxy_id, name, proxy_settings)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update proxy: {e}"),
|
||||
})?
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
@@ -3030,9 +3190,8 @@ impl McpServer {
|
||||
let url = format!("http://127.0.0.1:{port}/json");
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Retry connecting to CDP endpoint — Wayfern closes the debugging port
|
||||
// briefly after launch for anti-detection and reopens it after ~30s.
|
||||
let max_attempts = 45;
|
||||
// Retry connecting to CDP endpoint (browser may still be starting up)
|
||||
let max_attempts = 15;
|
||||
let mut last_err = String::new();
|
||||
for attempt in 0..max_attempts {
|
||||
if attempt > 0 {
|
||||
@@ -3900,6 +4059,146 @@ impl McpServer {
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Synchronizer handlers ---
|
||||
|
||||
async fn handle_start_sync_session(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let leader_id = arguments
|
||||
.get("leader_profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing leader_profile_id".to_string(),
|
||||
})?;
|
||||
let follower_ids: Vec<String> = arguments
|
||||
.get("follower_profile_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing follower_profile_ids".to_string(),
|
||||
})?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
let app = {
|
||||
let inner = self.inner.lock().await;
|
||||
inner.app_handle.clone().ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?
|
||||
};
|
||||
|
||||
let info = crate::synchronizer::SynchronizerManager::instance()
|
||||
.start_session(app, leader_id.to_string(), follower_ids)
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: e,
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": serde_json::to_string_pretty(&info).unwrap_or_default()
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_stop_sync_session(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let session_id = arguments
|
||||
.get("session_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing session_id".to_string(),
|
||||
})?;
|
||||
|
||||
let app = {
|
||||
let inner = self.inner.lock().await;
|
||||
inner.app_handle.clone().ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?
|
||||
};
|
||||
|
||||
crate::synchronizer::SynchronizerManager::instance()
|
||||
.stop_session(app, session_id)
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: e,
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "Sync session stopped"
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_get_sync_sessions(&self) -> Result<serde_json::Value, McpError> {
|
||||
let sessions = crate::synchronizer::SynchronizerManager::instance()
|
||||
.get_sessions()
|
||||
.await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": serde_json::to_string_pretty(&sessions).unwrap_or_default()
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_remove_sync_follower(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let session_id = arguments
|
||||
.get("session_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing session_id".to_string(),
|
||||
})?;
|
||||
let follower_id = arguments
|
||||
.get("follower_profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing follower_profile_id".to_string(),
|
||||
})?;
|
||||
|
||||
let app = {
|
||||
let inner = self.inner.lock().await;
|
||||
inner.app_handle.clone().ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?
|
||||
};
|
||||
|
||||
crate::synchronizer::SynchronizerManager::instance()
|
||||
.remove_follower(app, session_id, follower_id)
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: e,
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "Follower removed from sync session"
|
||||
}]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -3963,6 +4262,11 @@ mod tests {
|
||||
// Team lock tools
|
||||
assert!(tool_names.contains(&"get_team_locks"));
|
||||
assert!(tool_names.contains(&"get_team_lock_status"));
|
||||
// Synchronizer tools
|
||||
assert!(tool_names.contains(&"start_sync_session"));
|
||||
assert!(tool_names.contains(&"stop_sync_session"));
|
||||
assert!(tool_names.contains(&"get_sync_sessions"));
|
||||
assert!(tool_names.contains(&"remove_sync_follower"));
|
||||
// Browser interaction tools
|
||||
assert!(tool_names.contains(&"navigate"));
|
||||
assert!(tool_names.contains(&"screenshot"));
|
||||
|
||||
@@ -117,6 +117,10 @@ pub struct StoredProxy {
|
||||
pub geo_city: Option<String>,
|
||||
#[serde(default)]
|
||||
pub geo_isp: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dynamic_proxy_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dynamic_proxy_format: Option<String>,
|
||||
}
|
||||
|
||||
impl StoredProxy {
|
||||
@@ -135,9 +139,15 @@ impl StoredProxy {
|
||||
geo_region: None,
|
||||
geo_city: None,
|
||||
geo_isp: None,
|
||||
dynamic_proxy_url: None,
|
||||
dynamic_proxy_format: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_dynamic(&self) -> bool {
|
||||
self.dynamic_proxy_url.is_some()
|
||||
}
|
||||
|
||||
/// Migrate legacy geo_state to geo_region
|
||||
pub fn migrate_geo_fields(&mut self) {
|
||||
if self.geo_region.is_none() && self.geo_state.is_some() {
|
||||
@@ -450,6 +460,8 @@ impl ProxyManager {
|
||||
geo_region: None,
|
||||
geo_city: None,
|
||||
geo_isp: None,
|
||||
dynamic_proxy_url: None,
|
||||
dynamic_proxy_format: None,
|
||||
};
|
||||
stored_proxies.insert(CLOUD_PROXY_ID.to_string(), cloud_proxy.clone());
|
||||
drop(stored_proxies);
|
||||
@@ -639,6 +651,8 @@ impl ProxyManager {
|
||||
geo_region: region,
|
||||
geo_city: city,
|
||||
geo_isp: isp,
|
||||
dynamic_proxy_url: None,
|
||||
dynamic_proxy_format: None,
|
||||
};
|
||||
|
||||
{
|
||||
@@ -965,6 +979,269 @@ impl ProxyManager {
|
||||
self.load_proxy_check_cache(proxy_id)
|
||||
}
|
||||
|
||||
// Check if a stored proxy is dynamic
|
||||
pub fn is_dynamic_proxy(&self, proxy_id: &str) -> bool {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
stored_proxies.get(proxy_id).is_some_and(|p| p.is_dynamic())
|
||||
}
|
||||
|
||||
// Fetch proxy settings from a dynamic proxy URL
|
||||
pub async fn fetch_dynamic_proxy(
|
||||
&self,
|
||||
url: &str,
|
||||
format: &str,
|
||||
) -> Result<ProxySettings, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch dynamic proxy: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Dynamic proxy URL returned status {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read dynamic proxy response: {e}"))?;
|
||||
|
||||
let body = body.trim();
|
||||
if body.is_empty() {
|
||||
return Err("Dynamic proxy URL returned empty response".to_string());
|
||||
}
|
||||
|
||||
match format {
|
||||
"json" => Self::parse_dynamic_proxy_json(body),
|
||||
"text" => Self::parse_dynamic_proxy_text(body),
|
||||
_ => Err(format!("Unsupported dynamic proxy format: {format}")),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON format: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." }
|
||||
fn parse_dynamic_proxy_json(body: &str) -> Result<ProxySettings, String> {
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))?;
|
||||
|
||||
let obj = json
|
||||
.as_object()
|
||||
.ok_or_else(|| "JSON response is not an object".to_string())?;
|
||||
|
||||
let host = obj
|
||||
.get("ip")
|
||||
.or_else(|| obj.get("host"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'ip' or 'host' field in JSON response".to_string())?
|
||||
.to_string();
|
||||
|
||||
let port = obj
|
||||
.get("port")
|
||||
.and_then(|v| {
|
||||
v.as_u64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||
})
|
||||
.ok_or_else(|| "Missing or invalid 'port' field in JSON response".to_string())?
|
||||
as u16;
|
||||
|
||||
let proxy_type = obj
|
||||
.get("type")
|
||||
.or_else(|| obj.get("proxy_type"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("http")
|
||||
.to_string();
|
||||
|
||||
let username = obj
|
||||
.get("username")
|
||||
.or_else(|| obj.get("user"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let password = obj
|
||||
.get("password")
|
||||
.or_else(|| obj.get("pass"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Ok(ProxySettings {
|
||||
proxy_type,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
// Parse text format using the same logic as proxy import
|
||||
fn parse_dynamic_proxy_text(body: &str) -> Result<ProxySettings, String> {
|
||||
let line = body
|
||||
.lines()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if line.is_empty() {
|
||||
return Err("Empty text response".to_string());
|
||||
}
|
||||
|
||||
match Self::parse_single_proxy_line(line) {
|
||||
ProxyParseResult::Parsed(parsed) => Ok(ProxySettings {
|
||||
proxy_type: parsed.proxy_type,
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
}),
|
||||
ProxyParseResult::Ambiguous {
|
||||
possible_formats, ..
|
||||
} => Err(format!(
|
||||
"Ambiguous proxy format. Could be: {}",
|
||||
possible_formats.join(" or ")
|
||||
)),
|
||||
ProxyParseResult::Invalid { reason, .. } => {
|
||||
Err(format!("Failed to parse proxy response: {reason}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve dynamic proxy: fetch from URL and return settings
|
||||
pub async fn resolve_dynamic_proxy(&self, proxy_id: &str) -> Result<ProxySettings, String> {
|
||||
let (url, format) = {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
let proxy = stored_proxies
|
||||
.get(proxy_id)
|
||||
.ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?;
|
||||
|
||||
match (&proxy.dynamic_proxy_url, &proxy.dynamic_proxy_format) {
|
||||
(Some(url), Some(format)) => (url.clone(), format.clone()),
|
||||
_ => return Err("Proxy is not a dynamic proxy".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
self.fetch_dynamic_proxy(&url, &format).await
|
||||
}
|
||||
|
||||
// Create a dynamic stored proxy
|
||||
pub fn create_dynamic_proxy(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
name: String,
|
||||
url: String,
|
||||
format: String,
|
||||
) -> Result<StoredProxy, String> {
|
||||
{
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
if stored_proxies.values().any(|p| p.name == name) {
|
||||
return Err(format!("Proxy with name '{name}' already exists"));
|
||||
}
|
||||
}
|
||||
|
||||
let placeholder_settings = ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "dynamic".to_string(),
|
||||
port: 0,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
let mut stored_proxy = StoredProxy::new(name, placeholder_settings);
|
||||
stored_proxy.dynamic_proxy_url = Some(url);
|
||||
stored_proxy.dynamic_proxy_format = Some(format);
|
||||
|
||||
{
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
stored_proxies.insert(stored_proxy.id.clone(), stored_proxy.clone());
|
||||
}
|
||||
|
||||
if let Err(e) = self.save_proxy(&stored_proxy) {
|
||||
log::warn!("Failed to save proxy: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = events::emit_empty("proxies-changed") {
|
||||
log::error!("Failed to emit proxies-changed event: {e}");
|
||||
}
|
||||
|
||||
if stored_proxy.sync_enabled {
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
let id = stored_proxy.id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_proxy_sync(id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stored_proxy)
|
||||
}
|
||||
|
||||
// Update a dynamic proxy's URL and format
|
||||
pub fn update_dynamic_proxy(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
proxy_id: &str,
|
||||
name: Option<String>,
|
||||
url: Option<String>,
|
||||
format: Option<String>,
|
||||
) -> Result<StoredProxy, String> {
|
||||
{
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
if !stored_proxies.contains_key(proxy_id) {
|
||||
return Err(format!("Proxy with ID '{proxy_id}' not found"));
|
||||
}
|
||||
if let Some(ref new_name) = name {
|
||||
if stored_proxies
|
||||
.values()
|
||||
.any(|p| p.id != proxy_id && p.name == *new_name)
|
||||
{
|
||||
return Err(format!("Proxy with name '{new_name}' already exists"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let updated_proxy = {
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
let stored_proxy = stored_proxies.get_mut(proxy_id).unwrap();
|
||||
|
||||
if let Some(new_name) = name {
|
||||
stored_proxy.update_name(new_name);
|
||||
}
|
||||
if let Some(new_url) = url {
|
||||
stored_proxy.dynamic_proxy_url = Some(new_url);
|
||||
}
|
||||
if let Some(new_format) = format {
|
||||
stored_proxy.dynamic_proxy_format = Some(new_format);
|
||||
}
|
||||
|
||||
stored_proxy.clone()
|
||||
};
|
||||
|
||||
if let Err(e) = self.save_proxy(&updated_proxy) {
|
||||
log::warn!("Failed to save proxy: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = events::emit_empty("proxies-changed") {
|
||||
log::error!("Failed to emit proxies-changed event: {e}");
|
||||
}
|
||||
|
||||
if updated_proxy.sync_enabled {
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
let id = updated_proxy.id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_proxy_sync(id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(updated_proxy)
|
||||
}
|
||||
|
||||
// Export all proxies as JSON
|
||||
pub fn export_proxies_json(&self) -> Result<String, String> {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
@@ -2835,6 +3112,8 @@ mod tests {
|
||||
geo_region: None,
|
||||
geo_city: None,
|
||||
geo_isp: None,
|
||||
dynamic_proxy_url: None,
|
||||
dynamic_proxy_format: None,
|
||||
};
|
||||
|
||||
// Before migration
|
||||
|
||||
@@ -127,6 +127,14 @@ impl SyncClient {
|
||||
}
|
||||
|
||||
pub async fn list(&self, prefix: &str) -> SyncResult<ListResponse> {
|
||||
self.list_page(prefix, None).await
|
||||
}
|
||||
|
||||
async fn list_page(
|
||||
&self,
|
||||
prefix: &str,
|
||||
continuation_token: Option<String>,
|
||||
) -> SyncResult<ListResponse> {
|
||||
let response = self
|
||||
.client
|
||||
.post(self.url("list"))
|
||||
@@ -134,7 +142,7 @@ impl SyncClient {
|
||||
.json(&ListRequest {
|
||||
prefix: prefix.to_string(),
|
||||
max_keys: Some(1000),
|
||||
continuation_token: None,
|
||||
continuation_token,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
@@ -152,6 +160,27 @@ impl SyncClient {
|
||||
.map_err(|e| SyncError::SerializationError(e.to_string()))
|
||||
}
|
||||
|
||||
/// List all objects under a prefix, paginating through all results
|
||||
pub async fn list_all(&self, prefix: &str) -> SyncResult<Vec<ListObject>> {
|
||||
let mut all_objects = Vec::new();
|
||||
let mut continuation_token: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let response = self.list_page(prefix, continuation_token).await?;
|
||||
all_objects.extend(response.objects);
|
||||
|
||||
if !response.is_truncated {
|
||||
break;
|
||||
}
|
||||
continuation_token = response.next_continuation_token;
|
||||
if continuation_token.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_objects)
|
||||
}
|
||||
|
||||
pub async fn upload_bytes(
|
||||
&self,
|
||||
presigned_url: &str,
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::settings_manager::SettingsManager;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
@@ -49,6 +49,70 @@ fn is_critical_file(path: &str) -> bool {
|
||||
.any(|pattern| path.contains(pattern))
|
||||
}
|
||||
|
||||
/// Checkpoint all SQLite WAL files in a profile directory.
|
||||
///
|
||||
/// When a browser crashes or is killed, SQLite WAL files may contain
|
||||
/// uncommitted data (e.g. cookies, login data). Since WAL files are
|
||||
/// excluded from sync, we must checkpoint them into the main database
|
||||
/// files before generating the manifest to avoid data loss.
|
||||
fn checkpoint_sqlite_wal_files(profile_dir: &Path) {
|
||||
fn find_wal_files(dir: &Path, wal_files: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
find_wal_files(&path, wal_files);
|
||||
} else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if name.ends_with("-wal") {
|
||||
wal_files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut wal_files = Vec::new();
|
||||
find_wal_files(profile_dir, &mut wal_files);
|
||||
|
||||
for wal_path in &wal_files {
|
||||
// Only checkpoint non-empty WAL files
|
||||
let is_non_empty = fs::metadata(wal_path).map(|m| m.len() > 0).unwrap_or(false);
|
||||
if !is_non_empty {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Derive the main database path by stripping the "-wal" suffix
|
||||
let db_path_str = wal_path.to_string_lossy();
|
||||
let db_path = PathBuf::from(db_path_str.strip_suffix("-wal").unwrap());
|
||||
|
||||
if !db_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match rusqlite::Connection::open(&db_path) {
|
||||
Ok(conn) => match conn.pragma_update(None, "wal_checkpoint", "TRUNCATE") {
|
||||
Ok(_) => {
|
||||
log::info!(
|
||||
"Checkpointed WAL for: {}",
|
||||
db_path.file_name().unwrap_or_default().to_string_lossy()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to checkpoint WAL for {}: {}", db_path.display(), e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to open DB for WAL checkpoint {}: {}",
|
||||
db_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume state persisted to disk so interrupted syncs can continue
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
struct SyncResumeState {
|
||||
@@ -362,6 +426,10 @@ impl SyncEngine {
|
||||
))
|
||||
})?;
|
||||
|
||||
// Checkpoint any SQLite WAL files to ensure all data is in the main DB
|
||||
// before we generate the manifest (WAL files are excluded from sync)
|
||||
checkpoint_sqlite_wal_files(&profile_dir);
|
||||
|
||||
// Load or create hash cache
|
||||
let cache_path = get_cache_path(&profile_dir);
|
||||
let mut hash_cache = HashCache::load(&cache_path);
|
||||
@@ -488,9 +556,22 @@ impl SyncEngine {
|
||||
.upload_profile_metadata(&profile_id, profile, &key_prefix)
|
||||
.await?;
|
||||
|
||||
// If we recovered from an empty local state (downloaded everything from remote),
|
||||
// regenerate the manifest from the actual files now on disk so we don't
|
||||
// overwrite the remote manifest with an empty one.
|
||||
let final_manifest = if local_manifest.files.is_empty() && !diff.files_to_download.is_empty() {
|
||||
let mut new_cache = HashCache::load(&cache_path);
|
||||
let mut regenerated = generate_manifest(&profile_id, &profile_dir, &mut new_cache)?;
|
||||
new_cache.save(&cache_path)?;
|
||||
regenerated.encrypted = encryption_key.is_some();
|
||||
regenerated
|
||||
} else {
|
||||
let mut m = local_manifest;
|
||||
m.encrypted = encryption_key.is_some();
|
||||
m
|
||||
};
|
||||
|
||||
// Upload manifest.json last for atomicity
|
||||
let mut final_manifest = local_manifest;
|
||||
final_manifest.encrypted = encryption_key.is_some();
|
||||
self
|
||||
.upload_manifest(&profile_id, &final_manifest, &key_prefix)
|
||||
.await?;
|
||||
@@ -2165,14 +2246,14 @@ impl SyncEngine {
|
||||
) -> SyncResult<Vec<String>> {
|
||||
log::info!("Checking for missing synced profiles...");
|
||||
|
||||
// List personal profiles from S3
|
||||
let list_response = self.client.list("profiles/").await?;
|
||||
// List all personal profiles from S3 (paginated)
|
||||
let all_objects = self.client.list_all("profiles/").await?;
|
||||
|
||||
let mut downloaded: Vec<String> = Vec::new();
|
||||
|
||||
// Extract unique profile IDs with their key prefix
|
||||
let mut profiles_to_check: HashMap<String, String> = HashMap::new();
|
||||
for obj in list_response.objects {
|
||||
for obj in all_objects {
|
||||
if obj.key.starts_with("profiles/") && obj.key.ends_with("/manifest.json") {
|
||||
if let Some(profile_id) = obj
|
||||
.key
|
||||
@@ -2189,8 +2270,8 @@ impl SyncEngine {
|
||||
if let Some(team_id) = &auth.user.team_id {
|
||||
let team_prefix = format!("teams/{}/", team_id);
|
||||
let team_list_key = format!("{}profiles/", team_prefix);
|
||||
if let Ok(team_list) = self.client.list(&team_list_key).await {
|
||||
for obj in team_list.objects {
|
||||
if let Ok(team_objects) = self.client.list_all(&team_list_key).await {
|
||||
for obj in team_objects {
|
||||
if obj.key.starts_with("profiles/") && obj.key.ends_with("/manifest.json") {
|
||||
if let Some(profile_id) = obj
|
||||
.key
|
||||
@@ -3341,3 +3422,138 @@ pub async fn set_extension_group_sync_enabled(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_sqlite_wal_files() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let db_path = temp_dir.path().join("test.db");
|
||||
|
||||
// Create a SQLite database in WAL mode and insert data.
|
||||
// Use std::mem::forget to prevent the connection destructor from running,
|
||||
// which simulates a browser crash where WAL is not checkpointed.
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
|
||||
conn.pragma_update(None, "wal_autocheckpoint", "0").unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"CREATE TABLE cookies (id INTEGER PRIMARY KEY, value TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies (value) VALUES ('session_token_123')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Leak the connection to prevent auto-checkpoint on drop
|
||||
std::mem::forget(conn);
|
||||
}
|
||||
|
||||
// Verify WAL file exists and has data
|
||||
let wal_path = temp_dir.path().join("test.db-wal");
|
||||
assert!(wal_path.exists(), "WAL file should exist");
|
||||
let wal_size = fs::metadata(&wal_path).unwrap().len();
|
||||
assert!(wal_size > 0, "WAL file should be non-empty");
|
||||
|
||||
// Run checkpoint
|
||||
checkpoint_sqlite_wal_files(temp_dir.path());
|
||||
|
||||
// After checkpoint, WAL should be truncated (empty)
|
||||
let wal_size_after = fs::metadata(&wal_path).map(|m| m.len()).unwrap_or(0);
|
||||
assert_eq!(
|
||||
wal_size_after, 0,
|
||||
"WAL should be truncated after checkpoint"
|
||||
);
|
||||
|
||||
// Verify data is still accessible from the main database
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
let value: String = conn
|
||||
.query_row("SELECT value FROM cookies WHERE id = 1", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(value, "session_token_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_handles_missing_db() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
// Create a WAL file without a corresponding database
|
||||
let wal_path = temp_dir.path().join("missing.db-wal");
|
||||
fs::write(&wal_path, b"fake wal data").unwrap();
|
||||
|
||||
// Should not panic
|
||||
checkpoint_sqlite_wal_files(temp_dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_skips_empty_wal() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let db_path = temp_dir.path().join("test.db");
|
||||
|
||||
// Create a database and checkpoint immediately (WAL is empty)
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
|
||||
conn
|
||||
.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)", [])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Create an empty WAL file
|
||||
let wal_path = temp_dir.path().join("test.db-wal");
|
||||
fs::write(&wal_path, b"").unwrap();
|
||||
|
||||
// Should skip empty WAL without error
|
||||
checkpoint_sqlite_wal_files(temp_dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_nested_directories() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let nested_dir = temp_dir.path().join("profile").join("Default");
|
||||
fs::create_dir_all(&nested_dir).unwrap();
|
||||
|
||||
let db_path = nested_dir.join("Cookies");
|
||||
|
||||
// Create a database with WAL data, leak connection to simulate crash
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
|
||||
conn.pragma_update(None, "wal_autocheckpoint", "0").unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies VALUES ('.example.com', 'session', 'abc')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
std::mem::forget(conn);
|
||||
}
|
||||
|
||||
let wal_path = nested_dir.join("Cookies-wal");
|
||||
assert!(wal_path.exists());
|
||||
|
||||
// Checkpoint from the top-level directory
|
||||
checkpoint_sqlite_wal_files(temp_dir.path());
|
||||
|
||||
// Verify data is in the main database
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM cookies", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,6 +408,19 @@ pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> Mani
|
||||
let remote_files: HashMap<&str, &ManifestFileEntry> =
|
||||
remote.files.iter().map(|f| (f.path.as_str(), f)).collect();
|
||||
|
||||
// Safety: if local is empty but remote has files, always download from remote.
|
||||
// This prevents data loss when profile data files are deleted but metadata
|
||||
// survives — the newly generated manifest would have updated_at=NOW, which
|
||||
// would appear "newer" and cause all remote files to be deleted.
|
||||
if local.files.is_empty() && !remote.files.is_empty() {
|
||||
log::info!(
|
||||
"Local manifest is empty but remote has {} files — downloading from remote to recover",
|
||||
remote.files.len()
|
||||
);
|
||||
diff.files_to_download = remote.files.clone();
|
||||
return diff;
|
||||
}
|
||||
|
||||
// Compare timestamps to determine direction
|
||||
let local_updated = local.updated_at_datetime();
|
||||
let remote_updated = remote.updated_at_datetime();
|
||||
@@ -738,4 +751,50 @@ mod tests {
|
||||
let deserialized: SyncManifest = serde_json::from_str(&serialized).unwrap();
|
||||
assert!(deserialized.encrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_diff_empty_local_downloads_from_remote() {
|
||||
// When local has no files but remote does, always download from remote.
|
||||
// This prevents data loss when profile data is deleted but metadata survives.
|
||||
let local = SyncManifest {
|
||||
version: 1,
|
||||
profile_id: "test".to_string(),
|
||||
generated_at: Utc::now().to_rfc3339(),
|
||||
updated_at: Utc::now().to_rfc3339(), // NOW — appears newer than remote
|
||||
exclude_globs: vec![],
|
||||
files: vec![],
|
||||
encrypted: false,
|
||||
};
|
||||
|
||||
let remote = SyncManifest {
|
||||
version: 1,
|
||||
profile_id: "test".to_string(),
|
||||
generated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
exclude_globs: vec![],
|
||||
files: vec![
|
||||
ManifestFileEntry {
|
||||
path: "Cookies".to_string(),
|
||||
size: 100,
|
||||
mtime: 1000,
|
||||
hash: "abc".to_string(),
|
||||
},
|
||||
ManifestFileEntry {
|
||||
path: "Local State".to_string(),
|
||||
size: 200,
|
||||
mtime: 1000,
|
||||
hash: "def".to_string(),
|
||||
},
|
||||
],
|
||||
encrypted: false,
|
||||
};
|
||||
|
||||
let diff = compute_diff(&local, Some(&remote));
|
||||
|
||||
// Must download all remote files, NOT delete them
|
||||
assert_eq!(diff.files_to_download.len(), 2);
|
||||
assert!(diff.files_to_upload.is_empty());
|
||||
assert!(diff.files_to_delete_remote.is_empty());
|
||||
assert!(diff.files_to_delete_local.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+101
-86
@@ -396,97 +396,112 @@ impl SyncScheduler {
|
||||
ready
|
||||
};
|
||||
|
||||
// Mark all profiles as in-flight and filter out duplicates
|
||||
let mut to_sync = Vec::new();
|
||||
for profile_id in profiles_to_sync {
|
||||
// Mark as in-flight to prevent duplicate syncs
|
||||
{
|
||||
let mut in_flight = self.in_flight_profiles.lock().await;
|
||||
if in_flight.contains(&profile_id) {
|
||||
log::debug!("Profile {} already in-flight, skipping", profile_id);
|
||||
continue;
|
||||
}
|
||||
in_flight.insert(profile_id.clone());
|
||||
}
|
||||
|
||||
log::info!("Executing queued sync for profile {}", profile_id);
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
serde_json::json!({
|
||||
"profile_id": profile_id,
|
||||
"status": "syncing"
|
||||
}),
|
||||
);
|
||||
|
||||
let profile_to_sync = {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager.list_profiles().ok().and_then(|profiles| {
|
||||
profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id && p.is_sync_enabled() && !p.is_cross_os())
|
||||
})
|
||||
};
|
||||
|
||||
let Some(profile) = profile_to_sync else {
|
||||
// Remove from in-flight
|
||||
let mut in_flight = self.in_flight_profiles.lock().await;
|
||||
in_flight.remove(&profile_id);
|
||||
let mut in_flight = self.in_flight_profiles.lock().await;
|
||||
if in_flight.contains(&profile_id) {
|
||||
log::debug!("Profile {} already in-flight, skipping", profile_id);
|
||||
continue;
|
||||
};
|
||||
|
||||
let result = match SyncEngine::create_from_settings(app_handle).await {
|
||||
Ok(engine) => engine.sync_profile(app_handle, &profile).await,
|
||||
Err(e) => {
|
||||
log::error!("Failed to create sync engine: {}", e);
|
||||
Err(super::types::SyncError::NotConfigured)
|
||||
}
|
||||
};
|
||||
|
||||
// Remove from in-flight and check if sync just completed
|
||||
let sync_just_completed = {
|
||||
let mut in_flight = self.in_flight_profiles.lock().await;
|
||||
in_flight.remove(&profile_id);
|
||||
// If this was the last in-flight profile and there are no pending profiles, sync just completed
|
||||
in_flight.is_empty()
|
||||
&& self.pending_profiles.lock().await.is_empty()
|
||||
&& self.pending_proxies.lock().await.is_empty()
|
||||
&& self.pending_groups.lock().await.is_empty()
|
||||
&& self.pending_vpns.lock().await.is_empty()
|
||||
&& self.pending_extensions.lock().await.is_empty()
|
||||
&& self.pending_extension_groups.lock().await.is_empty()
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
log::info!("Profile {} synced successfully", profile_id);
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
serde_json::json!({
|
||||
"profile_id": profile_id,
|
||||
"status": "synced"
|
||||
}),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to sync profile {}: {}", profile_id, e);
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
serde_json::json!({
|
||||
"profile_id": profile_id,
|
||||
"status": "error",
|
||||
"error": e.to_string()
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
in_flight.insert(profile_id.clone());
|
||||
to_sync.push(profile_id);
|
||||
}
|
||||
|
||||
// Trigger cleanup after sync completes if this was the last profile
|
||||
if sync_just_completed {
|
||||
log::debug!("All profile syncs completed, triggering cleanup");
|
||||
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
if let Err(e) = registry.cleanup_unused_binaries() {
|
||||
log::warn!("Cleanup after sync failed: {e}");
|
||||
} else {
|
||||
log::debug!("Cleanup after sync completed successfully");
|
||||
// Sync all profiles in parallel
|
||||
let mut sync_set = tokio::task::JoinSet::new();
|
||||
for profile_id in to_sync {
|
||||
let app = app_handle.clone();
|
||||
let in_flight = self.in_flight_profiles.clone();
|
||||
sync_set.spawn(async move {
|
||||
log::info!("Executing queued sync for profile {}", profile_id);
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
serde_json::json!({
|
||||
"profile_id": profile_id,
|
||||
"status": "syncing"
|
||||
}),
|
||||
);
|
||||
|
||||
let profile_to_sync = {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager.list_profiles().ok().and_then(|profiles| {
|
||||
profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id && p.is_sync_enabled() && !p.is_cross_os())
|
||||
})
|
||||
};
|
||||
|
||||
let Some(profile) = profile_to_sync else {
|
||||
let mut inf = in_flight.lock().await;
|
||||
inf.remove(&profile_id);
|
||||
return;
|
||||
};
|
||||
|
||||
let result = match SyncEngine::create_from_settings(&app).await {
|
||||
Ok(engine) => engine.sync_profile(&app, &profile).await,
|
||||
Err(e) => {
|
||||
log::error!("Failed to create sync engine: {}", e);
|
||||
Err(super::types::SyncError::NotConfigured)
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut inf = in_flight.lock().await;
|
||||
inf.remove(&profile_id);
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
log::info!("Profile {} synced successfully", profile_id);
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
serde_json::json!({
|
||||
"profile_id": profile_id,
|
||||
"status": "synced"
|
||||
}),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to sync profile {}: {}", profile_id, e);
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
serde_json::json!({
|
||||
"profile_id": profile_id,
|
||||
"status": "error",
|
||||
"error": e.to_string()
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for all parallel syncs to finish
|
||||
while let Some(result) = sync_set.join_next().await {
|
||||
if let Err(e) = result {
|
||||
log::error!("Profile sync task panicked: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger cleanup if everything is done
|
||||
let all_done = {
|
||||
let in_flight = self.in_flight_profiles.lock().await;
|
||||
in_flight.is_empty()
|
||||
&& self.pending_profiles.lock().await.is_empty()
|
||||
&& self.pending_proxies.lock().await.is_empty()
|
||||
&& self.pending_groups.lock().await.is_empty()
|
||||
&& self.pending_vpns.lock().await.is_empty()
|
||||
&& self.pending_extensions.lock().await.is_empty()
|
||||
&& self.pending_extension_groups.lock().await.is_empty()
|
||||
};
|
||||
if all_done {
|
||||
log::debug!("All profile syncs completed, triggering cleanup");
|
||||
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
if let Err(e) = registry.cleanup_unused_binaries() {
|
||||
log::warn!("Cleanup after sync failed: {e}");
|
||||
} else {
|
||||
log::debug!("Cleanup after sync completed successfully");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -514,6 +514,15 @@ impl WayfernManager {
|
||||
args.push(format!("--load-extension={}", extension_paths.join(",")));
|
||||
}
|
||||
|
||||
// Pass wayfern token as CLI flag so the browser can gate CDP features
|
||||
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||
if let Some(ref token) = wayfern_token {
|
||||
args.push(format!("--wayfern-token={token}"));
|
||||
log::info!("Wayfern token passed as CLI flag (length: {})", token.len());
|
||||
} else {
|
||||
log::warn!("No wayfern token available — CDP gated methods will be blocked");
|
||||
}
|
||||
|
||||
// Don't add URL to args - we'll navigate via CDP after setting fingerprint
|
||||
// This ensures fingerprint is applied at navigation commit time
|
||||
|
||||
@@ -674,25 +683,6 @@ impl WayfernManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Close the debugging port to prevent localhost port-scan detection.
|
||||
// Reopen on a random high port after 5s so we can still manage the browser.
|
||||
let reopen_port = port; // Reopen on same port for find_wayfern_by_profile recovery
|
||||
if let Some(target) = page_targets.first() {
|
||||
if let Some(ws_url) = &target.websocket_debugger_url {
|
||||
match self
|
||||
.send_cdp_command(
|
||||
ws_url,
|
||||
"Wayfern.closeDebuggingPort",
|
||||
json!({ "reopenPort": reopen_port, "reopenDelayMs": 30000 }),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => log::info!("Closed debugging port, will reopen on {reopen_port} after 30s"),
|
||||
Err(e) => log::warn!("Failed to close debugging port: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let instance = WayfernInstance {
|
||||
id: id.clone(),
|
||||
|
||||
Reference in New Issue
Block a user