refactor: sync

This commit is contained in:
zhom
2026-02-20 07:22:42 +04:00
parent 8bc1ea500b
commit 4872dcc8ad
28 changed files with 678 additions and 72 deletions
+5
View File
@@ -1162,6 +1162,7 @@ async fn delete_proxy(
request_body = RunProfileRequest,
responses(
(status = 200, description = "Profile launched successfully", body = RunProfileResponse),
(status = 400, description = "Cannot launch cross-OS profile"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error")
@@ -1189,6 +1190,10 @@ async fn run_profile(
.find(|p| p.id.to_string() == id)
.ok_or(StatusCode::NOT_FOUND)?;
if profile.is_cross_os() {
return Err(StatusCode::BAD_REQUEST);
}
// Generate a random port for remote debugging
let remote_debugging_port = rand::random::<u16>().saturating_add(9000).max(9000);
+24
View File
@@ -2401,6 +2401,14 @@ impl BrowserRunner {
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| format!("Profile '{profile_id}' not found"))?;
if profile.is_cross_os() {
return Err(format!(
"Cannot open URL with profile '{}': it was created on {} and is not supported on this system",
profile.name,
profile.host_os.as_deref().unwrap_or("unknown")
));
}
log::info!("Opening URL '{url}' with profile '{profile_id}'");
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
@@ -2429,6 +2437,14 @@ pub async fn launch_browser_profile(
profile.id
);
if profile.is_cross_os() {
return Err(format!(
"Cannot launch profile '{}': it was created on {} and is not supported on this system",
profile.name,
profile.host_os.as_deref().unwrap_or("unknown")
));
}
let browser_runner = BrowserRunner::instance();
// Store the internal proxy settings for passing to launch_browser
@@ -2664,6 +2680,14 @@ pub async fn launch_browser_profile_with_debugging(
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, String> {
if profile.is_cross_os() {
return Err(format!(
"Cannot launch profile '{}': it was created on {} and is not supported on this system",
profile.name,
profile.host_os.as_deref().unwrap_or("unknown")
));
}
let browser_runner = BrowserRunner::instance();
browser_runner
.launch_browser_with_debugging(app_handle, &profile, url, remote_debugging_port, headless)
+15
View File
@@ -611,6 +611,21 @@ impl CloudAuthManager {
}
}
/// Non-async version that uses try_lock, defaults to false if lock can't be acquired.
pub fn has_active_paid_subscription_sync(&self) -> bool {
match self.state.try_lock() {
Ok(state) => match &*state {
Some(auth) => {
auth.user.plan != "free"
&& (auth.user.subscription_status == "active"
|| auth.user.plan_period.as_deref() == Some("lifetime"))
}
None => false,
},
Err(_) => false,
}
}
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
let host_os = crate::profile::types::get_host_os();
match fingerprint_os {
@@ -312,6 +312,30 @@ impl DownloadedBrowsersRegistry {
}
}
// Filter out versions that would leave a browser with zero versions in the registry
{
let data = self.data.lock().unwrap();
let mut removal_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for (browser, _) in &to_remove {
*removal_counts.entry(browser.clone()).or_insert(0) += 1;
}
to_remove.retain(|(browser, version)| {
let total = data
.browsers
.get(browser.as_str())
.map(|v| v.len())
.unwrap_or(0);
let removing = *removal_counts.get(browser.as_str()).unwrap_or(&0);
if removing >= total {
log::info!("Keeping last available version: {browser} {version}");
*removal_counts.get_mut(browser.as_str()).unwrap() -= 1;
return false;
}
true
});
}
// Remove unused binaries and their version folders
for (browser, version) in to_remove {
if let Err(e) = self.cleanup_failed_download(&browser, &version) {
@@ -1164,6 +1188,58 @@ mod tests {
);
}
#[test]
fn test_last_version_kept_during_cleanup() {
let registry = DownloadedBrowsersRegistry::new();
// Add a single version for "firefox"
registry.add_browser(DownloadedBrowserInfo {
browser: "firefox".to_string(),
version: "139.0".to_string(),
file_path: PathBuf::from("/test/firefox/139.0"),
});
// Add two versions for "chromium"
registry.add_browser(DownloadedBrowserInfo {
browser: "chromium".to_string(),
version: "120.0".to_string(),
file_path: PathBuf::from("/test/chromium/120.0"),
});
registry.add_browser(DownloadedBrowserInfo {
browser: "chromium".to_string(),
version: "121.0".to_string(),
file_path: PathBuf::from("/test/chromium/121.0"),
});
// No active or running profiles
let result = registry
.cleanup_unused_binaries_internal(&[], &[])
.expect("cleanup should succeed");
// firefox 139.0 should be kept (last version), chromium should lose one but keep one
// The exact one kept depends on iteration order, but at least one must remain
assert!(
!result.contains(&"firefox 139.0".to_string()),
"Last version of firefox should not be cleaned up"
);
// At most one chromium version should have been cleaned up
let chromium_cleaned: Vec<_> = result
.iter()
.filter(|r| r.starts_with("chromium"))
.collect();
assert!(
chromium_cleaned.len() <= 1,
"At most one chromium version should be cleaned up, got: {:?}",
chromium_cleaned
);
// Verify firefox is still registered
assert!(
registry.is_browser_registered("firefox", "139.0"),
"Last firefox version should still be registered"
);
}
#[test]
fn test_is_browser_registered_vs_downloaded() {
let registry = DownloadedBrowsersRegistry::new();
+61 -1
View File
@@ -434,6 +434,63 @@ impl Downloader {
Ok(())
}
fn configure_camoufox_search_engine(
&self,
browser_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let policies_path = browser_dir.join("distribution").join("policies.json");
if !policies_path.exists() {
if let Some(parent) = policies_path.parent() {
std::fs::create_dir_all(parent)?;
}
let policies = serde_json::json!({
"policies": {
"SearchEngines": {
"Default": "DuckDuckGo"
}
}
});
std::fs::write(&policies_path, serde_json::to_string_pretty(&policies)?)?;
log::info!("Created policies.json with DuckDuckGo as default search engine");
return Ok(());
}
let content = std::fs::read_to_string(&policies_path)?;
let mut policies: serde_json::Value = serde_json::from_str(&content)?;
let current_default = policies
.get("policies")
.and_then(|p| p.get("SearchEngines"))
.and_then(|se| se.get("Default"))
.and_then(|d| d.as_str())
.unwrap_or("");
if current_default != "None" {
log::info!(
"Camoufox search engine already configured to '{}', not overwriting",
current_default
);
return Ok(());
}
if let Some(policies_obj) = policies.get_mut("policies") {
if let Some(se) = policies_obj.get_mut("SearchEngines") {
se["Default"] = serde_json::json!("DuckDuckGo");
if let Some(remove_arr) = se.get_mut("Remove").and_then(|r| r.as_array_mut()) {
remove_arr.retain(|v| v.as_str() != Some("DuckDuckGo"));
}
}
}
let updated = serde_json::to_string_pretty(&policies)?;
std::fs::write(&policies_path, updated)?;
log::info!("Updated Camoufox search engine from 'None' to DuckDuckGo");
Ok(())
}
pub async fn download_browser<R: tauri::Runtime>(
&self,
_app_handle: &tauri::AppHandle<R>,
@@ -975,7 +1032,10 @@ impl Downloader {
.await
{
log::warn!("Failed to create version.json for Camoufox: {e}");
// Don't fail the download if version.json creation fails
}
if let Err(e) = self.configure_camoufox_search_engine(&browser_dir) {
log::warn!("Failed to configure Camoufox search engine: {e}");
}
}
+82 -5
View File
@@ -593,7 +593,11 @@ impl Extractor {
}
}
log::info!("ZIP extraction completed. Searching for executable...");
log::info!("ZIP extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self
.find_extracted_executable(dest_dir)
.await
@@ -617,7 +621,9 @@ impl Extractor {
// Set executable permissions for extracted files
self.set_executable_permissions_recursive(dest_dir).await?;
log::info!("tar.gz extraction completed. Searching for executable...");
log::info!("tar.gz extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -638,7 +644,9 @@ impl Extractor {
// Set executable permissions for extracted files
self.set_executable_permissions_recursive(dest_dir).await?;
log::info!("tar.bz2 extraction completed. Searching for executable...");
log::info!("tar.bz2 extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -673,7 +681,9 @@ impl Extractor {
// Set executable permissions for extracted files
self.set_executable_permissions_recursive(dest_dir).await?;
log::info!("tar.xz extraction completed. Searching for executable...");
log::info!("tar.xz extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -691,7 +701,9 @@ impl Extractor {
extractor.to(dest_dir);
}
log::info!("MSI extraction completed. Searching for executable...");
log::info!("MSI extraction completed.");
self.flatten_single_directory_archive(dest_dir)?;
log::info!("Searching for executable...");
self.find_extracted_executable(dest_dir).await
}
@@ -778,6 +790,71 @@ impl Extractor {
self.find_extracted_executable(dest_dir).await
}
fn flatten_single_directory_archive(
&self,
dest_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let entries: Vec<_> = fs::read_dir(dest_dir)?.filter_map(|e| e.ok()).collect();
let archive_extensions = ["zip", "tar", "xz", "gz", "bz2", "dmg", "msi", "exe"];
let mut dirs = Vec::new();
let mut has_non_archive_files = false;
for entry in &entries {
let path = entry.path();
if path.is_dir() {
dirs.push(path);
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if !archive_extensions.contains(&ext.to_lowercase().as_str()) {
has_non_archive_files = true;
}
} else {
has_non_archive_files = true;
}
}
if dirs.len() == 1 && !has_non_archive_files {
let single_dir = &dirs[0];
log::info!(
"Flattening single-directory archive: moving contents of {} to {}",
single_dir.display(),
dest_dir.display()
);
let inner_entries: Vec<_> = fs::read_dir(single_dir)?.filter_map(|e| e.ok()).collect();
for entry in inner_entries {
let source = entry.path();
let file_name = match source.file_name() {
Some(name) => name.to_owned(),
None => continue,
};
let target = dest_dir.join(&file_name);
fs::rename(&source, &target).map_err(|e| {
format!(
"Failed to move {} to {}: {}",
source.display(),
target.display(),
e
)
})?;
}
fs::remove_dir(single_dir).map_err(|e| {
format!(
"Failed to remove empty directory {}: {}",
single_dir.display(),
e
)
})?;
log::info!("Successfully flattened archive directory structure");
}
Ok(())
}
async fn find_extracted_executable(
&self,
dest_dir: &Path,
+2 -1
View File
@@ -119,10 +119,11 @@ impl GroupManager {
return Err(format!("Group with name '{name}' already exists").into());
}
let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync();
let group = ProfileGroup {
id: uuid::Uuid::new_v4().to_string(),
name,
sync_enabled: false,
sync_enabled,
last_sync: None,
};
+5 -3
View File
@@ -82,9 +82,9 @@ use settings_manager::{
};
use sync::{
is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile,
is_vpn_in_use_by_synced_profile, request_profile_sync, set_group_sync_enabled,
set_profile_sync_enabled, set_proxy_sync_enabled, set_vpn_sync_enabled,
enable_sync_for_all_entities, get_unsynced_entity_counts, is_group_in_use_by_synced_profile,
is_proxy_in_use_by_synced_profile, is_vpn_in_use_by_synced_profile, request_profile_sync,
set_group_sync_enabled, set_profile_sync_enabled, set_proxy_sync_enabled, set_vpn_sync_enabled,
};
use tag_manager::get_all_tags;
@@ -1309,6 +1309,8 @@ pub fn run() {
is_group_in_use_by_synced_profile,
set_vpn_sync_enabled,
is_vpn_in_use_by_synced_profile,
get_unsynced_entity_counts,
enable_sync_for_all_entities,
read_profile_cookies,
copy_profile_cookies,
check_wayfern_terms_accepted,
+2 -1
View File
@@ -117,11 +117,12 @@ pub struct StoredProxy {
impl StoredProxy {
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync();
Self {
id: uuid::Uuid::new_v4().to_string(),
name,
proxy_settings,
sync_enabled: false,
sync_enabled,
last_sync: None,
is_cloud_managed: false,
is_cloud_derived: false,
+84
View File
@@ -1823,3 +1823,87 @@ pub async fn set_vpn_sync_enabled(
pub fn is_vpn_in_use_by_synced_profile(vpn_id: String) -> bool {
is_vpn_used_by_synced_profile(&vpn_id)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct UnsyncedEntityCounts {
pub proxies: usize,
pub groups: usize,
pub vpns: usize,
}
#[tauri::command]
pub fn get_unsynced_entity_counts() -> Result<UnsyncedEntityCounts, String> {
let proxy_count = {
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
proxies
.iter()
.filter(|p| !p.sync_enabled && !p.is_cloud_managed)
.count()
};
let group_count = {
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
let groups = gm
.get_all_groups()
.map_err(|e| format!("Failed to get groups: {e}"))?;
groups.iter().filter(|g| !g.sync_enabled).count()
};
let vpn_count = {
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
let configs = storage
.list_configs()
.map_err(|e| format!("Failed to list VPN configs: {e}"))?;
configs.iter().filter(|c| !c.sync_enabled).count()
};
Ok(UnsyncedEntityCounts {
proxies: proxy_count,
groups: group_count,
vpns: vpn_count,
})
}
#[tauri::command]
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> {
// Enable sync for all unsynced proxies
{
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?;
}
}
}
// Enable sync for all unsynced groups
{
let groups = {
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
gm.get_all_groups()
.map_err(|e| format!("Failed to get groups: {e}"))?
};
for group in &groups {
if !group.sync_enabled {
set_group_sync_enabled(app_handle.clone(), group.id.clone(), true).await?;
}
}
}
// Enable sync for all unsynced VPNs
{
let configs = {
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.list_configs()
.map_err(|e| format!("Failed to list VPN configs: {e}"))?
};
for config in &configs {
if !config.sync_enabled {
set_vpn_sync_enabled(app_handle.clone(), config.id.clone(), true).await?;
}
}
}
Ok(())
}
+6 -6
View File
@@ -7,12 +7,12 @@ pub mod types;
pub use client::SyncClient;
pub use engine::{
enable_group_sync_if_needed, enable_proxy_sync_if_needed, enable_vpn_sync_if_needed,
is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile,
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
set_group_sync_enabled, set_profile_sync_enabled, set_proxy_sync_enabled, set_vpn_sync_enabled,
sync_profile, trigger_sync_for_profile, SyncEngine,
enable_group_sync_if_needed, enable_proxy_sync_if_needed, enable_sync_for_all_entities,
enable_vpn_sync_if_needed, get_unsynced_entity_counts, is_group_in_use_by_synced_profile,
is_group_used_by_synced_profile, is_proxy_in_use_by_synced_profile,
is_proxy_used_by_synced_profile, is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile,
request_profile_sync, set_group_sync_enabled, set_profile_sync_enabled, set_proxy_sync_enabled,
set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
};
pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest};
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
+4 -2
View File
@@ -328,6 +328,7 @@ impl VpnStorage {
}
let id = Uuid::new_v4().to_string();
let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync();
let config = VpnConfig {
id,
@@ -336,7 +337,7 @@ impl VpnStorage {
config_data: config_data.to_string(),
created_at: Utc::now().timestamp(),
last_used: None,
sync_enabled: false,
sync_enabled,
last_sync: None,
};
@@ -396,6 +397,7 @@ impl VpnStorage {
let base = filename.trim_end_matches(".conf").trim_end_matches(".ovpn");
format!("{} ({})", base, vpn_type)
});
let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync();
let config = VpnConfig {
id,
@@ -404,7 +406,7 @@ impl VpnStorage {
config_data: content.to_string(),
created_at: Utc::now().timestamp(),
last_used: None,
sync_enabled: false,
sync_enabled,
last_sync: None,
};