refactor: cleanup

This commit is contained in:
zhom
2026-03-09 14:21:43 +04:00
parent a8be96d28e
commit 43ee6856f9
47 changed files with 5619 additions and 1535 deletions
+38 -9
View File
@@ -1124,18 +1124,47 @@ impl ApiClient {
log::info!("Fetching Wayfern version from https://donutbrowser.com/wayfern.json");
let url = "https://donutbrowser.com/wayfern.json";
let response = self
.client
.get(url)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?;
let mut last_err = None;
let mut version_info: Option<WayfernVersionInfo> = None;
if !response.status().is_success() {
return Err(format!("Failed to fetch Wayfern version: {}", response.status()).into());
for attempt in 1..=3 {
match self
.client
.get(url)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await
{
Ok(response) => {
if !response.status().is_success() {
last_err = Some(format!("HTTP {}", response.status()));
} else {
match response.json::<WayfernVersionInfo>().await {
Ok(info) => {
version_info = Some(info);
break;
}
Err(e) => last_err = Some(format!("Failed to parse response: {e}")),
}
}
}
Err(e) => {
log::warn!("Wayfern fetch attempt {attempt}/3 failed: {e}");
last_err = Some(e.to_string());
}
}
if attempt < 3 {
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
}
let version_info: WayfernVersionInfo = response.json().await?;
let version_info = version_info.ok_or_else(|| {
format!(
"Failed to fetch Wayfern version after 3 attempts: {}",
last_err.unwrap_or_default()
)
})?;
log::info!("Fetched Wayfern version: {}", version_info.version);
// Cache the results (unless bypassing cache)
+53 -1
View File
@@ -315,6 +315,7 @@ impl ApiServer {
.routes(routes!(download_browser_api))
.routes(routes!(get_browser_versions))
.routes(routes!(check_browser_downloaded))
.routes(routes!(get_wayfern_token, refresh_wayfern_token))
.split_for_parts();
let api = ApiDoc::openapi();
@@ -333,7 +334,7 @@ impl ApiServer {
.with_state(ws_state);
let app = Router::new()
.nest("/v1", v1_routes)
.merge(v1_routes)
.nest("/ws", ws_routes)
.route("/openapi.json", get(move || async move { Json(api) }))
.layer(CorsLayer::permissive())
@@ -1501,3 +1502,54 @@ async fn check_browser_downloaded(
let is_downloaded = crate::downloaded_browsers_registry::is_browser_downloaded(browser, version);
Ok(Json(is_downloaded))
}
// API Handlers - Wayfern Token
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct WayfernTokenResponse {
pub token: Option<String>,
}
#[utoipa::path(
get,
path = "/v1/wayfern-token",
responses(
(status = 200, description = "Current wayfern token", body = WayfernTokenResponse),
(status = 401, description = "Unauthorized"),
),
security(
("bearer_auth" = [])
),
tag = "wayfern"
)]
async fn get_wayfern_token(
State(_state): State<ApiServerState>,
) -> Result<Json<WayfernTokenResponse>, StatusCode> {
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
Ok(Json(WayfernTokenResponse { token }))
}
#[utoipa::path(
post,
path = "/v1/wayfern-token/refresh",
responses(
(status = 200, description = "Refreshed wayfern token", body = WayfernTokenResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Failed to refresh token"),
),
security(
("bearer_auth" = [])
),
tag = "wayfern"
)]
async fn refresh_wayfern_token(
State(_state): State<ApiServerState>,
) -> Result<Json<WayfernTokenResponse>, (StatusCode, String)> {
crate::cloud_auth::CLOUD_AUTH
.request_wayfern_token()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
Ok(Json(WayfernTokenResponse { token }))
}
+5
View File
@@ -66,6 +66,10 @@ pub fn proxies_dir() -> PathBuf {
data_dir().join("proxies")
}
pub fn proxy_workers_dir() -> PathBuf {
cache_dir().join("proxy_workers")
}
pub fn vpn_dir() -> PathBuf {
data_dir().join("vpn")
}
@@ -155,6 +159,7 @@ mod tests {
assert!(data_subdir().ends_with("data"));
assert!(settings_dir().ends_with("settings"));
assert!(proxies_dir().ends_with("proxies"));
assert!(proxy_workers_dir().ends_with("proxy_workers"));
assert!(vpn_dir().ends_with("vpn"));
assert!(extensions_dir().ends_with("extensions"));
}
+103 -27
View File
@@ -81,24 +81,25 @@ impl AutoUpdater {
}
for (browser, profiles) in browser_profiles {
// Get cached versions first, then try to fetch if needed
let versions = if let Some(cached) = self
// Always fetch fresh versions for update checks — stale cache would miss new releases
let versions = match self
.browser_version_manager
.get_cached_browser_versions_detailed(&browser)
.fetch_browser_versions_detailed(&browser, false)
.await
{
cached
} else if self.browser_version_manager.should_update_cache(&browser) {
// Try to fetch fresh versions
match self
.browser_version_manager
.fetch_browser_versions_detailed(&browser, false)
.await
{
Ok(versions) => versions,
Err(_) => continue, // Skip this browser if fetch fails
Ok(versions) => versions,
Err(e) => {
log::warn!("Failed to fetch versions for {browser}: {e}, trying cache");
// Fall back to cache if network fails
if let Some(cached) = self
.browser_version_manager
.get_cached_browser_versions_detailed(&browser)
{
cached
} else {
continue;
}
}
} else {
continue; // No cached versions and cache doesn't need update
};
browser_versions.insert(browser.clone(), versions.clone());
@@ -108,20 +109,29 @@ impl AutoUpdater {
if let Some(update) = self.check_profile_update(&profile, &versions)? {
// Apply chromium threshold logic
if browser == "chromium" {
// For chromium, only show notifications if there are 400+ new versions
let current_version = &profile.version.parse::<u32>().unwrap();
let new_version = &update.new_version.parse::<u32>().unwrap();
// For chromium, only show notifications if there's a significant version jump
// Compare the major version component (first number before the dot)
let current_major: u32 = profile
.version
.split('.')
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let new_major: u32 = update
.new_version
.split('.')
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let result = new_version - current_version;
let result = new_major.saturating_sub(current_major);
log::info!(
"Current version: {current_version}, New version: {new_version}, Result: {result}"
"Current major version: {current_major}, New major version: {new_major}, Diff: {result}"
);
if result > 400 {
if result > 0 {
notifications.push(update);
} else {
log::info!(
"Skipping chromium update notification: only {result} new versions (need 400+)"
);
log::info!("Skipping chromium update notification: same major version");
}
} else {
notifications.push(update);
@@ -136,15 +146,52 @@ impl AutoUpdater {
pub async fn check_for_updates_with_progress(&self, app_handle: &tauri::AppHandle) {
log::info!("Starting auto-update check with progress...");
// Check if auto-updates are disabled in settings
let auto_download = {
let disable = self
.settings_manager
.load_settings()
.map(|s| s.disable_auto_updates)
.unwrap_or(false);
!disable && !cfg!(target_os = "linux")
};
// Check for browser updates and trigger auto-downloads
match self.check_for_updates().await {
Ok(update_notifications) => {
if !update_notifications.is_empty() {
log::info!(
"Found {} browser updates to auto-download",
update_notifications.len()
"Found {} browser updates (auto_download={})",
update_notifications.len(),
auto_download
);
if !auto_download {
// Emit notification events instead of downloading
for notification in update_notifications {
let update_event = serde_json::json!({
"browser": notification.browser,
"new_version": notification.new_version,
"current_version": notification.current_version,
"affected_profiles": notification.affected_profiles
});
if let Err(e) = events::emit("browser-update-available", &update_event) {
log::error!(
"Failed to emit update-available event for {}: {e}",
notification.browser
);
} else {
log::info!(
"Emitted update-available event for {} {}",
notification.browser,
notification.new_version
);
}
}
return;
}
// Trigger automatic downloads for each update
for notification in update_notifications {
log::info!(
@@ -323,7 +370,36 @@ impl AutoUpdater {
// Check if profile is currently running
if profile.process_id.is_some() {
continue; // Skip running profiles
// Store as pending update so it gets applied when browser closes
log::info!(
"Profile {} is running, storing pending update {} -> {}",
profile.name,
profile.version,
new_version
);
let mut state = self.load_auto_update_state().unwrap_or_default();
let notification = UpdateNotification {
id: format!("{}_{}_to_{}", browser, profile.version, new_version),
browser: browser.to_string(),
current_version: profile.version.clone(),
new_version: new_version.to_string(),
affected_profiles: vec![profile.name.clone()],
is_stable_update: true,
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
};
// Add if not already pending
if !state
.pending_updates
.iter()
.any(|u| u.id == notification.id)
{
state.pending_updates.push(notification);
let _ = self.save_auto_update_state(&state);
}
continue;
}
// Check if this is an update (newer version)
+44 -13
View File
@@ -39,13 +39,23 @@ impl BrowserRunner {
}
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
/// then resolve the proxy settings.
async fn resolve_proxy_with_refresh(&self, proxy_id: Option<&String>) -> Option<ProxySettings> {
/// then resolve the proxy settings with profile-specific sid for sticky sessions.
async fn resolve_proxy_with_refresh(
&self,
proxy_id: Option<&String>,
profile_id: Option<&str>,
) -> Option<ProxySettings> {
let proxy_id = proxy_id?;
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;
}
// 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);
}
}
PROXY_MANAGER.get_proxy_settings_by_id(proxy_id)
}
@@ -106,7 +116,7 @@ impl BrowserRunner {
// Always start a local proxy for Camoufox (for traffic monitoring and geoip support)
// Refresh cloud proxy credentials if needed before resolving
let mut upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref())
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
@@ -364,7 +374,7 @@ impl BrowserRunner {
// Always start a local proxy for Wayfern (for traffic monitoring and geoip support)
// Refresh cloud proxy credentials if needed before resolving
let mut upstream_proxy = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref())
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
@@ -521,6 +531,7 @@ impl BrowserRunner {
proxy_url,
profile.ephemeral,
&extension_paths,
remote_debugging_port,
)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
@@ -622,7 +633,7 @@ impl BrowserRunner {
// Refresh cloud proxy credentials if needed before resolving
let _stored_proxy_settings = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref())
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await;
// Use provided local proxy for Chromium-based browsers launch arguments
@@ -1077,10 +1088,10 @@ impl BrowserRunner {
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Always start a local proxy for API launches
// Determine upstream proxy if configured; otherwise use DIRECT
let upstream_proxy = profile
.proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
// 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;
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
@@ -2539,6 +2550,13 @@ pub async fn launch_browser_profile(
// Team lock check: if profile is sync-enabled and user is on a team, acquire lock
crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
// Notify sync scheduler that profile is now running
if let Some(scheduler) = crate::sync::get_global_scheduler() {
scheduler
.mark_profile_running(&profile.id.to_string())
.await;
}
let browser_runner = BrowserRunner::instance();
// Store the internal proxy settings for passing to launch_browser
@@ -2569,10 +2587,13 @@ pub async fn launch_browser_profile(
// This ensures all traffic goes through the local proxy for monitoring and future features
if profile.browser != "camoufox" && profile.browser != "wayfern" {
// Determine upstream proxy if configured; otherwise use DIRECT (no upstream)
let mut upstream_proxy = profile_for_launch
.proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
// Refresh cloud proxy credentials and inject profile-specific sid
let mut upstream_proxy = BrowserRunner::instance()
.resolve_proxy_with_refresh(
profile_for_launch.proxy_id.as_ref(),
Some(&profile_for_launch.id.to_string()),
)
.await;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
@@ -2746,6 +2767,16 @@ pub async fn kill_browser_profile(
// Release team lock if applicable
crate::team_lock::release_team_lock_if_needed(&profile).await;
// Notify sync scheduler that profile stopped and queue sync
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let pid = profile.id.to_string();
scheduler.mark_profile_stopped(&pid).await;
if profile.is_sync_enabled() {
log::info!("Profile '{}' killed, queuing sync", profile.name);
scheduler.queue_profile_sync(pid).await;
}
}
// Auto-update non-running profiles and cleanup unused binaries
let browser_for_update = profile.browser.clone();
let app_handle_for_update = app_handle.clone();
+4 -2
View File
@@ -557,9 +557,11 @@ impl CamoufoxManager {
/// Check if a Camoufox server is running with the given process ID
async fn is_server_running(&self, process_id: u32) -> bool {
// Check if the process is still running
use sysinfo::{Pid, System};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
let system = System::new_all();
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
if let Some(process) = system.process(Pid::from(process_id as usize)) {
// Check if this is actually a Camoufox process by looking at the command line
let cmd = process.cmd();
+283 -32
View File
@@ -81,6 +81,14 @@ struct SyncTokenResponse {
sync_token: String,
}
#[derive(Debug, Deserialize)]
struct WayfernTokenResponse {
token: String,
#[serde(rename = "expiresIn")]
#[allow(dead_code)]
expires_in: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocationItem {
pub code: String,
@@ -105,6 +113,7 @@ pub struct CloudAuthManager {
client: Client,
state: Mutex<Option<CloudAuthState>>,
refresh_lock: tokio::sync::Mutex<()>,
wayfern_token: Mutex<Option<String>>,
}
lazy_static! {
@@ -118,6 +127,7 @@ impl CloudAuthManager {
client: Client::new(),
state: Mutex::new(state),
refresh_lock: tokio::sync::Mutex::new(()),
wayfern_token: Mutex::new(None),
}
}
@@ -578,6 +588,9 @@ impl CloudAuthManager {
}
pub async fn logout(&self) -> Result<(), String> {
// Clear wayfern token
self.clear_wayfern_token().await;
// Disconnect team lock manager
crate::team_lock::TEAM_LOCK.disconnect().await;
@@ -666,7 +679,7 @@ impl CloudAuthManager {
/// API call with 401 retry: if first attempt gets 401, refresh access token and retry once.
/// Uses refresh_lock to prevent concurrent token rotations from racing.
async fn api_call_with_retry<F, Fut, T>(&self, make_request: F) -> Result<T, String>
pub async fn api_call_with_retry<F, Fut, T>(&self, make_request: F) -> Result<T, String>
where
F: Fn(String) -> Fut + Send,
Fut: std::future::Future<Output = Result<T, String>> + Send,
@@ -697,11 +710,12 @@ impl CloudAuthManager {
/// Fetch proxy configuration from the cloud backend
async fn fetch_proxy_config(&self) -> Result<Option<CloudProxyConfigResponse>, String> {
// Check cached user state for proxy bandwidth
// Check cached user state for proxy bandwidth (subscription or extra)
{
let state = self.state.lock().await;
match &*state {
Some(auth) if auth.user.proxy_bandwidth_limit_mb > 0 => {}
Some(auth)
if auth.user.proxy_bandwidth_limit_mb > 0 || auth.user.proxy_bandwidth_extra_mb > 0 => {}
_ => return Ok(None),
}
}
@@ -840,13 +854,13 @@ impl CloudAuthManager {
.await
}
/// Fetch state list for a country from the cloud backend
pub async fn fetch_states(&self, country: &str) -> Result<Vec<LocationItem>, String> {
/// Fetch region list for a country from the cloud backend
pub async fn fetch_regions(&self, country: &str) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
self
.api_call_with_retry(move |access_token| {
let url = format!(
"{CLOUD_API_URL}/api/proxy/locations/states?country={}",
"{CLOUD_API_URL}/api/proxy/locations/regions?country={}",
country
);
let client = reqwest::Client::new();
@@ -856,37 +870,40 @@ impl CloudAuthManager {
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch states: {e}"))?;
.map_err(|e| format!("Failed to fetch regions: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("States fetch failed ({status}): {body}"));
return Err(format!("Regions fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse states: {e}"))
.map_err(|e| format!("Failed to parse regions: {e}"))
}
})
.await
}
/// Fetch city list for a country+state from the cloud backend
/// Fetch city list for a country, optionally filtered by region
pub async fn fetch_cities(
&self,
country: &str,
state: &str,
region: Option<&str>,
) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
let state = state.to_string();
let region = region.map(|s| s.to_string());
self
.api_call_with_retry(move |access_token| {
let url = format!(
"{CLOUD_API_URL}/api/proxy/locations/cities?country={}&state={}",
country, state
let mut url = format!(
"{CLOUD_API_URL}/api/proxy/locations/cities?country={}",
country
);
if let Some(ref r) = region {
url.push_str(&format!("&region={}", r));
}
let client = reqwest::Client::new();
async move {
let response = client
@@ -911,8 +928,108 @@ impl CloudAuthManager {
.await
}
/// Fetch ISP list for a country, optionally filtered by region and city
pub async fn fetch_isps(
&self,
country: &str,
region: Option<&str>,
city: Option<&str>,
) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
let region = region.map(|s| s.to_string());
let city = city.map(|s| s.to_string());
self
.api_call_with_retry(move |access_token| {
let mut url = format!(
"{CLOUD_API_URL}/api/proxy/locations/isps?country={}",
country
);
if let Some(ref r) = region {
url.push_str(&format!("&region={}", r));
}
if let Some(ref c) = city {
url.push_str(&format!("&city={}", c));
}
let client = reqwest::Client::new();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch ISPs: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("ISPs fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse ISPs: {e}"))
}
})
.await
}
/// Request a wayfern token from the cloud API. Only succeeds for paid users.
pub async fn request_wayfern_token(&self) -> Result<(), String> {
if !self.has_active_paid_subscription().await {
self.clear_wayfern_token().await;
return Ok(());
}
let token = self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
let client = reqwest::Client::new();
async move {
let response = client
.post(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to request wayfern token: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Wayfern token request failed ({status}): {body}"));
}
let result: WayfernTokenResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse wayfern token response: {e}"))?;
Ok(result.token)
}
})
.await?;
let mut wt = self.wayfern_token.lock().await;
*wt = Some(token);
log::info!("Wayfern token acquired");
Ok(())
}
/// Get the current wayfern token, if any.
pub async fn get_wayfern_token(&self) -> Option<String> {
let wt = self.wayfern_token.lock().await;
wt.clone()
}
/// Clear the cached wayfern token.
pub async fn clear_wayfern_token(&self) {
let mut wt = self.wayfern_token.lock().await;
*wt = None;
}
/// Background loop that refreshes the sync token periodically
pub async fn start_sync_token_refresh_loop(app_handle: tauri::AppHandle) {
let mut wayfern_refresh_counter: u32 = 0;
loop {
tokio::time::sleep(std::time::Duration::from_secs(600)).await; // 10 minutes
@@ -920,6 +1037,8 @@ impl CloudAuthManager {
continue;
}
wayfern_refresh_counter += 1;
// Proactively refresh the access token if it's expired or expiring soon.
// This runs first so subsequent API calls use a fresh token.
if let Ok(Some(token)) = Self::load_access_token() {
@@ -961,6 +1080,18 @@ 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 {
wayfern_refresh_counter = 0;
if CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to refresh wayfern token: {e}");
}
} else {
CLOUD_AUTH.clear_wayfern_token().await;
}
}
let _ = &app_handle; // keep app_handle alive
}
}
@@ -996,6 +1127,11 @@ pub async fn cloud_verify_otp(
Ok(None) => log::warn!("Sync token not available despite active subscription"),
Err(e) => log::error!("Failed to pre-fetch sync token after login: {e}"),
}
// Request wayfern token for paid users
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token after login: {e}");
}
}
// Sync cloud proxy after login
@@ -1037,6 +1173,9 @@ pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
}
let _ = manager.remove_sync_token(&app_handle).await;
// Remove cloud-managed and cloud-derived proxies
crate::proxy_manager::PROXY_MANAGER.remove_cloud_proxies();
let _ = crate::events::emit_empty("cloud-auth-changed");
Ok(())
}
@@ -1046,33 +1185,59 @@ pub async fn cloud_has_active_subscription() -> Result<bool, String> {
Ok(CLOUD_AUTH.has_active_paid_subscription().await)
}
#[tauri::command]
pub async fn cloud_get_wayfern_token() -> Result<Option<String>, String> {
Ok(CLOUD_AUTH.get_wayfern_token().await)
}
#[tauri::command]
pub async fn cloud_refresh_wayfern_token() -> Result<Option<String>, String> {
CLOUD_AUTH.request_wayfern_token().await?;
Ok(CLOUD_AUTH.get_wayfern_token().await)
}
#[tauri::command]
pub async fn cloud_get_countries() -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_countries().await
}
#[tauri::command]
pub async fn cloud_get_states(country: String) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_states(&country).await
pub async fn cloud_get_regions(country: String) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_regions(&country).await
}
#[tauri::command]
pub async fn cloud_get_cities(country: String, state: String) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_cities(&country, &state).await
pub async fn cloud_get_cities(
country: String,
region: Option<String>,
) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_cities(&country, region.as_deref()).await
}
#[tauri::command]
pub async fn cloud_get_isps(
country: String,
region: Option<String>,
city: Option<String>,
) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH
.fetch_isps(&country, region.as_deref(), city.as_deref())
.await
}
#[tauri::command]
pub async fn create_cloud_location_proxy(
name: String,
country: String,
state: Option<String>,
region: Option<String>,
city: Option<String>,
isp: Option<String>,
) -> Result<crate::proxy_manager::StoredProxy, String> {
// If no cloud proxy exists yet, attempt to sync it first
if !PROXY_MANAGER.has_cloud_proxy() {
CLOUD_AUTH.sync_cloud_proxy().await;
}
PROXY_MANAGER.create_cloud_location_proxy(name, country, state, city)
PROXY_MANAGER.create_cloud_location_proxy(name, country, region, city, isp)
}
#[derive(Debug, Serialize)]
@@ -1080,22 +1245,108 @@ pub struct CloudProxyUsage {
pub used_mb: i64,
pub limit_mb: i64,
pub remaining_mb: i64,
pub recurring_limit_mb: i64,
pub extra_limit_mb: i64,
}
#[derive(Debug, Deserialize)]
struct ProxyUsageResponse {
#[serde(rename = "usedMb")]
used_mb: i64,
#[serde(rename = "limitMb")]
limit_mb: i64,
#[serde(rename = "remainingMb")]
remaining_mb: i64,
#[serde(rename = "recurringLimitMb", default)]
recurring_limit_mb: i64,
#[serde(rename = "extraLimitMb", default)]
extra_limit_mb: i64,
}
#[tauri::command]
pub async fn cloud_get_proxy_usage() -> Result<Option<CloudProxyUsage>, String> {
let state = CLOUD_AUTH.state.lock().await;
match &*state {
Some(auth) if auth.user.proxy_bandwidth_limit_mb > 0 => {
let used = auth.user.proxy_bandwidth_used_mb;
let limit = auth.user.proxy_bandwidth_limit_mb;
Ok(Some(CloudProxyUsage {
used_mb: used,
limit_mb: limit,
remaining_mb: (limit - used).max(0),
}))
let (has_proxy, cached_recurring, cached_extra) = {
let state = CLOUD_AUTH.state.lock().await;
match &*state {
Some(auth)
if auth.user.proxy_bandwidth_limit_mb > 0 || auth.user.proxy_bandwidth_extra_mb > 0 =>
{
(
true,
auth.user.proxy_bandwidth_limit_mb,
auth.user.proxy_bandwidth_extra_mb,
)
}
_ => return Ok(None),
}
};
if !has_proxy {
return Ok(None);
}
// Fetch live usage from the API
match CLOUD_AUTH
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/proxy/usage");
let client = reqwest::Client::new();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch proxy usage: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"Proxy usage API returned status {}",
response.status()
));
}
response
.json::<ProxyUsageResponse>()
.await
.map_err(|e| format!("Failed to parse proxy usage: {e}"))
}
})
.await
{
Ok(usage) => Ok(Some(CloudProxyUsage {
used_mb: usage.used_mb,
limit_mb: usage.limit_mb,
remaining_mb: usage.remaining_mb,
recurring_limit_mb: if usage.recurring_limit_mb > 0 {
usage.recurring_limit_mb
} else {
cached_recurring
},
extra_limit_mb: if usage.recurring_limit_mb > 0 {
usage.extra_limit_mb
} else {
cached_extra
},
})),
Err(e) => {
log::warn!("Failed to fetch live proxy usage, falling back to cached: {e}");
// Fallback to cached values
let state = CLOUD_AUTH.state.lock().await;
match &*state {
Some(auth) => {
let used = auth.user.proxy_bandwidth_used_mb;
let total = cached_recurring + cached_extra;
Ok(Some(CloudProxyUsage {
used_mb: used,
limit_mb: total,
remaining_mb: (total - used).max(0),
recurring_limit_mb: cached_recurring,
extra_limit_mb: cached_extra,
}))
}
_ => Ok(None),
}
}
_ => Ok(None),
}
}
+310 -1
View File
@@ -19,6 +19,14 @@ pub struct Extension {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub homepage_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -71,6 +79,166 @@ fn get_file_type(file_name: &str) -> Option<String> {
}
}
fn find_zip_start(data: &[u8]) -> usize {
for i in 0..data.len().saturating_sub(3) {
if data[i] == 0x50 && data[i + 1] == 0x4B && data[i + 2] == 0x03 && data[i + 3] == 0x04 {
return i;
}
}
0
}
#[allow(clippy::type_complexity)]
fn extract_manifest_metadata(
file_data: &[u8],
file_type: &str,
) -> (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
) {
let zip_start = if file_type == "crx" {
find_zip_start(file_data)
} else {
0
};
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
let mut archive = match zip::ZipArchive::new(cursor) {
Ok(a) => a,
Err(_) => return (None, None, None, None, None),
};
let manifest_content = if let Ok(mut file) = archive.by_name("manifest.json") {
let mut contents = String::new();
if std::io::Read::read_to_string(&mut file, &mut contents).is_ok() {
Some(contents)
} else {
None
}
} else {
None
};
let manifest_content = match manifest_content {
Some(c) => c,
None => return (None, None, None, None, None),
};
let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) {
Ok(v) => v,
Err(_) => return (None, None, None, None, None),
};
let name = manifest
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let version = manifest
.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = manifest
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let author = manifest
.get("author")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let homepage_url = manifest
.get("homepage_url")
.or_else(|| manifest.get("homepage"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
(name, version, description, author, homepage_url)
}
fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec<u8>, String)> {
let zip_start = if file_type == "crx" {
find_zip_start(file_data)
} else {
0
};
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
let mut archive = match zip::ZipArchive::new(cursor) {
Ok(a) => a,
Err(_) => return None,
};
let icon_path = {
let manifest_content = if let Ok(mut file) = archive.by_name("manifest.json") {
let mut contents = String::new();
if std::io::Read::read_to_string(&mut file, &mut contents).is_ok() {
Some(contents)
} else {
None
}
} else {
None
};
let manifest_content = manifest_content?;
let manifest: serde_json::Value = serde_json::from_str(&manifest_content).ok()?;
let mut best_path: Option<String> = None;
let mut best_size: u32 = 0;
if let Some(icons) = manifest.get("icons").and_then(|v| v.as_object()) {
for (size_str, path_val) in icons {
if let (Ok(size), Some(path)) = (size_str.parse::<u32>(), path_val.as_str()) {
if size > best_size {
best_size = size;
best_path = Some(path.to_string());
}
}
}
}
if best_path.is_none() {
for key in &["action", "browser_action"] {
if let Some(action) = manifest.get(*key) {
if let Some(icon) = action.get("default_icon") {
if let Some(path) = icon.as_str() {
best_path = Some(path.to_string());
} else if let Some(icons) = icon.as_object() {
for (size_str, path_val) in icons {
if let (Ok(size), Some(path)) = (size_str.parse::<u32>(), path_val.as_str()) {
if size > best_size {
best_size = size;
best_path = Some(path.to_string());
}
}
}
}
}
}
}
}
best_path
};
let icon_path = icon_path?;
let clean_path = icon_path.trim_start_matches('/');
let mut file = archive.by_name(clean_path).ok()?;
let mut data = Vec::new();
std::io::Read::read_to_end(&mut file, &mut data).ok()?;
let ext = clean_path
.rsplit('.')
.next()
.unwrap_or("png")
.to_lowercase();
Some((data, ext))
}
pub struct ExtensionManager;
impl ExtensionManager {
@@ -108,9 +276,18 @@ impl ExtensionManager {
let browser_compatibility = determine_browser_compatibility(&file_type);
let now = now_secs();
let (manifest_name, version, description, author, homepage_url) =
extract_manifest_metadata(&file_data, &file_type);
let final_name = if manifest_name.is_some() {
manifest_name.clone().unwrap_or(name)
} else {
name
};
let ext = Extension {
id: uuid::Uuid::new_v4().to_string(),
name,
name: final_name,
file_name: file_name.clone(),
file_type,
browser_compatibility,
@@ -118,12 +295,23 @@ impl ExtensionManager {
updated_at: now,
sync_enabled: crate::sync::is_sync_configured(),
last_sync: None,
version,
description,
author,
homepage_url,
};
let file_dir = self.get_file_dir(&ext.id);
fs::create_dir_all(&file_dir)?;
fs::write(file_dir.join(&file_name), &file_data)?;
if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&file_data, &ext.file_type) {
let icon_path = self
.get_extension_dir(&ext.id)
.join(format!("icon.{icon_ext}"));
let _ = fs::write(icon_path, icon_data);
}
let metadata_path = self.get_metadata_path(&ext.id);
let json = serde_json::to_string_pretty(&ext)?;
fs::write(metadata_path, json)?;
@@ -187,6 +375,7 @@ impl ExtensionManager {
) -> Result<Extension, Box<dyn std::error::Error>> {
let mut ext = self.get_extension(id)?;
let explicit_name_provided = name.is_some();
if let Some(new_name) = name {
ext.name = new_name;
}
@@ -206,6 +395,31 @@ impl ExtensionManager {
ext.file_name = new_file_name;
ext.file_type = new_file_type.clone();
ext.browser_compatibility = determine_browser_compatibility(&new_file_type);
let (manifest_name, version, description, author, homepage_url) =
extract_manifest_metadata(&data, &new_file_type);
if let Some(v) = version {
ext.version = Some(v);
}
if let Some(d) = description {
ext.description = Some(d);
}
if let Some(a) = author {
ext.author = Some(a);
}
if let Some(h) = homepage_url {
ext.homepage_url = Some(h);
}
if let Some(mn) = manifest_name {
if !explicit_name_provided {
ext.name = mn;
}
}
if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&data, &new_file_type) {
let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}"));
let _ = fs::write(icon_path, icon_data);
}
}
ext.updated_at = now_secs();
@@ -777,6 +991,95 @@ impl ExtensionManager {
let magic = [0x50, 0x4B, 0x03, 0x04];
data.windows(4).position(|window| window == magic)
}
pub fn ensure_icons_extracted(&self) {
let extensions = match self.list_extensions() {
Ok(exts) => exts,
Err(_) => return,
};
for ext in extensions {
let ext_dir = self.get_extension_dir(&ext.id);
let has_icon = ext_dir
.read_dir()
.map(|entries| {
entries
.filter_map(|e| e.ok())
.any(|e| e.file_name().to_string_lossy().starts_with("icon."))
})
.unwrap_or(false);
if has_icon {
continue;
}
let file_dir = self.get_file_dir(&ext.id);
let file_path = file_dir.join(&ext.file_name);
if let Ok(file_data) = fs::read(&file_path) {
if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&file_data, &ext.file_type) {
let icon_path = ext_dir.join(format!("icon.{icon_ext}"));
let _ = fs::write(icon_path, icon_data);
}
}
if ext.version.is_none() && ext.description.is_none() {
let file_path = file_dir.join(&ext.file_name);
if let Ok(file_data) = fs::read(&file_path) {
let (manifest_name, version, description, author, homepage_url) =
extract_manifest_metadata(&file_data, &ext.file_type);
if version.is_some()
|| description.is_some()
|| author.is_some()
|| homepage_url.is_some()
|| manifest_name.is_some()
{
let mut updated_ext = ext.clone();
if let Some(v) = version {
updated_ext.version = Some(v);
}
if let Some(d) = description {
updated_ext.description = Some(d);
}
if let Some(a) = author {
updated_ext.author = Some(a);
}
if let Some(h) = homepage_url {
updated_ext.homepage_url = Some(h);
}
let metadata_path = self.get_metadata_path(&ext.id);
if let Ok(json) = serde_json::to_string_pretty(&updated_ext) {
let _ = fs::write(metadata_path, json);
}
}
}
}
}
}
pub fn get_extension_icon(&self, ext_id: &str) -> Option<String> {
let ext_dir = self.get_extension_dir(ext_id);
let entries = ext_dir.read_dir().ok()?;
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("icon.") {
let icon_path = entry.path();
let data = fs::read(&icon_path).ok()?;
let ext = name.rsplit('.').next().unwrap_or("png");
let mime = match ext {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"svg" => "image/svg+xml",
"gif" => "image/gif",
"webp" => "image/webp",
_ => "image/png",
};
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
return Some(format!("data:{};base64,{}", mime, b64));
}
}
None
}
}
// Global instance
@@ -800,6 +1103,12 @@ pub async fn list_extensions() -> Result<Vec<Extension>, String> {
.map_err(|e| format!("Failed to list extensions: {e}"))
}
#[tauri::command]
pub fn get_extension_icon(extension_id: String) -> Option<String> {
let manager = crate::extension_manager::ExtensionManager::new();
manager.get_extension_icon(&extension_id)
}
#[tauri::command]
pub async fn add_extension(
name: String,
+206 -5
View File
@@ -117,8 +117,9 @@ use profile_importer::{detect_existing_profiles, import_browser_profile};
use extension_manager::{
add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group,
delete_extension, delete_extension_group, get_extension_group_for_profile, list_extension_groups,
list_extensions, remove_extension_from_group, update_extension, update_extension_group,
delete_extension, delete_extension_group, get_extension_group_for_profile, get_extension_icon,
list_extension_groups, list_extensions, remove_extension_from_group, update_extension,
update_extension_group,
};
use group_manager::{
@@ -303,7 +304,33 @@ async fn copy_profile_cookies(
{
return Err("Cookie copying requires an active Pro subscription".to_string());
}
cookie_manager::CookieManager::copy_cookies(&app_handle, request).await
let target_ids = request.target_profile_ids.clone();
let results = cookie_manager::CookieManager::copy_cookies(&app_handle, request).await?;
// Trigger sync for target profiles that have sync enabled
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let profile_manager = profile::manager::ProfileManager::instance();
if let Ok(profiles) = profile_manager.list_profiles() {
let sync_ids: Vec<String> = target_ids
.iter()
.filter(|tid| {
profiles
.iter()
.any(|p| p.id.to_string() == **tid && p.is_sync_enabled())
})
.cloned()
.collect();
if !sync_ids.is_empty() {
tauri::async_runtime::spawn(async move {
for id in sync_ids {
scheduler.queue_profile_sync(id).await;
}
});
}
}
}
Ok(results)
}
#[tauri::command]
@@ -318,7 +345,25 @@ async fn import_cookies_from_file(
{
return Err("Cookie import requires an active Pro subscription".to_string());
}
cookie_manager::CookieManager::import_cookies(&app_handle, &profile_id, &content).await
let result =
cookie_manager::CookieManager::import_cookies(&app_handle, &profile_id, &content).await?;
// Trigger sync for the profile if sync is enabled
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let profile_manager = profile::manager::ProfileManager::instance();
if let Ok(profiles) = profile_manager.list_profiles() {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
if profile.is_sync_enabled() {
let pid = profile_id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_profile_sync(pid).await;
});
}
}
}
}
Ok(result)
}
#[tauri::command]
@@ -756,6 +801,62 @@ async fn list_active_vpn_connections() -> Result<Vec<vpn::VpnStatus>, String> {
)
}
#[tauri::command]
async fn generate_sample_fingerprint(
app_handle: tauri::AppHandle,
browser: String,
version: String,
config_json: String,
) -> Result<String, String> {
let temp_profile = crate::profile::BrowserProfile {
id: uuid::Uuid::new_v4(),
name: "temp_fingerprint_gen".to_string(),
browser: browser.clone(),
version: version.clone(),
process_id: None,
proxy_id: None,
vpn_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
wayfern_config: None,
group_id: None,
tags: Vec::new(),
note: None,
sync_mode: crate::profile::types::SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: None,
ephemeral: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
};
if browser == "camoufox" {
let config: crate::camoufox_manager::CamoufoxConfig =
serde_json::from_str(&config_json).map_err(|e| format!("Failed to parse config: {e}"))?;
let manager = crate::camoufox_manager::CamoufoxManager::instance();
manager
.generate_fingerprint_config(&app_handle, &temp_profile, &config)
.await
.map_err(|e| format!("Failed to generate fingerprint: {e}"))
} else if browser == "wayfern" {
let config: crate::wayfern_manager::WayfernConfig =
serde_json::from_str(&config_json).map_err(|e| format!("Failed to parse config: {e}"))?;
let manager = crate::wayfern_manager::WayfernManager::instance();
manager
.generate_fingerprint_config(&app_handle, &temp_profile, &config)
.await
.map_err(|e| format!("Failed to generate fingerprint: {e}"))
} else {
Err(format!(
"Unsupported browser for fingerprint generation: {browser}"
))
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let args: Vec<String> = env::args().collect();
@@ -818,6 +919,12 @@ pub fn run() {
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
ephemeral_dirs::recover_ephemeral_dirs();
// Extract icons and metadata for existing extensions that don't have them yet
{
let mgr = extension_manager::ExtensionManager::new();
mgr.ensure_icons_extracted();
}
// Start the daemon for tray icon
if let Err(e) = daemon_spawn::ensure_daemon_running() {
log::warn!("Failed to start daemon: {e}");
@@ -961,6 +1068,71 @@ pub fn run() {
version_updater::VersionUpdater::run_background_task().await;
});
// TODO(v0.17+): Remove this migration block after a few releases.
// Migrate proxy/VPN worker configs from old proxies/ dir to new proxy_workers/ cache dir.
// Before v0.16, ephemeral worker configs (proxy_*, vpnw_*) lived alongside persistent
// StoredProxy files in proxies/. Now they live in cache_dir/proxy_workers/.
{
let old_dir = crate::app_dirs::proxies_dir();
let new_dir = crate::app_dirs::proxy_workers_dir();
if old_dir.exists() {
if let Err(e) = std::fs::create_dir_all(&new_dir) {
log::error!("Failed to create proxy_workers dir: {e}");
} else if let Ok(entries) = std::fs::read_dir(&old_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if (name.starts_with("proxy_") || name.starts_with("vpnw_"))
&& name.ends_with(".json")
{
let dest = new_dir.join(name);
match std::fs::rename(&path, &dest) {
Ok(()) => log::info!("Migrated worker config {name} to proxy_workers/"),
Err(e) => {
// rename fails across filesystems, fall back to copy+delete
if let Ok(content) = std::fs::read(&path) {
if std::fs::write(&dest, &content).is_ok() {
let _ = std::fs::remove_file(&path);
log::info!("Migrated worker config {name} to proxy_workers/ (copy)");
}
} else {
log::warn!("Failed to migrate worker config {name}: {e}");
}
}
}
}
}
}
}
}
}
// Clear stale process IDs from profiles (processes that died while app was closed)
{
let profile_manager = crate::profile::ProfileManager::instance();
if let Ok(profiles) = profile_manager.list_profiles() {
let system = sysinfo::System::new_with_specifics(
sysinfo::RefreshKind::nothing()
.with_processes(sysinfo::ProcessRefreshKind::everything()),
);
for profile in profiles {
if let Some(pid) = profile.process_id {
let sysinfo_pid = sysinfo::Pid::from_u32(pid);
if system.process(sysinfo_pid).is_none() {
log::info!(
"Clearing stale process_id {} for profile {}",
pid,
profile.name
);
let mut updated = profile.clone();
updated.process_id = None;
let _ = profile_manager.save_profile(&updated);
}
}
}
}
}
let app_handle_auto_updater = app.handle().clone();
// Start the auto-update check task separately
@@ -1187,6 +1359,20 @@ pub fn run() {
);
}
// Notify sync scheduler of running state changes
if let Some(scheduler) = sync::get_global_scheduler() {
if is_running {
scheduler.mark_profile_running(&profile_id).await;
} else {
scheduler.mark_profile_stopped(&profile_id).await;
// Queue sync after profile stops (if sync is enabled)
if profile.is_sync_enabled() {
log::info!("Profile '{}' stopped, queuing sync", profile.name);
scheduler.queue_profile_sync(profile_id.clone()).await;
}
}
}
last_running_states.insert(profile_id, is_running);
} else {
// Update the state even if unchanged to ensure we have it tracked
@@ -1314,6 +1500,13 @@ pub fn run() {
log::warn!("Failed to refresh cloud sync token on startup: {e}");
}
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
// Request wayfern token on startup for paid users
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token on startup: {e}");
}
}
}
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
});
@@ -1386,6 +1579,7 @@ pub fn run() {
import_proxies_from_parsed,
update_camoufox_config,
update_wayfern_config,
generate_sample_fingerprint,
get_profile_groups,
get_groups_with_profile_counts,
create_profile_group,
@@ -1394,6 +1588,7 @@ pub fn run() {
assign_profiles_to_group,
delete_selected_profiles,
list_extensions,
get_extension_icon,
add_extension,
update_extension,
delete_extension,
@@ -1464,10 +1659,13 @@ pub fn run() {
cloud_auth::cloud_logout,
cloud_auth::cloud_get_proxy_usage,
cloud_auth::cloud_get_countries,
cloud_auth::cloud_get_states,
cloud_auth::cloud_get_regions,
cloud_auth::cloud_get_cities,
cloud_auth::cloud_get_isps,
cloud_auth::create_cloud_location_proxy,
cloud_auth::restart_sync_service,
cloud_auth::cloud_get_wayfern_token,
cloud_auth::cloud_refresh_wayfern_token,
// Team lock commands
team_lock::get_team_locks,
team_lock::get_team_lock_status,
@@ -1514,6 +1712,9 @@ mod tests {
"set_extension_sync_enabled",
"set_extension_group_sync_enabled",
"get_team_lock_status",
"generate_sample_fingerprint",
"cloud_get_wayfern_token",
"cloud_refresh_wayfern_token",
];
// Extract command names from the generate_handler! macro in this file
File diff suppressed because it is too large Load Diff
+50 -1
View File
@@ -42,7 +42,7 @@ impl ProxyConfig {
}
pub fn get_storage_dir() -> PathBuf {
crate::app_dirs::proxies_dir()
crate::app_dirs::proxy_workers_dir()
}
pub fn save_proxy_config(config: &ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
@@ -137,3 +137,52 @@ pub fn is_process_running(pid: u32) -> bool {
);
system.process(sysinfo::Pid::from_u32(pid)).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_process_running_detects_current_process() {
let pid = std::process::id();
assert!(
is_process_running(pid),
"is_process_running must detect the current process (PID {pid})"
);
}
#[test]
fn test_is_process_running_returns_false_for_dead_pid() {
// Spawn a short-lived child and wait for it to exit
let child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
.args(if cfg!(windows) {
vec!["/C", "exit"]
} else {
vec![]
})
.spawn()
.expect("failed to spawn child");
let pid = child.id();
let mut child = child;
child.wait().expect("child failed");
assert!(
!is_process_running(pid),
"is_process_running must return false for a dead process (PID {pid})"
);
}
#[test]
fn test_is_process_running_returns_false_for_nonexistent_pid() {
// PID 0 is not a valid user process on any supported platform
assert!(
!is_process_running(0),
"is_process_running must return false for PID 0"
);
// Very high PID unlikely to exist
assert!(
!is_process_running(u32::MAX),
"is_process_running must return false for PID u32::MAX"
);
}
}
+4
View File
@@ -55,6 +55,8 @@ pub struct AppSettings {
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default
#[serde(default)]
pub window_resize_warning_dismissed: bool,
#[serde(default)]
pub disable_auto_updates: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -89,6 +91,7 @@ impl Default for AppSettings {
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
disable_auto_updates: false,
}
}
}
@@ -1020,6 +1023,7 @@ mod tests {
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
disable_auto_updates: false,
};
let save_result = manager.save_settings(&test_settings);
+64 -43
View File
@@ -210,63 +210,84 @@ impl SyncClient {
&self,
items: Vec<(String, Option<String>)>,
) -> SyncResult<PresignUploadBatchResponse> {
let request = PresignUploadBatchRequest {
items: items
.into_iter()
.map(|(key, content_type)| PresignUploadBatchItem { key, content_type })
.collect(),
expires_in: Some(3600),
};
let chunk_size = 500;
let mut all_items = Vec::new();
let response = self
.client
.post(self.url("presign-upload-batch"))
.header("Authorization", format!("Bearer {}", self.token))
.json(&request)
.send()
.await
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
for chunk in items.chunks(chunk_size) {
let request = PresignUploadBatchRequest {
items: chunk
.iter()
.map(|(key, content_type)| PresignUploadBatchItem {
key: key.clone(),
content_type: content_type.clone(),
})
.collect(),
expires_in: Some(3600),
};
if response.status().is_client_error() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SyncError::AuthError(format!("({status}) {body}")));
let response = self
.client
.post(self.url("presign-upload-batch"))
.header("Authorization", format!("Bearer {}", self.token))
.json(&request)
.send()
.await
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
if response.status().is_client_error() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SyncError::AuthError(format!("({status}) {body}")));
}
let batch_response: PresignUploadBatchResponse = response
.json()
.await
.map_err(|e| SyncError::SerializationError(e.to_string()))?;
all_items.extend(batch_response.items);
}
response
.json()
.await
.map_err(|e| SyncError::SerializationError(e.to_string()))
Ok(PresignUploadBatchResponse { items: all_items })
}
pub async fn presign_download_batch(
&self,
keys: Vec<String>,
) -> SyncResult<PresignDownloadBatchResponse> {
let request = PresignDownloadBatchRequest {
keys,
expires_in: Some(3600),
};
let chunk_size = 500;
let mut all_items = Vec::new();
let response = self
.client
.post(self.url("presign-download-batch"))
.header("Authorization", format!("Bearer {}", self.token))
.json(&request)
.send()
.await
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
for chunk in keys.chunks(chunk_size) {
let request = PresignDownloadBatchRequest {
keys: chunk.to_vec(),
expires_in: Some(3600),
};
if response.status().is_client_error() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SyncError::AuthError(format!("({status}) {body}")));
let response = self
.client
.post(self.url("presign-download-batch"))
.header("Authorization", format!("Bearer {}", self.token))
.json(&request)
.send()
.await
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
if response.status().is_client_error() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SyncError::AuthError(format!("({status}) {body}")));
}
let batch_response: PresignDownloadBatchResponse = response
.json()
.await
.map_err(|e| SyncError::SerializationError(e.to_string()))?;
all_items.extend(batch_response.items);
}
response
.json()
.await
.map_err(|e| SyncError::SerializationError(e.to_string()))
Ok(PresignDownloadBatchResponse { items: all_items })
}
pub async fn delete_prefix(
+609 -70
View File
@@ -7,11 +7,188 @@ use crate::profile::types::{BrowserProfile, SyncMode};
use crate::profile::ProfileManager;
use crate::settings_manager::SettingsManager;
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::Semaphore;
use std::time::Instant;
use tokio::sync::{Mutex as TokioMutex, Semaphore};
/// Upload/download concurrency limit
const SYNC_CONCURRENCY: usize = 32;
/// Max retries for individual file uploads/downloads
const MAX_FILE_RETRIES: u32 = 3;
/// Critical file patterns — if any of these fail to upload/download, the sync is aborted.
const CRITICAL_FILE_PATTERNS: &[&str] = &[
"Cookies",
"Login Data",
"Local Storage",
"Local State",
"Preferences",
"Secure Preferences",
"Web Data",
"Extension Cookies",
// Firefox/Camoufox equivalents
"cookies.sqlite",
"key4.db",
"logins.json",
"cert9.db",
"places.sqlite",
"formhistory.sqlite",
"permissions.sqlite",
"prefs.js",
"storage.sqlite",
];
fn is_critical_file(path: &str) -> bool {
CRITICAL_FILE_PATTERNS
.iter()
.any(|pattern| path.contains(pattern))
}
/// Resume state persisted to disk so interrupted syncs can continue
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct SyncResumeState {
profile_id: String,
direction: String,
started_at: String,
completed_files: HashSet<String>,
}
impl SyncResumeState {
fn path(profile_dir: &Path) -> std::path::PathBuf {
profile_dir.join(".donut-sync").join("resume-state.json")
}
fn load(profile_dir: &Path) -> Option<Self> {
let path = Self::path(profile_dir);
let content = fs::read_to_string(&path).ok()?;
let state: Self = serde_json::from_str(&content).ok()?;
// Discard if older than 12 hours (presigned URLs expire in 1h but files may still be there)
if let Ok(started) = DateTime::parse_from_rfc3339(&state.started_at) {
let age = Utc::now() - started.with_timezone(&Utc);
if age.num_hours() > 12 {
let _ = fs::remove_file(&path);
return None;
}
}
Some(state)
}
fn save(&self, profile_dir: &Path) -> SyncResult<()> {
let path = Self::path(profile_dir);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| SyncError::IoError(format!("Failed to create resume state dir: {e}")))?;
}
let json = serde_json::to_string(self).map_err(|e| {
SyncError::SerializationError(format!("Failed to serialize resume state: {e}"))
})?;
fs::write(&path, json)
.map_err(|e| SyncError::IoError(format!("Failed to write resume state: {e}")))?;
Ok(())
}
fn delete(profile_dir: &Path) {
let path = Self::path(profile_dir);
let _ = fs::remove_file(&path);
}
}
/// Tracks live sync progress and emits throttled events to the frontend
struct SyncProgressTracker {
profile_id: String,
profile_name: String,
phase: String,
total_files: u64,
total_bytes: u64,
completed_files: AtomicU64,
completed_bytes: AtomicU64,
failed_count: AtomicU64,
start_time: Instant,
last_emit: TokioMutex<Instant>,
}
impl SyncProgressTracker {
fn new(
profile_id: String,
profile_name: String,
phase: &str,
total_files: u64,
total_bytes: u64,
) -> Self {
Self {
profile_id,
profile_name,
phase: phase.to_string(),
total_files,
total_bytes,
completed_files: AtomicU64::new(0),
completed_bytes: AtomicU64::new(0),
failed_count: AtomicU64::new(0),
start_time: Instant::now(),
last_emit: TokioMutex::new(Instant::now() - std::time::Duration::from_secs(1)),
}
}
fn record_success(&self, bytes: u64) {
self.completed_files.fetch_add(1, Ordering::Relaxed);
self.completed_bytes.fetch_add(bytes, Ordering::Relaxed);
self.maybe_emit();
}
fn record_failure(&self) {
self.completed_files.fetch_add(1, Ordering::Relaxed);
self.failed_count.fetch_add(1, Ordering::Relaxed);
self.maybe_emit();
}
fn maybe_emit(&self) {
let Ok(mut last) = self.last_emit.try_lock() else {
return;
};
if last.elapsed().as_millis() < 250 {
return;
}
*last = Instant::now();
self.emit_progress();
}
fn emit_final(&self) {
self.emit_progress();
}
fn emit_progress(&self) {
let completed_bytes = self.completed_bytes.load(Ordering::Relaxed);
let elapsed = self.start_time.elapsed().as_secs_f64().max(0.1);
let speed = (completed_bytes as f64 / elapsed) as u64;
let remaining_bytes = self.total_bytes.saturating_sub(completed_bytes);
let eta = if speed > 0 {
remaining_bytes / speed
} else {
0
};
let _ = events::emit(
"profile-sync-progress",
serde_json::json!({
"profile_id": self.profile_id,
"profile_name": self.profile_name,
"phase": self.phase,
"completed_files": self.completed_files.load(Ordering::Relaxed),
"total_files": self.total_files,
"completed_bytes": completed_bytes,
"total_bytes": self.total_bytes,
"speed_bytes_per_sec": speed,
"eta_seconds": eta,
"failed_count": self.failed_count.load(Ordering::Relaxed),
}),
);
}
}
/// Check if sync is configured (cloud or self-hosted)
pub fn is_sync_configured() -> bool {
@@ -108,6 +285,29 @@ impl SyncEngine {
return Ok(());
}
// Skip if profile is currently running locally
if profile.process_id.is_some() {
log::info!(
"Skipping sync for running profile: {} ({})",
profile.name,
profile.id
);
return Ok(());
}
// Skip if profile is locked by another team member
if crate::team_lock::TEAM_LOCK
.is_locked_by_another(&profile.id.to_string())
.await
{
log::info!(
"Skipping sync for profile locked by another team member: {} ({})",
profile.name,
profile.id
);
return Ok(());
}
// Derive encryption key if encrypted sync
let encryption_key = if profile.is_encrypted_sync() {
let password = encryption::load_e2e_password()
@@ -149,6 +349,7 @@ impl SyncEngine {
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"profile_name": profile.name,
"status": "syncing"
}),
);
@@ -202,6 +403,7 @@ impl SyncEngine {
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"profile_name": profile.name,
"status": "synced"
}),
);
@@ -228,6 +430,7 @@ impl SyncEngine {
"profile-sync-progress",
serde_json::json!({
"profile_id": profile_id,
"profile_name": profile.name,
"phase": "started",
"total_files": total_files,
"total_bytes": upload_bytes + download_bytes
@@ -240,6 +443,7 @@ impl SyncEngine {
.upload_profile_files(
app_handle,
&profile_id,
&profile.name,
&profile_dir,
&diff.files_to_upload,
encryption_key.as_ref(),
@@ -254,6 +458,7 @@ impl SyncEngine {
.download_profile_files(
app_handle,
&profile_id,
&profile.name,
&profile_dir,
&diff.files_to_download,
encryption_key.as_ref(),
@@ -290,6 +495,9 @@ impl SyncEngine {
.upload_manifest(&profile_id, &final_manifest, &key_prefix)
.await?;
// Sync completed successfully — clean up resume state
SyncResumeState::delete(&profile_dir);
// Sync associated proxy, group, and VPN
if let Some(proxy_id) = &profile.proxy_id {
let _ = self.sync_proxy(proxy_id, Some(app_handle)).await;
@@ -316,6 +524,7 @@ impl SyncEngine {
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"profile_name": profile.name,
"status": "synced"
}),
);
@@ -389,10 +598,12 @@ impl SyncEngine {
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn upload_profile_files(
&self,
_app_handle: &tauri::AppHandle,
profile_id: &str,
profile_name: &str,
profile_dir: &Path,
files: &[super::manifest::ManifestFileEntry],
encryption_key: Option<&[u8; 32]>,
@@ -402,10 +613,53 @@ impl SyncEngine {
return Ok(());
}
log::info!("Uploading {} files for profile {}", files.len(), profile_id);
// Load resume state to skip already-uploaded files
let mut resume_state = SyncResumeState::load(profile_dir)
.filter(|s| s.profile_id == profile_id && s.direction == "upload");
let already_done: HashSet<String> = resume_state
.as_ref()
.map(|s| s.completed_files.clone())
.unwrap_or_default();
let files_to_process: Vec<_> = files
.iter()
.filter(|f| !already_done.contains(&f.path))
.collect();
let skipped = files.len() - files_to_process.len();
if skipped > 0 {
log::info!(
"Resume: skipping {} already-uploaded files, processing {} remaining for profile {}",
skipped,
files_to_process.len(),
profile_id
);
}
log::info!(
"Uploading {} files for profile {}",
files_to_process.len(),
profile_id
);
if files_to_process.is_empty() {
return Ok(());
}
// Initialize resume state if not resuming
if resume_state.is_none() {
resume_state = Some(SyncResumeState {
profile_id: profile_id.to_string(),
direction: "upload".to_string(),
started_at: Utc::now().to_rfc3339(),
completed_files: HashSet::new(),
});
}
let resume_state = Arc::new(TokioMutex::new(resume_state.unwrap()));
// Get batch presigned URLs
let items: Vec<(String, Option<String>)> = files
let items: Vec<(String, Option<String>)> = files_to_process
.iter()
.map(|f| {
let key = format!("{}profiles/{}/files/{}", key_prefix, profile_id, f.path);
@@ -425,28 +679,70 @@ impl SyncEngine {
.map(|item| (item.key, item.url))
.collect();
// Upload with bounded concurrency
let semaphore = Arc::new(Semaphore::new(8));
let total_bytes: u64 = files.iter().map(|f| f.size).sum();
let already_bytes: u64 = files
.iter()
.filter(|f| already_done.contains(&f.path))
.map(|f| f.size)
.sum();
let tracker = Arc::new(SyncProgressTracker::new(
profile_id.to_string(),
profile_name.to_string(),
"uploading",
files.len() as u64,
total_bytes,
));
// Pre-populate tracker with resumed progress
tracker
.completed_files
.store(skipped as u64, Ordering::Relaxed);
tracker
.completed_bytes
.store(already_bytes, Ordering::Relaxed);
tracker.emit_final();
let semaphore = Arc::new(Semaphore::new(SYNC_CONCURRENCY));
let client = self.client.clone();
let profile_dir = profile_dir.to_path_buf();
let profile_id = profile_id.to_string();
let profile_id_owned = profile_id.to_string();
let enc_key = encryption_key.copied();
let mut handles = Vec::new();
type FileResult = Result<String, (String, String, bool)>;
let mut handles: Vec<tokio::task::JoinHandle<FileResult>> = Vec::new();
for file in files {
// Counter for batching resume state saves
let save_counter = Arc::new(AtomicU64::new(0));
for file in &files_to_process {
let sem = semaphore.clone();
let file_path = profile_dir.join(&file.path);
let remote_key = format!("{}profiles/{}/files/{}", key_prefix, profile_id, file.path);
let relative_path = file.path.clone();
let file_size = file.size;
let remote_key = format!(
"{}profiles/{}/files/{}",
key_prefix, profile_id_owned, file.path
);
let url = url_map.get(&remote_key).cloned();
let critical = is_critical_file(&file.path);
if url.is_none() {
log::warn!("No presigned URL for {}", remote_key);
if critical {
return Err(SyncError::NetworkError(format!(
"No presigned URL for critical file: {}",
file.path
)));
}
continue;
}
let url = url.unwrap();
let client = client.clone();
let tracker = tracker.clone();
let resume_state = resume_state.clone();
let save_counter = save_counter.clone();
let profile_dir_clone = profile_dir.clone();
let content_type = mime_guess::from_path(&file.path)
.first()
.map(|m| m.to_string());
@@ -456,9 +752,16 @@ impl SyncEngine {
let data = match fs::read(&file_path) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !critical => {
log::debug!("File disappeared, skipping: {}", file_path.display());
tracker.record_success(0);
return Ok(relative_path);
}
Err(e) => {
log::warn!("Failed to read {}: {}", file_path.display(), e);
return;
let msg = format!("Failed to read {}: {}", file_path.display(), e);
log::warn!("{}", msg);
tracker.record_failure();
return Err((relative_path, msg, critical));
}
};
@@ -466,44 +769,113 @@ impl SyncEngine {
match encryption::encrypt_bytes(key, &data) {
Ok(encrypted) => encrypted,
Err(e) => {
log::warn!("Failed to encrypt {}: {}", file_path.display(), e);
return;
let msg = format!("Failed to encrypt {}: {}", file_path.display(), e);
log::warn!("{}", msg);
tracker.record_failure();
return Err((relative_path, msg, critical));
}
}
} else {
data
};
if let Err(e) = client
.upload_bytes(&url, &upload_data, content_type.as_deref())
.await
{
log::warn!("Failed to upload {}: {}", file_path.display(), e);
// Retry loop for network uploads
let mut last_err = String::new();
for attempt in 0..MAX_FILE_RETRIES {
match client
.upload_bytes(&url, &upload_data, content_type.as_deref())
.await
{
Ok(()) => {
tracker.record_success(file_size);
// Record in resume state, save periodically
{
let mut state = resume_state.lock().await;
state.completed_files.insert(relative_path.clone());
let count = save_counter.fetch_add(1, Ordering::Relaxed);
if count.is_multiple_of(50) {
let _ = state.save(&profile_dir_clone);
}
}
return Ok(relative_path);
}
Err(e) => {
last_err = format!("{}", e);
if attempt < MAX_FILE_RETRIES - 1 {
log::debug!(
"Retry {}/{} for {}: {}",
attempt + 1,
MAX_FILE_RETRIES,
relative_path,
last_err
);
tokio::time::sleep(std::time::Duration::from_millis(500 * (attempt as u64 + 1)))
.await;
}
}
}
}
let msg = format!(
"Failed to upload {} after {} retries: {}",
relative_path, MAX_FILE_RETRIES, last_err
);
log::warn!("{}", msg);
tracker.record_failure();
Err((relative_path, msg, critical))
}));
}
// Collect results
let mut critical_failures = Vec::new();
let mut non_critical_failures = Vec::new();
for handle in handles {
let _ = handle.await;
match handle.await {
Ok(Ok(_)) => {}
Ok(Err((path, msg, true))) => critical_failures.push((path, msg)),
Ok(Err((path, msg, false))) => non_critical_failures.push((path, msg)),
Err(e) => {
log::warn!("Upload task panicked: {}", e);
}
}
}
let _ = events::emit(
"profile-sync-progress",
serde_json::json!({
"profile_id": profile_id,
"phase": "upload",
"done": files.len(),
"total": files.len()
}),
);
// Final resume state save
{
let state = resume_state.lock().await;
let _ = state.save(&profile_dir);
}
tracker.emit_final();
if !non_critical_failures.is_empty() {
log::warn!(
"Upload completed with {} non-critical failures for profile {}",
non_critical_failures.len(),
profile_id_owned
);
}
if !critical_failures.is_empty() {
let file_list: Vec<&str> = critical_failures.iter().map(|(p, _)| p.as_str()).collect();
return Err(SyncError::IoError(format!(
"Critical files failed to upload: {}. Sync aborted to prevent data loss.",
file_list.join(", ")
)));
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn download_profile_files(
&self,
_app_handle: &tauri::AppHandle,
profile_id: &str,
profile_name: &str,
profile_dir: &Path,
files: &[super::manifest::ManifestFileEntry],
encryption_key: Option<&[u8; 32]>,
@@ -513,14 +885,53 @@ impl SyncEngine {
return Ok(());
}
// Load resume state to skip already-downloaded files
let mut resume_state = SyncResumeState::load(profile_dir)
.filter(|s| s.profile_id == profile_id && s.direction == "download");
let already_done: HashSet<String> = resume_state
.as_ref()
.map(|s| s.completed_files.clone())
.unwrap_or_default();
let files_to_process: Vec<_> = files
.iter()
.filter(|f| !already_done.contains(&f.path))
.collect();
let skipped = files.len() - files_to_process.len();
if skipped > 0 {
log::info!(
"Resume: skipping {} already-downloaded files, processing {} remaining for profile {}",
skipped,
files_to_process.len(),
profile_id
);
}
log::info!(
"Downloading {} files for profile {}",
files.len(),
files_to_process.len(),
profile_id
);
if files_to_process.is_empty() {
return Ok(());
}
// Initialize resume state if not resuming
if resume_state.is_none() {
resume_state = Some(SyncResumeState {
profile_id: profile_id.to_string(),
direction: "download".to_string(),
started_at: Utc::now().to_rfc3339(),
completed_files: HashSet::new(),
});
}
let resume_state = Arc::new(TokioMutex::new(resume_state.unwrap()));
// Get batch presigned URLs
let keys: Vec<String> = files
let keys: Vec<String> = files_to_process
.iter()
.map(|f| format!("{}profiles/{}/files/{}", key_prefix, profile_id, f.path))
.collect();
@@ -534,73 +945,178 @@ impl SyncEngine {
.map(|item| (item.key, item.url))
.collect();
// Download with bounded concurrency
let semaphore = Arc::new(Semaphore::new(8));
let total_bytes: u64 = files.iter().map(|f| f.size).sum();
let already_bytes: u64 = files
.iter()
.filter(|f| already_done.contains(&f.path))
.map(|f| f.size)
.sum();
let tracker = Arc::new(SyncProgressTracker::new(
profile_id.to_string(),
profile_name.to_string(),
"downloading",
files.len() as u64,
total_bytes,
));
tracker
.completed_files
.store(skipped as u64, Ordering::Relaxed);
tracker
.completed_bytes
.store(already_bytes, Ordering::Relaxed);
tracker.emit_final();
let semaphore = Arc::new(Semaphore::new(SYNC_CONCURRENCY));
let client = self.client.clone();
let profile_dir = profile_dir.to_path_buf();
let profile_id = profile_id.to_string();
let profile_id_owned = profile_id.to_string();
let enc_key = encryption_key.copied();
let mut handles = Vec::new();
type FileResult = Result<String, (String, String, bool)>;
let mut handles: Vec<tokio::task::JoinHandle<FileResult>> = Vec::new();
for file in files {
let save_counter = Arc::new(AtomicU64::new(0));
for file in &files_to_process {
let sem = semaphore.clone();
let file_path = profile_dir.join(&file.path);
let remote_key = format!("{}profiles/{}/files/{}", key_prefix, profile_id, file.path);
let relative_path = file.path.clone();
let file_size = file.size;
let remote_key = format!(
"{}profiles/{}/files/{}",
key_prefix, profile_id_owned, file.path
);
let url = url_map.get(&remote_key).cloned();
let critical = is_critical_file(&file.path);
if url.is_none() {
log::warn!("No presigned URL for {}", remote_key);
if critical {
return Err(SyncError::NetworkError(format!(
"No presigned URL for critical file: {}",
file.path
)));
}
continue;
}
let url = url.unwrap();
let client = client.clone();
let tracker = tracker.clone();
let resume_state = resume_state.clone();
let save_counter = save_counter.clone();
let profile_dir_clone = profile_dir.clone();
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap();
match client.download_bytes(&url).await {
Ok(data) => {
let write_data = if let Some(ref key) = enc_key {
match encryption::decrypt_bytes(key, &data) {
Ok(decrypted) => decrypted,
Err(e) => {
log::warn!("Failed to decrypt {}, skipping: {}", remote_key, e);
return;
// Retry loop for network downloads
let mut last_err = String::new();
for attempt in 0..MAX_FILE_RETRIES {
match client.download_bytes(&url).await {
Ok(data) => {
let write_data = if let Some(ref key) = enc_key {
match encryption::decrypt_bytes(key, &data) {
Ok(decrypted) => decrypted,
Err(e) => {
let msg = format!("Failed to decrypt {}: {}", relative_path, e);
log::warn!("{}", msg);
tracker.record_failure();
return Err((relative_path, msg, critical));
}
}
} else {
data
};
if let Some(parent) = file_path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(e) = fs::write(&file_path, &write_data) {
let msg = format!("Failed to write {}: {}", file_path.display(), e);
log::warn!("{}", msg);
tracker.record_failure();
return Err((relative_path, msg, critical));
}
tracker.record_success(file_size);
{
let mut state = resume_state.lock().await;
state.completed_files.insert(relative_path.clone());
let count = save_counter.fetch_add(1, Ordering::Relaxed);
if count.is_multiple_of(50) {
let _ = state.save(&profile_dir_clone);
}
}
} else {
data
};
if let Some(parent) = file_path.parent() {
let _ = fs::create_dir_all(parent);
return Ok(relative_path);
}
if let Err(e) = fs::write(&file_path, &write_data) {
log::warn!("Failed to write {}: {}", file_path.display(), e);
Err(e) => {
last_err = format!("{}", e);
if attempt < MAX_FILE_RETRIES - 1 {
log::debug!(
"Retry {}/{} for {}: {}",
attempt + 1,
MAX_FILE_RETRIES,
relative_path,
last_err
);
tokio::time::sleep(std::time::Duration::from_millis(500 * (attempt as u64 + 1)))
.await;
}
}
}
Err(e) => {
log::warn!("Failed to download {}: {}", remote_key, e);
}
}
let msg = format!(
"Failed to download {} after {} retries: {}",
relative_path, MAX_FILE_RETRIES, last_err
);
log::warn!("{}", msg);
tracker.record_failure();
Err((relative_path, msg, critical))
}));
}
let mut critical_failures = Vec::new();
let mut non_critical_failures = Vec::new();
for handle in handles {
let _ = handle.await;
match handle.await {
Ok(Ok(_)) => {}
Ok(Err((path, msg, true))) => critical_failures.push((path, msg)),
Ok(Err((path, msg, false))) => non_critical_failures.push((path, msg)),
Err(e) => {
log::warn!("Download task panicked: {}", e);
}
}
}
let _ = events::emit(
"profile-sync-progress",
serde_json::json!({
"profile_id": profile_id,
"phase": "download",
"done": files.len(),
"total": files.len()
}),
);
// Final resume state save
{
let state = resume_state.lock().await;
let _ = state.save(&profile_dir);
}
tracker.emit_final();
if !non_critical_failures.is_empty() {
log::warn!(
"Download completed with {} non-critical failures for profile {}",
non_critical_failures.len(),
profile_id_owned
);
}
if !critical_failures.is_empty() {
let file_list: Vec<&str> = critical_failures.iter().map(|(p, _)| p.as_str()).collect();
return Err(SyncError::IoError(format!(
"Critical files failed to download: {}. Sync aborted to prevent data loss.",
file_list.join(", ")
)));
}
Ok(())
}
@@ -1531,6 +2047,7 @@ impl SyncEngine {
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"profile_name": profile.name,
"status": "synced"
}),
);
@@ -1599,6 +2116,7 @@ impl SyncEngine {
.download_profile_files(
app_handle,
profile_id,
&profile.name,
&profile_dir,
&manifest.files,
encryption_key.as_ref(),
@@ -1631,6 +2149,7 @@ impl SyncEngine {
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"profile_name": profile.name,
"status": "synced"
}),
);
@@ -2063,6 +2582,7 @@ pub async fn set_profile_sync_mode(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"profile_name": profile.name,
"status": "error",
"error": "Sync server not configured. Please configure sync settings first."
}),
@@ -2078,6 +2598,7 @@ pub async fn set_profile_sync_mode(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"profile_name": profile.name,
"status": "error",
"error": "Sync token not configured. Please configure sync settings first."
}),
@@ -2135,6 +2656,7 @@ pub async fn set_profile_sync_mode(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"profile_name": profile.name,
"status": if is_running { "waiting" } else { "syncing" }
}),
);
@@ -2197,6 +2719,7 @@ pub async fn set_profile_sync_mode(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"profile_name": profile.name,
"status": "disabled"
}),
);
@@ -2250,6 +2773,7 @@ pub async fn request_profile_sync(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"profile_name": profile.name,
"status": if is_running { "waiting" } else { "syncing" }
}),
);
@@ -2624,7 +3148,9 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
for proxy in &proxies {
if !proxy.sync_enabled && !proxy.is_cloud_managed {
set_proxy_sync_enabled(app_handle.clone(), proxy.id.clone(), true).await?;
if let Err(e) = set_proxy_sync_enabled(app_handle.clone(), proxy.id.clone(), true).await {
log::warn!("Failed to enable sync for proxy {}: {e}", proxy.id);
}
}
}
}
@@ -2638,7 +3164,9 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul
};
for group in &groups {
if !group.sync_enabled {
set_group_sync_enabled(app_handle.clone(), group.id.clone(), true).await?;
if let Err(e) = set_group_sync_enabled(app_handle.clone(), group.id.clone(), true).await {
log::warn!("Failed to enable sync for group {}: {e}", group.id);
}
}
}
}
@@ -2653,7 +3181,9 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul
};
for config in &configs {
if !config.sync_enabled {
set_vpn_sync_enabled(app_handle.clone(), config.id.clone(), true).await?;
if let Err(e) = set_vpn_sync_enabled(app_handle.clone(), config.id.clone(), true).await {
log::warn!("Failed to enable sync for VPN {}: {e}", config.id);
}
}
}
}
@@ -2667,7 +3197,9 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul
};
for ext in &exts {
if !ext.sync_enabled {
set_extension_sync_enabled(app_handle.clone(), ext.id.clone(), true).await?;
if let Err(e) = set_extension_sync_enabled(app_handle.clone(), ext.id.clone(), true).await {
log::warn!("Failed to enable sync for extension {}: {e}", ext.id);
}
}
}
}
@@ -2681,7 +3213,14 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul
};
for group in &groups {
if !group.sync_enabled {
set_extension_group_sync_enabled(app_handle.clone(), group.id.clone(), true).await?;
if let Err(e) =
set_extension_group_sync_enabled(app_handle.clone(), group.id.clone(), true).await
{
log::warn!(
"Failed to enable sync for extension group {}: {e}",
group.id
);
}
}
}
}
+91 -11
View File
@@ -9,24 +9,44 @@ use std::time::SystemTime;
use super::types::{SyncError, SyncResult};
/// Default exclude patterns for volatile Chromium profile files
/// Default exclude patterns for volatile browser profile files.
/// Patterns use `**/` prefix to match at any directory depth, since the sync
/// engine scans from `profiles/{uuid}/` which contains `profile/Default/...`.
pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"Cache/**",
"Code Cache/**",
"GPUCache/**",
"GrShaderCache/**",
"ShaderCache/**",
"Service Worker/CacheStorage/**",
"Crashpad/**",
"Crash Reports/**",
"BrowserMetrics/**",
"blob_storage/**",
// Chromium caches (re-downloadable / re-generated)
"**/Cache/**",
"**/Code Cache/**",
"**/GPUCache/**",
"**/GrShaderCache/**",
"**/ShaderCache/**",
"**/DawnCache/**",
"**/DawnGraphiteCache/**",
"**/Service Worker/CacheStorage/**",
"**/Service Worker/ScriptCache/**",
// Chromium transient / volatile data
"**/Session Storage/**",
"**/blob_storage/**",
"**/Crashpad/**",
"**/Crash Reports/**",
"**/BrowserMetrics/**",
"**/optimization_guide_model_store/**",
"**/Safe Browsing/**",
"**/component_crx_cache/**",
// Firefox/Camoufox caches (re-downloadable / re-generated)
"**/cache2/**",
"**/startupCache/**",
"**/safebrowsing/**",
"**/storage/temporary/**",
"**/crashes/**",
"**/minidumps/**",
// Common volatile files
"*.log",
"*.tmp",
"**/LOG",
"**/LOG.old",
"**/LOCK",
"**/*-journal",
"**/*-wal",
".donut-sync/**",
];
@@ -528,6 +548,66 @@ mod tests {
assert_eq!(manifest.files[0].path, "file1.txt");
}
#[test]
fn test_generate_manifest_excludes_nested_caches() {
let temp_dir = TempDir::new().unwrap();
let profile_dir = temp_dir.path().join("profile_root");
fs::create_dir_all(&profile_dir).unwrap();
// Simulate real Chromium structure: profile/Default/Cache/...
let default_dir = profile_dir.join("profile/Default");
fs::create_dir_all(&default_dir).unwrap();
fs::write(default_dir.join("Cookies"), "keep").unwrap();
fs::create_dir_all(default_dir.join("Cache")).unwrap();
fs::write(default_dir.join("Cache/data_0"), "exclude").unwrap();
fs::create_dir_all(default_dir.join("Code Cache/js")).unwrap();
fs::write(default_dir.join("Code Cache/js/abc"), "exclude").unwrap();
fs::create_dir_all(default_dir.join("GPUCache")).unwrap();
fs::write(default_dir.join("GPUCache/data_0"), "exclude").unwrap();
fs::create_dir_all(default_dir.join("Session Storage")).unwrap();
fs::write(default_dir.join("Session Storage/000003.log"), "exclude").unwrap();
fs::create_dir_all(default_dir.join("Local Storage/leveldb")).unwrap();
fs::write(default_dir.join("Local Storage/leveldb/000001.ldb"), "keep").unwrap();
// Caches at user-data-dir level
fs::create_dir_all(profile_dir.join("profile/ShaderCache")).unwrap();
fs::write(profile_dir.join("profile/ShaderCache/data"), "exclude").unwrap();
fs::create_dir_all(profile_dir.join("profile/Crashpad")).unwrap();
fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap();
// metadata.json at root
fs::write(profile_dir.join("metadata.json"), "keep").unwrap();
let mut cache = HashCache::default();
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect();
assert!(
paths.contains(&"metadata.json"),
"metadata.json should be synced"
);
assert!(
paths.contains(&"profile/Default/Cookies"),
"Cookies should be synced"
);
assert!(
paths.contains(&"profile/Default/Local Storage/leveldb/000001.ldb"),
"Local Storage should be synced"
);
assert!(
!paths.iter().any(|p| p.contains("Cache")),
"Cache directories should be excluded: {paths:?}"
);
assert!(
!paths.iter().any(|p| p.contains("Session Storage")),
"Session Storage should be excluded: {paths:?}"
);
assert!(
!paths.iter().any(|p| p.contains("Crashpad")),
"Crashpad should be excluded: {paths:?}"
);
}
#[test]
fn test_compute_diff_upload_all_when_no_remote() {
let local = SyncManifest {
+44 -6
View File
@@ -164,10 +164,24 @@ impl SyncScheduler {
let profile_manager = ProfileManager::instance();
if let Ok(profiles) = profile_manager.list_profiles() {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
return profile.process_id.is_some();
if profile.process_id.is_some() {
return true;
}
}
}
// Check if locked by another team member (profile in use remotely)
if crate::team_lock::TEAM_LOCK
.is_locked_by_another(profile_id)
.await
{
log::debug!(
"Profile {} is locked by another team member, treating as running",
profile_id
);
return true;
}
false
}
@@ -276,17 +290,38 @@ impl SyncScheduler {
for profile in sync_enabled_profiles {
let profile_id = profile.id.to_string();
let is_running = profile.process_id.is_some();
let is_team_locked = crate::team_lock::TEAM_LOCK
.is_locked_by_another(&profile_id)
.await;
let should_wait = is_running || is_team_locked;
// Track running state in the scheduler
if is_running {
self.mark_profile_running(&profile_id).await;
}
if should_wait {
log::info!(
"Profile '{}' is {} — will sync after it becomes available",
profile.name,
if is_running {
"running locally"
} else {
"locked by a team member"
}
);
}
// Emit initial status
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
"status": if is_running { "waiting" } else { "syncing" }
"status": if should_wait { "waiting" } else { "syncing" }
}),
);
// Queue for immediate sync (or wait if running)
// Queue for sync — running profiles will be deferred by the scheduler
self.queue_profile_sync_immediate(profile_id).await;
}
}
@@ -497,7 +532,8 @@ impl SyncScheduler {
"proxy-sync-status",
serde_json::json!({
"id": proxy_id,
"status": "error"
"status": "error",
"error": e.to_string()
}),
);
}
@@ -563,7 +599,8 @@ impl SyncScheduler {
"group-sync-status",
serde_json::json!({
"id": group_id,
"status": "error"
"status": "error",
"error": e.to_string()
}),
);
}
@@ -626,7 +663,8 @@ impl SyncScheduler {
"vpn-sync-status",
serde_json::json!({
"id": vpn_id,
"status": "error"
"status": "error",
"error": e.to_string()
}),
);
}
+27 -11
View File
@@ -311,12 +311,18 @@ impl WayfernManager {
"windows"
});
// Include wayfern token if available (enables cross-OS fingerprinting for paid users)
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
let mut refresh_params = json!({ "operatingSystem": os });
if let Some(ref token) = wayfern_token {
refresh_params
.as_object_mut()
.unwrap()
.insert("wayfernToken".to_string(), json!(token));
}
let refresh_result = self
.send_cdp_command(
&ws_url,
"Wayfern.refreshFingerprint",
json!({ "operatingSystem": os }),
)
.send_cdp_command(&ws_url, "Wayfern.refreshFingerprint", refresh_params)
.await;
if let Err(e) = refresh_result {
@@ -397,6 +403,7 @@ impl WayfernManager {
proxy_url: Option<&str>,
ephemeral: bool,
extension_paths: &[String],
remote_debugging_port: Option<u16>,
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
let executable_path = if let Some(path) = &config.executable_path {
let p = PathBuf::from(path);
@@ -414,7 +421,10 @@ impl WayfernManager {
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?
};
let port = Self::find_free_port().await?;
let port = match remote_debugging_port {
Some(p) => p,
None => Self::find_free_port().await?,
};
log::info!("Launching Wayfern on CDP port {port}");
let mut args = vec![
@@ -528,16 +538,21 @@ impl WayfernManager {
);
}
// Include wayfern token if available (enables cross-OS fingerprinting for paid users)
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
let mut fingerprint_params = fingerprint_for_cdp.clone();
if let Some(ref token) = wayfern_token {
if let Some(obj) = fingerprint_params.as_object_mut() {
obj.insert("wayfernToken".to_string(), json!(token));
}
}
for target in &page_targets {
if let Some(ws_url) = &target.websocket_debugger_url {
log::info!("Applying fingerprint to target via WebSocket: {}", ws_url);
// Wayfern.setFingerprint expects the fingerprint object directly, NOT wrapped
match self
.send_cdp_command(
ws_url,
"Wayfern.setFingerprint",
fingerprint_for_cdp.clone(),
)
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
.await
{
Ok(result) => log::info!(
@@ -840,6 +855,7 @@ impl WayfernManager {
proxy_url,
profile.ephemeral,
&[],
None,
)
.await
}