From 96614a3f3347db54c880ad5065d02a58d33e278f Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:15:48 +0400 Subject: [PATCH] refactor: better tombstone handling --- next-env.d.ts | 2 +- src-tauri/src/app_auto_updater.rs | 4 ++- src-tauri/src/auto_updater.rs | 8 +++++ src-tauri/src/browser_runner.rs | 27 +++++---------- src-tauri/src/downloader.rs | 20 +++++++++-- src-tauri/src/profile/manager.rs | 40 +++++++++++++++++++-- src-tauri/src/profile/types.rs | 17 +++++++-- src-tauri/src/profile_importer.rs | 4 ++- src-tauri/src/proxy_manager.rs | 10 +++--- src-tauri/src/sync/engine.rs | 48 ++++++++++++++++++++++++++ src/components/profile-data-table.tsx | 35 +++++++++++++------ src/components/profile-info-dialog.tsx | 20 ++++++++--- src/hooks/use-browser-state.ts | 19 +++++----- src/lib/browser-utils.ts | 14 ++++++-- 14 files changed, 209 insertions(+), 59 deletions(-) diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..b87975d 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./dist/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src-tauri/src/app_auto_updater.rs b/src-tauri/src/app_auto_updater.rs index 656ffa9..c7b0472 100644 --- a/src-tauri/src/app_auto_updater.rs +++ b/src-tauri/src/app_auto_updater.rs @@ -704,7 +704,8 @@ impl AppAutoUpdater { let total_size = response.content_length().unwrap_or(0); log::info!("Silent download size: {} bytes", total_size); - let mut file = fs::File::create(&file_path)?; + let raw_file = fs::File::create(&file_path)?; + let mut file = std::io::BufWriter::with_capacity(8 * 1024 * 1024, raw_file); let mut stream = response.bytes_stream(); use futures_util::StreamExt; @@ -712,6 +713,7 @@ impl AppAutoUpdater { let chunk = chunk?; file.write_all(&chunk)?; } + std::io::Write::flush(&mut file)?; log::info!("Silent download completed: {}", file_path.display()); Ok(file_path) diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 04d0d43..b57c2d2 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -145,6 +145,14 @@ impl AutoUpdater { let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(); + // Skip if this browser-version pair is already being downloaded + if crate::downloader::is_downloading(&browser, &new_version) { + log::info!( + "Browser {browser} {new_version} is already being downloaded, skipping duplicate" + ); + return; + } + if registry.is_browser_downloaded(&browser, &new_version) { log::info!("Browser {browser} {new_version} already downloaded, proceeding to auto-update profiles"); diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index d33899b..8b7e4c9 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -2157,14 +2157,11 @@ impl BrowserRunner { .find(|p| p.id.to_string() == profile_id) .ok_or_else(|| format!("Profile '{profile_id}' not found"))?; - if profile.is_cross_os() - && !crate::cloud_auth::CLOUD_AUTH - .is_fingerprint_os_allowed(profile.host_os.as_deref()) - .await - { + if profile.is_cross_os() { return Err(format!( - "Cannot open URL with profile '{}': cross-OS fingerprints require a paid subscription", + "Cannot open URL with profile '{}': this profile was created on {} and cannot be used on a different operating system", profile.name, + profile.host_os.as_deref().unwrap_or("another OS"), )); } @@ -2196,14 +2193,11 @@ pub async fn launch_browser_profile( profile.id ); - if profile.is_cross_os() - && !crate::cloud_auth::CLOUD_AUTH - .is_fingerprint_os_allowed(profile.host_os.as_deref()) - .await - { + if profile.is_cross_os() { return Err(format!( - "Cannot launch profile '{}': cross-OS fingerprints require a paid subscription", + "Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system", profile.name, + profile.host_os.as_deref().unwrap_or("another OS"), )); } @@ -2516,14 +2510,11 @@ pub async fn launch_browser_profile_with_debugging( remote_debugging_port: Option, headless: bool, ) -> Result { - if profile.is_cross_os() - && !crate::cloud_auth::CLOUD_AUTH - .is_fingerprint_os_allowed(profile.host_os.as_deref()) - .await - { + if profile.is_cross_os() { return Err(format!( - "Cannot launch profile '{}': cross-OS fingerprints require a paid subscription", + "Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system", profile.name, + profile.host_os.as_deref().unwrap_or("another OS"), )); } diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index 82d8fbb..21d61c4 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -445,12 +445,16 @@ impl Downloader { let _ = events::emit("download-progress", &progress); - // Open file in append mode (resuming) or create new + // Open file in append mode (resuming) or create new. + // Wrap in BufWriter with a large buffer to reduce the number of disk writes, + // which dramatically improves download speed on Windows (NTFS + Defender overhead). use std::fs::OpenOptions; - let mut file = OpenOptions::new() + use std::io::Write; + let raw_file = OpenOptions::new() .create(true) .append(true) .open(&file_path)?; + let mut file = io::BufWriter::with_capacity(8 * 1024 * 1024, raw_file); let mut stream = response.bytes_stream(); use futures_util::StreamExt; @@ -463,7 +467,7 @@ impl Downloader { } } let chunk = chunk?; - io::copy(&mut chunk.as_ref(), &mut file)?; + file.write_all(&chunk)?; downloaded += chunk.len() as u64; let now = std::time::Instant::now(); @@ -510,6 +514,9 @@ impl Downloader { } } + // Flush remaining buffered data to disk + file.flush()?; + Ok(file_path) } @@ -953,6 +960,13 @@ impl Downloader { } } +/// Check if a specific browser-version pair is currently being downloaded +pub fn is_downloading(browser: &str, version: &str) -> bool { + let download_key = format!("{browser}-{version}"); + let downloading = DOWNLOADING_BROWSERS.lock().unwrap(); + downloading.contains(&download_key) +} + #[tauri::command] pub async fn download_browser( app_handle: tauri::AppHandle, diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 3e4216a..df21295 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -425,8 +425,21 @@ impl ProfileManager { if path.is_dir() { let metadata_file = path.join("metadata.json"); if metadata_file.exists() { - let content = fs::read_to_string(metadata_file)?; - let profile: BrowserProfile = serde_json::from_str(&content)?; + let content = fs::read_to_string(&metadata_file)?; + let mut profile: BrowserProfile = serde_json::from_str(&content)?; + + // Backfill host_os from browser config for profiles created before + // the field existed (or synced without it). + if profile.host_os.is_none() { + let inferred_os = profile.resolved_os().map(str::to_string); + if let Some(os) = inferred_os { + profile.host_os = Some(os); + if let Ok(json) = serde_json::to_string_pretty(&profile) { + let _ = fs::write(&metadata_file, json); + } + } + } + profiles.push(profile); } } @@ -566,6 +579,29 @@ impl ProfileManager { Ok(()) } + /// Delete a profile from the local filesystem only, without triggering remote sync deletion. + /// Used when a profile was deleted on another device and the local copy should be cleaned up. + pub fn delete_profile_local_only( + &self, + profile_id: &str, + ) -> Result<(), Box> { + let profiles_dir = self.get_profiles_dir(); + let profile_dir = profiles_dir.join(profile_id); + if profile_dir.exists() { + fs::remove_dir_all(&profile_dir)?; + log::info!("Deleted local profile {} (tombstoned remotely)", profile_id); + } + + if let Err(e) = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance() + .cleanup_unused_binaries() + { + log::warn!("Failed to cleanup binaries after tombstone deletion: {e}"); + } + + let _ = crate::events::emit_empty("profiles-changed"); + Ok(()) + } + pub fn update_profile_version( &self, _app_handle: &tauri::AppHandle, diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index 6c01392..32ac5c4 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -87,11 +87,22 @@ impl BrowserProfile { profiles_dir.join(self.id.to_string()).join("profile") } + /// Resolve the OS this profile was created on. Checks `host_os` first, + /// then falls back to the fingerprint config's `os` field (for profiles + /// created before `host_os` was introduced or synced without it). + pub fn resolved_os(&self) -> Option<&str> { + self + .host_os + .as_deref() + .or_else(|| self.camoufox_config.as_ref().and_then(|c| c.os.as_deref())) + .or_else(|| self.wayfern_config.as_ref().and_then(|c| c.os.as_deref())) + } + /// Returns true when the profile was created on a different OS than the current host. - /// Profiles without an `os` field (backward compat) are treated as native. + /// Checks `host_os` first, then falls back to the browser config's `os` field. pub fn is_cross_os(&self) -> bool { - match &self.host_os { - Some(host_os) => host_os != &get_host_os(), + match self.resolved_os() { + Some(os) => os != get_host_os(), None => false, } } diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 9555ea8..9369e6d 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -1035,7 +1035,9 @@ Path=test.profile fn test_get_default_version_for_browser_no_versions() { let (importer, _temp_dir) = create_test_profile_importer(); - let result = importer.get_default_version_for_browser("camoufox"); + // Use a browser name that is guaranteed to have no downloaded versions, + // since the global registry singleton may contain real data from the system. + let result = importer.get_default_version_for_browser("nonexistent_browser_xyz"); assert!( result.is_err(), "Should fail when no versions are available" diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 7fd271b..e84c66d 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -2741,17 +2741,19 @@ mod tests { fn test_process_running_detection_with_child_lifecycle() { use crate::proxy_storage::is_process_running; - // Spawn a long-lived child so we can check while it runs - let mut child = std::process::Command::new(if cfg!(windows) { "timeout" } else { "sleep" }) + // Spawn a long-lived child so we can check while it runs. + // On Windows, `timeout` requires console input and exits immediately in + // non-interactive contexts, so use `ping` with a high count instead. + let mut child = std::process::Command::new(if cfg!(windows) { "ping" } else { "sleep" }) .args(if cfg!(windows) { - vec!["/T", "10"] + vec!["-n", "100", "127.0.0.1"] } else { vec!["10"] }) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() - .expect("spawn sleep"); + .expect("spawn long-lived child"); let pid = child.id(); diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 95555fe..ca3da82 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -2361,6 +2361,54 @@ impl SyncEngine { log::info!("No missing profiles found"); } + // Delete local synced profiles that have a remote tombstone (deleted on another device) + { + let profile_manager = ProfileManager::instance(); + let local_synced: Vec<(String, Option)> = profile_manager + .list_profiles() + .unwrap_or_default() + .iter() + .filter(|p| p.is_sync_enabled()) + .map(|p| (p.id.to_string(), p.created_by_id.clone())) + .collect(); + + let team_prefix = if let Some(auth) = crate::cloud_auth::CLOUD_AUTH.get_user().await { + auth.user.team_id.map(|tid| format!("teams/{}/", tid)) + } else { + None + }; + + for (pid, created_by_id) in &local_synced { + // Check personal tombstone + let personal_tombstone = format!("tombstones/profiles/{}.json", pid); + let has_personal_tombstone = matches!( + self.client.stat(&personal_tombstone).await, + Ok(stat) if stat.exists + ); + + // Check team tombstone + let has_team_tombstone = if let (Some(tp), Some(_)) = (&team_prefix, created_by_id) { + let team_tombstone = format!("{}tombstones/profiles/{}.json", tp, pid); + matches!( + self.client.stat(&team_tombstone).await, + Ok(stat) if stat.exists + ) + } else { + false + }; + + if has_personal_tombstone || has_team_tombstone { + log::info!( + "Profile {} has remote tombstone, deleting locally (deleted on another device)", + pid + ); + if let Err(e) = profile_manager.delete_profile_local_only(pid) { + log::warn!("Failed to delete tombstoned profile {}: {}", pid, e); + } + } + } + } + // Refresh metadata for local cross-OS profiles (propagate renames, tags, notes from originating device) let profile_manager = ProfileManager::instance(); // Collect cross-OS profiles before async operations to avoid holding non-Send Result across await diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 5d8d6fd..a25d795 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -1648,14 +1648,18 @@ export function ProfilesDataTable({ // Cross-OS profiles: show OS icon when checkboxes aren't visible, show checkbox when they are if (isCrossOs && !meta.showCheckboxes && !isSelected) { - const osName = profile.host_os - ? getOSDisplayName(profile.host_os) + const resolvedOs = + profile.host_os || + profile.camoufox_config?.os || + profile.wayfern_config?.os; + const osName = resolvedOs + ? getOSDisplayName(resolvedOs) : "another OS"; const crossOsTooltip = t("crossOs.viewOnly", { os: osName }); const OsIcon = - profile.host_os === "macos" + resolvedOs === "macos" ? FaApple - : profile.host_os === "windows" + : resolvedOs === "windows" ? FaWindows : FaLinux; return ( @@ -1684,8 +1688,12 @@ export function ProfilesDataTable({ // Cross-OS profiles with checkboxes visible: show checkbox (selectable for bulk delete) if (isCrossOs && (meta.showCheckboxes || isSelected)) { - const osName = profile.host_os - ? getOSDisplayName(profile.host_os) + const resolvedOs = + profile.host_os || + profile.camoufox_config?.os || + profile.wayfern_config?.os; + const osName = resolvedOs + ? getOSDisplayName(resolvedOs) : "another OS"; const crossOsTooltip = t("crossOs.viewOnly", { os: osName }); return ( @@ -2017,7 +2025,7 @@ export function ProfilesDataTable({ ); const isCrossOs = isCrossOsProfile(profile); - const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked; + const isCrossOsBlocked = isCrossOs; const isRunning = meta.isClient && meta.runningProfiles.has(profile.id); const isLaunching = meta.launchingProfiles.has(profile.id); @@ -2078,7 +2086,7 @@ export function ProfilesDataTable({ const meta = table.options.meta as TableMeta; const profile = row.original; const isCrossOs = isCrossOsProfile(profile); - const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked; + const isCrossOsBlocked = isCrossOs; const isRunning = meta.isClient && meta.runningProfiles.has(profile.id); const isLaunching = meta.launchingProfiles.has(profile.id); @@ -2107,7 +2115,7 @@ export function ProfilesDataTable({ const meta = table.options.meta as TableMeta; const profile = row.original; const isCrossOs = isCrossOsProfile(profile); - const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked; + const isCrossOsBlocked = isCrossOs; const isRunning = meta.isClient && meta.runningProfiles.has(profile.id); const isLaunching = meta.launchingProfiles.has(profile.id); @@ -2134,7 +2142,7 @@ export function ProfilesDataTable({ const meta = table.options.meta as TableMeta; const profile = row.original; const isCrossOs = isCrossOsProfile(profile); - const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked; + const isCrossOsBlocked = isCrossOs; const isRunning = meta.isClient && meta.runningProfiles.has(profile.id); const isLaunching = meta.launchingProfiles.has(profile.id); @@ -2534,7 +2542,12 @@ export function ProfilesDataTable({ const rowIsCrossOs = isCrossOsProfile(row.original); const crossOsTitle = rowIsCrossOs ? t("crossOs.viewOnly", { - os: getOSDisplayName(row.original.host_os ?? ""), + os: getOSDisplayName( + row.original.host_os || + row.original.camoufox_config?.os || + row.original.wayfern_config?.os || + "", + ), }) : undefined; return ( diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 8a334d5..300d5c1 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -211,7 +211,7 @@ export function ProfileInfoDialog({ profile.release_type.slice(1); const hasTags = profile.tags && profile.tags.length > 0; const hasNote = !!profile.note; - const showCrossOs = !!(profile.host_os && isCrossOsProfile(profile)); + const showCrossOs = isCrossOsProfile(profile); type ActionItem = { icon: React.ReactNode; @@ -364,10 +364,22 @@ export function ProfileInfoDialog({ {t("profiles.ephemeralBadge")} )} - {showCrossOs && profile.host_os && ( + {showCrossOs && ( - - {getOSDisplayName(profile.host_os)} + + {getOSDisplayName( + profile.host_os || + profile.camoufox_config?.os || + profile.wayfern_config?.os || + "", + )} )} diff --git a/src/hooks/use-browser-state.ts b/src/hooks/use-browser-state.ts index 4f2ab35..ff2ce00 100644 --- a/src/hooks/use-browser-state.ts +++ b/src/hooks/use-browser-state.ts @@ -15,7 +15,7 @@ export function useBrowserState( _isUpdating: (browser: string) => boolean, launchingProfiles: Set, stoppingProfiles: Set, - crossOsUnlocked = false, + _crossOsUnlocked = false, ) { const [isClient, setIsClient] = useState(false); @@ -53,7 +53,7 @@ export function useBrowserState( (profile: BrowserProfile): boolean => { if (!isClient) return false; - if (isCrossOsProfile(profile) && !crossOsUnlocked) return false; + if (isCrossOsProfile(profile)) return false; const isRunning = runningProfiles.has(profile.id); const isLaunching = launchingProfiles.has(profile.id); @@ -81,7 +81,6 @@ export function useBrowserState( isAnyInstanceRunning, launchingProfiles, stoppingProfiles, - crossOsUnlocked, ], ); @@ -158,11 +157,16 @@ export function useBrowserState( (profile: BrowserProfile): string => { if (!isClient) return "Loading..."; - if (isCrossOsProfile(profile) && profile.host_os) { - if (!crossOsUnlocked) { - const osName = getOSDisplayName(profile.host_os); - return `This profile was created on ${osName}. A paid subscription is required to launch cross-OS profiles.`; + if (isCrossOsProfile(profile)) { + const profileOs = + profile.host_os || + profile.camoufox_config?.os || + profile.wayfern_config?.os; + if (profileOs) { + const osName = getOSDisplayName(profileOs); + return `This profile was created on ${osName} and cannot be launched on a different operating system.`; } + return "This profile was created on a different operating system and cannot be launched here."; } const isRunning = runningProfiles.has(profile.id); @@ -197,7 +201,6 @@ export function useBrowserState( canLaunchProfile, launchingProfiles, stoppingProfiles, - crossOsUnlocked, ], ); diff --git a/src/lib/browser-utils.ts b/src/lib/browser-utils.ts index 7652c23..c2f8c51 100644 --- a/src/lib/browser-utils.ts +++ b/src/lib/browser-utils.ts @@ -57,9 +57,17 @@ export const getCurrentOS = () => { return "unknown"; }; -export function isCrossOsProfile(profile: { host_os?: string }): boolean { - if (!profile.host_os) return false; - return profile.host_os !== getCurrentOS(); +export function isCrossOsProfile(profile: { + host_os?: string; + camoufox_config?: { os?: string }; + wayfern_config?: { os?: string }; +}): boolean { + const profileOs = + profile.host_os || + profile.camoufox_config?.os || + profile.wayfern_config?.os; + if (!profileOs) return false; + return profileOs !== getCurrentOS(); } export function getOSDisplayName(os: string): string {