mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 22:41:22 +02:00
feat: cross-OS profile sync
This commit is contained in:
1 parent
b7a89ac94e
commit
c359049e09
28 files changed
+532
-122
No files matched your search
+201
-19
@@ -210,6 +210,76 @@ fn is_safe_manifest_path(path: &str) -> bool {
|
||||
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
|
||||
}
|
||||
|
||||
/// Whether this machine syncs the profile's browser files, or only its
|
||||
/// metadata. Every Wayfern profile syncs its files, whichever OS created it;
|
||||
/// only a profile of a browser bound to another OS keeps to its metadata.
|
||||
fn syncs_browser_files(profile: &BrowserProfile) -> bool {
|
||||
!profile.is_cross_os()
|
||||
}
|
||||
|
||||
/// True when `dir` holds no file at any depth, or does not exist.
|
||||
fn holds_no_files(dir: &Path) -> bool {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return true;
|
||||
};
|
||||
entries.flatten().all(|entry| {
|
||||
entry
|
||||
.file_type()
|
||||
.is_ok_and(|kind| kind.is_dir() && holds_no_files(&entry.path()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a synced profile created on another OS is on this machine as
|
||||
/// metadata alone, with no browser files yet. Earlier versions synced a
|
||||
/// profile from another OS that way.
|
||||
pub fn awaits_first_download(profile: &BrowserProfile, profiles_dir: &Path) -> bool {
|
||||
profile.is_sync_enabled()
|
||||
&& syncs_browser_files(profile)
|
||||
&& profile
|
||||
.resolved_os()
|
||||
.is_some_and(|os| os != crate::profile::types::get_host_os())
|
||||
&& holds_no_files(&profile.get_profile_data_path(profiles_dir))
|
||||
}
|
||||
|
||||
/// Download the browser files of a profile that `awaits_first_download`
|
||||
/// before it launches. Launched empty, the browser would write a fresh
|
||||
/// profile, and the sync after it closes would publish that as the newer copy
|
||||
/// and delete the real files everywhere, so a download that does not complete
|
||||
/// refuses the launch.
|
||||
pub async fn download_before_first_launch(
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<(), String> {
|
||||
let profiles_dir = ProfileManager::instance().get_profiles_dir();
|
||||
if !awaits_first_download(profile, &profiles_dir) {
|
||||
return Ok(());
|
||||
}
|
||||
let refused = |detail: String| {
|
||||
log::warn!(
|
||||
"Launch refused, profile data not downloaded profile={} err=\"{detail}\"",
|
||||
profile.id
|
||||
);
|
||||
crate::backend_error_with_detail("PROFILE_DATA_NOT_DOWNLOADED", detail)
|
||||
};
|
||||
let engine = SyncEngine::create_from_settings(app_handle)
|
||||
.await
|
||||
.map_err(|e| refused(e.to_string()))?;
|
||||
match engine
|
||||
.sync_profile_with_bias(app_handle, profile, DiffBias::Auto)
|
||||
.await
|
||||
{
|
||||
Ok(ProfileSyncOutcome::Completed) => {
|
||||
log::info!(
|
||||
"Profile data downloaded before launch profile={}",
|
||||
profile.id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Ok(ProfileSyncOutcome::Skipped(reason)) => Err(refused(reason.to_string())),
|
||||
Err(e) => Err(refused(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an S3 `lastModified` (RFC3339) into unix seconds.
|
||||
fn rfc3339_secs(value: &str) -> Option<u64> {
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
@@ -678,7 +748,7 @@ impl SyncEngine {
|
||||
.ok_or_else(|| SyncError::InvalidData(format!("Profile {profile_id} not found")))?;
|
||||
let profile = ¤t_profile;
|
||||
|
||||
if profile.is_cross_os() {
|
||||
if !syncs_browser_files(profile) {
|
||||
log::debug!(
|
||||
"Profile sync: cross-OS, metadata only profile={}",
|
||||
profile.id
|
||||
@@ -1176,8 +1246,8 @@ impl SyncEngine {
|
||||
Ok(remote)
|
||||
}
|
||||
|
||||
/// Sync only metadata for cross-OS profiles (tags, notes, proxies, groups).
|
||||
/// No browser files are synced.
|
||||
/// Sync only metadata for profiles bound to another OS (tags, notes,
|
||||
/// proxies, groups). No browser files are synced.
|
||||
async fn sync_cross_os_metadata(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
@@ -2737,8 +2807,9 @@ impl SyncEngine {
|
||||
let mut profile: BrowserProfile = serde_json::from_slice(&metadata_data)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to parse metadata: {e}")))?;
|
||||
|
||||
// Cross-OS profile: save metadata only, skip manifest + file downloads
|
||||
if profile.is_cross_os() {
|
||||
// A profile bound to another OS: save metadata only, skip manifest + file
|
||||
// downloads. A Wayfern profile from any OS downloads in full below.
|
||||
if !syncs_browser_files(&profile) {
|
||||
fs::create_dir_all(&profile_dir).map_err(|e| {
|
||||
SyncError::IoError(format!(
|
||||
"Failed to create profile directory {}: {e}",
|
||||
@@ -2837,18 +2908,8 @@ impl SyncEngine {
|
||||
|
||||
// Verify critical files after download
|
||||
let os_crypt_key_path = profile_dir.join("profile").join("os_crypt_key");
|
||||
let cookies_path = {
|
||||
let network = profile_dir
|
||||
.join("profile")
|
||||
.join("Default")
|
||||
.join("Network")
|
||||
.join("Cookies");
|
||||
if network.exists() {
|
||||
network
|
||||
} else {
|
||||
profile_dir.join("profile").join("Default").join("Cookies")
|
||||
}
|
||||
};
|
||||
let cookies_path =
|
||||
crate::cookie_manager::CookieManager::wayfern_cookie_path(&profile_dir.join("profile"));
|
||||
let has_os_crypt_key = os_crypt_key_path.exists();
|
||||
let cookies_bytes = fs::metadata(&cookies_path).ok().map(|meta| meta.len());
|
||||
if !has_os_crypt_key || cookies_bytes.is_none() {
|
||||
@@ -3081,7 +3142,7 @@ impl SyncEngine {
|
||||
.list_profiles()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter(|p| p.is_cross_os() && p.is_sync_enabled())
|
||||
.filter(|p| !syncs_browser_files(p) && p.is_sync_enabled())
|
||||
.map(|p| (p.id.to_string(), p.sync_mode, p.created_by_id.clone()))
|
||||
.collect();
|
||||
|
||||
@@ -3530,7 +3591,7 @@ async fn apply_profile_sync_mode(
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
if profile.is_cross_os() {
|
||||
if !syncs_browser_files(&profile) {
|
||||
return Err("Cannot modify sync settings for a cross-OS profile".to_string());
|
||||
}
|
||||
|
||||
@@ -4742,6 +4803,127 @@ mod tests {
|
||||
assert!(tombstone_outranks_local(true, None, 501));
|
||||
}
|
||||
|
||||
fn other_host_os() -> &'static str {
|
||||
if crate::profile::types::get_host_os() == "windows" {
|
||||
"macos"
|
||||
} else {
|
||||
"windows"
|
||||
}
|
||||
}
|
||||
|
||||
fn synced_profile(browser: &str, host_os: &str) -> BrowserProfile {
|
||||
BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: browser.to_string(),
|
||||
host_os: Some(host_os.to_string()),
|
||||
sync_mode: SyncMode::Regular,
|
||||
..BrowserProfile::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wayfern_profile_from_another_os_syncs_its_browser_files() {
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
assert!(
|
||||
syncs_browser_files(&synced_profile("wayfern", os)),
|
||||
"a Wayfern profile created on {os} must sync its data here"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_bound_to_another_os_syncs_metadata_only() {
|
||||
assert!(!syncs_browser_files(&synced_profile(
|
||||
"camoufox",
|
||||
other_host_os()
|
||||
)));
|
||||
assert!(syncs_browser_files(&synced_profile(
|
||||
"camoufox",
|
||||
&crate::profile::types::get_host_os()
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_from_another_os_transfers_its_whole_directory() {
|
||||
// A profile directory as Chromium on Windows leaves it: a raw key file,
|
||||
// migrated network data, and the per-machine files sync never carries.
|
||||
let source = tempfile::TempDir::new().unwrap();
|
||||
let uuid_dir = source.path();
|
||||
let data_dir = uuid_dir.join("profile");
|
||||
let network_dir = data_dir.join("Default").join("Network");
|
||||
fs::create_dir_all(&network_dir).unwrap();
|
||||
fs::create_dir_all(data_dir.join("Default").join("Cache")).unwrap();
|
||||
fs::write(data_dir.join("os_crypt_key"), [0x42u8; 32]).unwrap();
|
||||
fs::write(data_dir.join("Local State"), b"{}").unwrap();
|
||||
fs::write(data_dir.join("Default").join("Preferences"), b"{}").unwrap();
|
||||
fs::write(network_dir.join("Cookies"), b"cookies").unwrap();
|
||||
fs::write(network_dir.join("NetworkDataMigrated"), b"").unwrap();
|
||||
fs::write(data_dir.join("SingletonLock"), b"host-1234").unwrap();
|
||||
fs::write(data_dir.join("Default").join("LOCK"), b"").unwrap();
|
||||
fs::write(data_dir.join("Default").join("Cache").join("data_0"), b"").unwrap();
|
||||
fs::write(uuid_dir.join("metadata.json"), b"{}").unwrap();
|
||||
|
||||
let mut cache = HashCache::default();
|
||||
let remote = generate_manifest("p", uuid_dir, &mut cache).unwrap();
|
||||
let mut uploaded: Vec<&str> = remote.files.iter().map(|f| f.path.as_str()).collect();
|
||||
uploaded.sort_unstable();
|
||||
assert_eq!(
|
||||
uploaded,
|
||||
vec![
|
||||
"profile/Default/Network/Cookies",
|
||||
"profile/Default/Network/NetworkDataMigrated",
|
||||
"profile/Default/Preferences",
|
||||
"profile/Local State",
|
||||
"profile/os_crypt_key",
|
||||
],
|
||||
"every data file syncs and only the per-machine files stay behind"
|
||||
);
|
||||
|
||||
// The machine on the other OS holds metadata alone; it pulls everything.
|
||||
let target = tempfile::TempDir::new().unwrap();
|
||||
fs::write(target.path().join("metadata.json"), b"{}").unwrap();
|
||||
let mut target_cache = HashCache::default();
|
||||
let local = generate_manifest("p", target.path(), &mut target_cache).unwrap();
|
||||
let diff = compute_diff_with_bias(&local, Some(&remote), DiffBias::Auto);
|
||||
assert_eq!(diff.files_to_download.len(), remote.files.len());
|
||||
assert!(diff.files_to_upload.is_empty());
|
||||
assert!(diff.files_to_delete_remote.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_metadata_only_copy_from_another_os_awaits_its_first_download() {
|
||||
let profiles_dir = tempfile::TempDir::new().unwrap();
|
||||
let profile = synced_profile("wayfern", other_host_os());
|
||||
let uuid_dir = profiles_dir.path().join(profile.id.to_string());
|
||||
fs::create_dir_all(&uuid_dir).unwrap();
|
||||
fs::write(uuid_dir.join("metadata.json"), b"{}").unwrap();
|
||||
assert!(awaits_first_download(&profile, profiles_dir.path()));
|
||||
|
||||
// An empty directory tree is still nothing to launch on.
|
||||
let data_dir = profile.get_profile_data_path(profiles_dir.path());
|
||||
fs::create_dir_all(data_dir.join("Default")).unwrap();
|
||||
assert!(awaits_first_download(&profile, profiles_dir.path()));
|
||||
|
||||
// Once any file is there, the launch goes ahead as for any profile.
|
||||
fs::write(data_dir.join("Local State"), b"{}").unwrap();
|
||||
assert!(!awaits_first_download(&profile, profiles_dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_synced_profile_from_another_os_waits_for_a_download() {
|
||||
let profiles_dir = tempfile::TempDir::new().unwrap();
|
||||
// A new profile created here launches on an empty directory.
|
||||
let local = synced_profile("wayfern", &crate::profile::types::get_host_os());
|
||||
assert!(!awaits_first_download(&local, profiles_dir.path()));
|
||||
// Without sync there is nothing to download.
|
||||
let mut unsynced = synced_profile("wayfern", other_host_os());
|
||||
unsynced.sync_mode = SyncMode::Disabled;
|
||||
assert!(!awaits_first_download(&unsynced, profiles_dir.path()));
|
||||
// A profile bound to another OS never launches here at all.
|
||||
let bound = synced_profile("camoufox", other_host_os());
|
||||
assert!(!awaits_first_download(&bound, profiles_dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rfc3339_secs() {
|
||||
assert_eq!(rfc3339_secs("1970-01-01T00:00:00Z"), Some(0));
|
||||
|
||||
@@ -13,9 +13,10 @@ pub use encryption::{
|
||||
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
|
||||
};
|
||||
pub use engine::{
|
||||
cancel_profile_sync, enable_extension_group_sync_if_needed, enable_group_sync_if_needed,
|
||||
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,
|
||||
awaits_first_download, cancel_profile_sync, download_before_first_launch,
|
||||
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, 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,
|
||||
pull_profile_after_remote_session, request_profile_sync, resume_profile_sync,
|
||||
|
||||
Reference in new issue
Block a user