mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-09 16:33:58 +02:00
refactor: cleanup
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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!("®ion={}", 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!("®ion={}", 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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+1017
-31
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user