mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-29 23:48:56 +02:00
refactor: better tombstone handling
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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"),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user