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
+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