refactor: better tombstone handling

This commit is contained in:
zhom
2026-03-17 13:15:48 +04:00
parent 222a8b89f5
commit 96614a3f33
14 changed files with 209 additions and 59 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
+3 -1
View File
@@ -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)
+8
View File
@@ -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");
+9 -18
View File
@@ -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<u16>,
headless: bool,
) -> Result<BrowserProfile, String> {
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"),
));
}
+17 -3
View File
@@ -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,
+38 -2
View File
@@ -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<dyn std::error::Error>> {
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,
+14 -3
View File
@@ -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,
}
}
+3 -1
View File
@@ -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"
+6 -4
View File
@@ -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();
+48
View File
@@ -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<String>)> = 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
+24 -11
View File
@@ -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 (
+16 -4
View File
@@ -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")}
</Badge>
)}
{showCrossOs && profile.host_os && (
{showCrossOs && (
<Badge variant="outline" className="text-xs gap-1">
<OSIcon os={profile.host_os} />
{getOSDisplayName(profile.host_os)}
<OSIcon
os={
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os ||
""
}
/>
{getOSDisplayName(
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os ||
"",
)}
</Badge>
)}
</div>
+11 -8
View File
@@ -15,7 +15,7 @@ export function useBrowserState(
_isUpdating: (browser: string) => boolean,
launchingProfiles: Set<string>,
stoppingProfiles: Set<string>,
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,
],
);
+11 -3
View File
@@ -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 {