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 {