refactor: cleanup

This commit is contained in:
zhom
2026-08-03 18:44:24 +04:00
parent 7d82a25107
commit 29cb83d063
32 changed files with 4227 additions and 551 deletions
+89 -10
View File
@@ -1,6 +1,8 @@
use super::client::SyncClient;
use super::encryption;
use super::manifest::{compute_diff, generate_manifest, get_cache_path, HashCache, SyncManifest};
use super::manifest::{
compute_diff_with_bias, generate_manifest, get_cache_path, DiffBias, HashCache, SyncManifest,
};
use super::types::*;
use crate::events;
use crate::profile::types::{BrowserProfile, SyncMode};
@@ -20,6 +22,22 @@ use tokio::sync::{Mutex as TokioMutex, Semaphore};
/// (last-write-wins) from a HEAD request without downloading the object body.
const UPDATED_AT_META_KEY: &str = "updated-at";
/// What one profile reconcile actually did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProfileSyncOutcome {
/// The local directory and the remote copy now agree.
Completed,
/// Nothing was transferred, and the reason is not an error. A caller waiting
/// on the remote copy has NOT got it and must try again.
Skipped(&'static str),
}
impl ProfileSyncOutcome {
pub fn is_completed(&self) -> bool {
matches!(self, Self::Completed)
}
}
lazy_static::lazy_static! {
static ref SYNC_CANCEL_FLAGS: StdMutex<HashMap<String, Arc<AtomicBool>>> =
StdMutex::new(HashMap::new());
@@ -450,13 +468,35 @@ impl SyncEngine {
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> SyncResult<()> {
self
.sync_profile_with_bias(app_handle, profile, DiffBias::Auto)
.await
.map(|_| ())
}
/// Reconcile a profile, stating which side wins and whether anything happened.
///
/// The outcome matters to exactly one caller: the pull that follows a remote
/// session. Every skip below returns `Ok(())` from `sync_profile`, so a caller
/// that treated success as "the profile is now current" would clear the local
/// launch gate without having downloaded a single byte — and the user would
/// then open a stale profile over the session's work. `Skipped` says so.
pub async fn sync_profile_with_bias(
&self,
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
bias: DiffBias,
) -> SyncResult<ProfileSyncOutcome> {
if profile.is_cross_os() {
log::info!(
"Cross-OS profile: {} ({}) — syncing metadata only",
profile.name,
profile.id
);
return self.sync_cross_os_metadata(app_handle, profile).await;
self.sync_cross_os_metadata(app_handle, profile).await?;
// The browser files are the thing a remote session changes, and a cross-OS
// profile syncs none of them here, so this is not a completed pull.
return Ok(ProfileSyncOutcome::Skipped("cross-OS profile"));
}
// Skip team profiles for self-hosted sync
@@ -466,7 +506,9 @@ impl SyncEngine {
profile.name,
profile.id
);
return Ok(());
return Ok(ProfileSyncOutcome::Skipped(
"team profile, self-hosted sync",
));
}
// Skip if profile is currently running locally
@@ -476,20 +518,21 @@ impl SyncEngine {
profile.name,
profile.id
);
return Ok(());
return Ok(ProfileSyncOutcome::Skipped("profile is running locally"));
}
// Skip if profile is locked by another team member
// Skip if profile is locked by another team member, or by one of this
// user's own remote sessions.
if crate::team_lock::TEAM_LOCK
.is_locked_by_another(&profile.id.to_string())
.await
{
log::info!(
"Skipping sync for profile locked by another team member: {} ({})",
"Skipping sync for profile locked by another holder: {} ({})",
profile.name,
profile.id
);
return Ok(());
return Ok(ProfileSyncOutcome::Skipped("profile is locked elsewhere"));
}
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
@@ -591,7 +634,7 @@ impl SyncEngine {
.await?;
// Compute diff
let diff = compute_diff(&local_manifest, remote_manifest.as_ref());
let diff = compute_diff_with_bias(&local_manifest, remote_manifest.as_ref(), bias);
if diff.is_empty() {
log::info!("Profile {} is already in sync", profile_id);
@@ -603,7 +646,9 @@ impl SyncEngine {
"status": "synced"
}),
);
return Ok(());
// Nothing to transfer IS a completed reconcile: the local copy already
// matches what the host pushed, which is exactly what the caller waits for.
return Ok(ProfileSyncOutcome::Completed);
}
let upload_bytes: u64 = diff.files_to_upload.iter().map(|f| f.size).sum();
@@ -769,7 +814,7 @@ impl SyncEngine {
);
log::info!("Profile {} synced successfully", profile_id);
Ok(())
Ok(ProfileSyncOutcome::Completed)
}
async fn download_manifest(
@@ -3546,6 +3591,40 @@ pub async fn trigger_sync_for_profile(
Ok(())
}
/// Pull a profile back down after a remote session wrote to it.
///
/// Not `trigger_sync_for_profile` with a different name. Two things differ, and
/// both of them are the reason the session's work used to be destroyed:
///
/// - The diff is biased to the remote copy. The host has just written the
/// authoritative profile; local mtimes may nonetheless be newer, and under the
/// ordinary rule that uploads the stale copy and deletes the host's files.
/// - The outcome is reported. Every skip inside `sync_profile` returns success,
/// so the caller could otherwise mark the profile current without a byte
/// having moved.
pub async fn pull_profile_after_remote_session(
app_handle: &tauri::AppHandle,
profile_id: &str,
) -> Result<ProfileSyncOutcome, String> {
let engine = SyncEngine::create_from_settings(app_handle)
.await
.map_err(|e| format!("Failed to create sync engine: {e}"))?;
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profile = ProfileManager::instance()
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?
.into_iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
engine
.sync_profile_with_bias(app_handle, &profile, DiffBias::PreferRemote)
.await
.map_err(|e| format!("Sync failed: {e}"))
}
#[tauri::command]
pub async fn set_proxy_sync_enabled(
app_handle: tauri::AppHandle,
+101 -6
View File
@@ -414,11 +414,41 @@ impl ManifestDiff {
}
/// Compute what needs to be synced between local and remote
/// Which side a sync should believe when both have moved.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DiffBias {
/// Newest `updated_at` wins. What an ordinary background sync uses.
#[default]
Auto,
/// Remote wins regardless of timestamps.
///
/// Used for exactly one thing: the pull that follows a remote session. A
/// leased host has just written the authoritative copy of this profile, and
/// the local directory is whatever it was before the session started. If the
/// user launched locally in between, local mtimes are NEWER than the host's
/// push, so `Auto` would upload the stale copy and put every file the host
/// wrote into `files_to_delete_remote` — the whole session's work destroyed,
/// silently. There is no timestamp comparison that gets this right, because
/// the local clock genuinely is later; only the caller knows that the remote
/// copy is the one that matters.
PreferRemote,
}
pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> ManifestDiff {
compute_diff_with_bias(local, remote, DiffBias::Auto)
}
pub fn compute_diff_with_bias(
local: &SyncManifest,
remote: Option<&SyncManifest>,
bias: DiffBias,
) -> ManifestDiff {
let mut diff = ManifestDiff::default();
let Some(remote) = remote else {
// No remote manifest - upload everything
// No remote manifest - upload everything. Even under PreferRemote: there is
// no remote copy to prefer, and refusing to upload would leave the profile
// with no cloud copy at all.
diff.files_to_upload = local.files.clone();
return diff;
};
@@ -446,11 +476,14 @@ pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> Mani
let local_updated = local.updated_at_datetime();
let remote_updated = remote.updated_at_datetime();
let local_is_newer = match (local_updated, remote_updated) {
(Some(l), Some(r)) => l > r,
(Some(_), None) => true,
(None, Some(_)) => false,
(None, None) => true, // Default to uploading
let local_is_newer = match bias {
DiffBias::PreferRemote => false,
DiffBias::Auto => match (local_updated, remote_updated) {
(Some(l), Some(r)) => l > r,
(Some(_), None) => true,
(None, Some(_)) => false,
(None, None) => true, // Default to uploading
},
};
if local_is_newer {
@@ -674,6 +707,68 @@ mod tests {
assert!(diff.files_to_delete_remote.is_empty());
}
/// A manifest with one file, at a stated time.
fn manifest_at(updated_at: &str, files: &[(&str, &str)]) -> SyncManifest {
SyncManifest {
version: 1,
profile_id: "test".to_string(),
generated_at: updated_at.to_string(),
updated_at: updated_at.to_string(),
exclude_globs: vec![],
files: files
.iter()
.map(|(path, hash)| ManifestFileEntry {
path: (*path).to_string(),
size: 10,
mtime: 1000,
hash: (*hash).to_string(),
})
.collect(),
encrypted: false,
}
}
#[test]
fn prefer_remote_downloads_even_though_the_local_clock_is_later() {
// The exact shape of the data-loss bug. A remote session finishes and the
// host pushes the profile; the user then launches locally before the pull
// lands, so every local mtime is newer than the host's write. Under Auto
// that uploads the stale copy and deletes the session's own files.
let local = manifest_at("2026-01-02T00:00:00Z", &[("Cookies", "before-session")]);
let remote = manifest_at(
"2026-01-01T00:00:00Z",
&[("Cookies", "after-session"), ("History", "warmed")],
);
let lossy = compute_diff_with_bias(&local, Some(&remote), DiffBias::Auto);
assert_eq!(lossy.files_to_delete_remote, vec!["History".to_string()]);
assert_eq!(lossy.files_to_upload.len(), 1);
let safe = compute_diff_with_bias(&local, Some(&remote), DiffBias::PreferRemote);
assert!(
safe.files_to_delete_remote.is_empty(),
"a post-session pull must never delete what the host just wrote"
);
assert!(safe.files_to_upload.is_empty());
let downloaded: Vec<&str> = safe
.files_to_download
.iter()
.map(|f| f.path.as_str())
.collect();
assert_eq!(downloaded.len(), 2);
assert!(downloaded.contains(&"Cookies"));
assert!(downloaded.contains(&"History"));
}
#[test]
fn prefer_remote_still_uploads_when_there_is_no_remote_copy() {
// Nothing to prefer. Refusing to upload here would leave a profile with no
// cloud copy because a session once ran against it.
let local = manifest_at("2026-01-02T00:00:00Z", &[("Cookies", "only-local")]);
let diff = compute_diff_with_bias(&local, None, DiffBias::PreferRemote);
assert_eq!(diff.files_to_upload.len(), 1);
}
#[test]
fn test_compute_diff_detect_changes() {
let old_time = "2024-01-01T00:00:00Z";
+9 -5
View File
@@ -15,12 +15,16 @@ pub use engine::{
enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed,
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode,
set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile,
pull_profile_after_remote_session, request_profile_sync, rollover_encryption_for_all_entities,
set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled,
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile,
trigger_sync_for_profile, ProfileSyncOutcome, SyncEngine,
};
pub use manifest::{
compute_diff, compute_diff_with_bias, generate_manifest, DiffBias, HashCache, ManifestDiff,
SyncManifest,
};
pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest};
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
pub use subscription::{SubscriptionManager, SyncWorkItem};
pub use types::{SyncError, SyncResult};