chore: add cross-platform webdriver tests

This commit is contained in:
zhom
2026-07-20 03:26:31 +04:00
parent 8fe38453d4
commit f7daf68b52
29 changed files with 4997 additions and 558 deletions
+221 -70
View File
@@ -442,7 +442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
dependencies = [
"atk-sys",
"glib",
"glib 0.18.5",
"libc",
]
@@ -452,10 +452,10 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
dependencies = [
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -912,7 +912,7 @@ checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.13.0",
"cairo-sys-rs",
"glib",
"glib 0.18.5",
"libc",
"once_cell",
"thiserror 1.0.69",
@@ -924,9 +924,9 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
dependencies = [
"glib-sys",
"glib-sys 0.18.1",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -1027,7 +1027,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
dependencies = [
"smallvec",
"target-lexicon",
"target-lexicon 0.12.16",
]
[[package]]
name = "cfg-expr"
version = "0.20.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c"
dependencies = [
"smallvec",
"target-lexicon 0.13.5",
]
[[package]]
@@ -1857,6 +1867,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-cross-platform-webdriver",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-fs",
@@ -2470,7 +2481,7 @@ dependencies = [
"gdk-pixbuf",
"gdk-sys",
"gio",
"glib",
"glib 0.18.5",
"libc",
"pango",
]
@@ -2483,7 +2494,7 @@ checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
dependencies = [
"gdk-pixbuf-sys",
"gio",
"glib",
"glib 0.18.5",
"libc",
"once_cell",
]
@@ -2494,11 +2505,11 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
dependencies = [
"gio-sys",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -2509,13 +2520,13 @@ checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"pango-sys",
"pkg-config",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -2525,11 +2536,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
dependencies = [
"gdk-sys",
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"pkg-config",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -2541,7 +2552,7 @@ dependencies = [
"gdk",
"gdkx11-sys",
"gio",
"glib",
"glib 0.18.5",
"libc",
"x11",
]
@@ -2553,9 +2564,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
dependencies = [
"gdk-sys",
"glib-sys",
"glib-sys 0.18.1",
"libc",
"system-deps",
"system-deps 6.2.2",
"x11",
]
@@ -2653,8 +2664,8 @@ dependencies = [
"futures-core",
"futures-io",
"futures-util",
"gio-sys",
"glib",
"gio-sys 0.18.1",
"glib 0.18.5",
"libc",
"once_cell",
"pin-project-lite",
@@ -2668,13 +2679,26 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
dependencies = [
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
"winapi",
]
[[package]]
name = "gio-sys"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22"
dependencies = [
"glib-sys 0.21.5",
"gobject-sys 0.21.5",
"libc",
"system-deps 7.0.8",
"windows-sys 0.61.2",
]
[[package]]
name = "glib"
version = "0.18.5"
@@ -2687,10 +2711,10 @@ dependencies = [
"futures-executor",
"futures-task",
"futures-util",
"gio-sys",
"glib-macros",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-macros 0.18.5",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"memchr",
"once_cell",
@@ -2698,6 +2722,27 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "glib"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b"
dependencies = [
"bitflags 2.13.0",
"futures-channel",
"futures-core",
"futures-executor",
"futures-task",
"futures-util",
"gio-sys 0.21.5",
"glib-macros 0.21.5",
"glib-sys 0.21.5",
"gobject-sys 0.21.5",
"libc",
"memchr",
"smallvec",
]
[[package]]
name = "glib-macros"
version = "0.18.5"
@@ -2712,6 +2757,19 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "glib-macros"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17"
dependencies = [
"heck 0.5.0",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "glib-sys"
version = "0.18.1"
@@ -2719,7 +2777,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
dependencies = [
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
name = "glib-sys"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c"
dependencies = [
"libc",
"system-deps 7.0.8",
]
[[package]]
@@ -2747,9 +2815,20 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
dependencies = [
"glib-sys",
"glib-sys 0.18.1",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
name = "gobject-sys"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294"
dependencies = [
"glib-sys 0.21.5",
"libc",
"system-deps 7.0.8",
]
[[package]]
@@ -2765,7 +2844,7 @@ dependencies = [
"gdk",
"gdk-pixbuf",
"gio",
"glib",
"glib 0.18.5",
"gtk-sys",
"gtk3-macros",
"libc",
@@ -2783,12 +2862,12 @@ dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gdk-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"pango-sys",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -3427,7 +3506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
dependencies = [
"bitflags 1.3.2",
"glib",
"glib 0.18.5",
"javascriptcore-rs-sys",
]
@@ -3437,10 +3516,10 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
dependencies = [
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -3596,7 +3675,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
dependencies = [
"glib",
"glib 0.18.5",
"gtk",
"gtk-sys",
"libappindicator-sys",
@@ -4278,6 +4357,16 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-javascript-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586"
dependencies = [
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-open-directory"
version = "0.3.2"
@@ -4301,6 +4390,17 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-security"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
@@ -4344,6 +4444,8 @@ dependencies = [
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-javascript-core",
"objc2-security",
]
[[package]]
@@ -4461,7 +4563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
dependencies = [
"gio",
"glib",
"glib 0.18.5",
"libc",
"once_cell",
"pango-sys",
@@ -4473,10 +4575,10 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
dependencies = [
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -5290,8 +5392,8 @@ checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gtk-sys",
"js-sys",
"log",
@@ -6149,7 +6251,7 @@ checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
dependencies = [
"futures-channel",
"gio",
"glib",
"glib 0.18.5",
"libc",
"soup3-sys",
]
@@ -6160,11 +6262,11 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
dependencies = [
"gio-sys",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -6363,13 +6465,26 @@ version = "6.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
dependencies = [
"cfg-expr",
"cfg-expr 0.15.8",
"heck 0.5.0",
"pkg-config",
"toml 0.8.2",
"version-compare",
]
[[package]]
name = "system-deps"
version = "7.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
dependencies = [
"cfg-expr 0.20.8",
"heck 0.5.0",
"pkg-config",
"toml 1.1.2+spec-1.1.0",
"version-compare",
]
[[package]]
name = "tao"
version = "0.35.3"
@@ -6444,6 +6559,12 @@ version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tauri"
version = "2.11.5"
@@ -6589,6 +6710,36 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-cross-platform-webdriver"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"block2",
"cairo-rs",
"glib 0.21.5",
"gtk",
"javascriptcore-rs",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"objc2-web-kit",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tempfile",
"tokio",
"tracing",
"uuid",
"webkit2gtk",
"webview2-com",
"windows 0.61.3",
"windows-core 0.61.2",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.9"
@@ -7032,9 +7183,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.3"
version = "1.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
dependencies = [
"bytes",
"libc",
@@ -7694,9 +7845,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.23.4"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -7964,10 +8115,10 @@ dependencies = [
"gdk",
"gdk-sys",
"gio",
"gio-sys",
"glib",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib 0.18.5",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gtk",
"gtk-sys",
"javascriptcore-rs",
@@ -7986,15 +8137,15 @@ dependencies = [
"bitflags 1.3.2",
"cairo-sys-rs",
"gdk-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gtk-sys",
"javascriptcore-rs-sys",
"libc",
"pkg-config",
"soup3-sys",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
+2
View File
@@ -42,6 +42,7 @@ tauri-plugin-macos-permissions = "2"
tauri-plugin-log = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-window-state = "2"
tauri-plugin-cross-platform-webdriver = { path = "../../tauri-cross-platform-webdriver/crates/tauri-plugin-cross-platform-webdriver", optional = true }
log = "0.4"
env_logger = "0.11"
@@ -186,3 +187,4 @@ default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]
e2e = ["dep:tauri-plugin-cross-platform-webdriver"]
+16
View File
@@ -2037,6 +2037,14 @@ rm "{}"
#[tauri::command]
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
#[cfg(feature = "e2e")]
if tauri_plugin_cross_platform_webdriver::automation_enabled()
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
{
log::info!("E2E: skipping automatic app update check");
return Ok(None);
}
if crate::app_dirs::is_portable() {
log::info!("App auto-updates disabled in portable mode");
return Ok(None);
@@ -2090,6 +2098,14 @@ pub async fn restart_application() -> Result<(), String> {
#[tauri::command]
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
#[cfg(feature = "e2e")]
if tauri_plugin_cross_platform_webdriver::automation_enabled()
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
{
log::info!("E2E: skipping manual app update check");
return Ok(None);
}
log::info!("Manual app update check triggered");
let updater = AppAutoUpdater::instance();
updater
+24
View File
@@ -779,6 +779,13 @@ impl CloudAuthManager {
/// Launch/drive profiles programmatically (local API + MCP automation).
pub async fn can_use_browser_automation(&self) -> bool {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled()
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
{
return true;
}
self
.entitlements()
.await
@@ -788,6 +795,13 @@ impl CloudAuthManager {
/// Edit fingerprints / use a non-native OS fingerprint.
pub async fn can_use_cross_os_fingerprints(&self) -> bool {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled()
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
{
return true;
}
self
.entitlements()
.await
@@ -1224,6 +1238,16 @@ impl CloudAuthManager {
/// Get the current wayfern token, if any.
pub async fn get_wayfern_token(&self) -> Option<String> {
#[cfg(feature = "e2e")]
if tauri_plugin_cross_platform_webdriver::automation_enabled() {
if let Some(token) = std::env::var_os("WAYFERN_TEST_TOKEN")
.filter(|token| !token.is_empty())
.and_then(|token| token.into_string().ok())
{
return Some(token);
}
}
let wt = self.wayfern_token.lock().await;
wt.clone()
}
+16 -2
View File
@@ -295,9 +295,23 @@ impl BlocklistManager {
}
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
let url = level
let production_url = level
.url()
.ok_or_else(|| format!("No URL for level {:?}", level))?;
#[cfg(feature = "e2e")]
let url = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL")
.ok()
.filter(|base| !base.is_empty())
.map(|base| {
format!(
"{}/{}",
base.trim_end_matches('/'),
level.filename().unwrap_or("blocklist.txt")
)
})
.unwrap_or_else(|| production_url.to_string());
#[cfg(not(feature = "e2e"))]
let url = production_url.to_string();
let path =
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
@@ -311,7 +325,7 @@ impl BlocklistManager {
);
let response = HTTP_CLIENT
.get(url)
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
@@ -1261,6 +1261,14 @@ mod tests {
pub async fn ensure_active_browsers_downloaded(
app_handle: tauri::AppHandle,
) -> Result<Vec<String>, String> {
#[cfg(feature = "e2e")]
if tauri_plugin_cross_platform_webdriver::automation_enabled()
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
{
log::info!("E2E: skipping proactive browser download");
return Ok(Vec::new());
}
let registry = DownloadedBrowsersRegistry::instance();
let version_manager = crate::browser_version_manager::BrowserVersionManager::instance();
let mut downloaded = Vec::new();
@@ -1410,6 +1418,14 @@ pub async fn check_missing_binaries() -> Result<Vec<(String, String, String)>, S
pub async fn ensure_all_binaries_exist(
app_handle: tauri::AppHandle,
) -> Result<Vec<String>, String> {
#[cfg(feature = "e2e")]
if tauri_plugin_cross_platform_webdriver::automation_enabled()
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
{
log::info!("E2E: skipping proactive binary and GeoIP downloads");
return Ok(Vec::new());
}
let registry = DownloadedBrowsersRegistry::instance();
registry
.ensure_all_binaries_exist(&app_handle)
+15 -6
View File
@@ -141,13 +141,22 @@ impl GeoIPDownloader {
},
);
// Fetch latest release from GitHub
let releases = self.fetch_geoip_releases().await?;
let latest_release = releases.first().ok_or("No GeoIP database releases found")?;
#[cfg(feature = "e2e")]
let fixture_url = std::env::var("DONUT_E2E_GEOIP_DOWNLOAD_URL")
.ok()
.filter(|url| !url.is_empty());
#[cfg(not(feature = "e2e"))]
let fixture_url: Option<String> = None;
let download_url = self
.find_city_mmdb_asset(latest_release)
.ok_or("No compatible GeoIP database asset found")?;
let download_url = if let Some(url) = fixture_url {
url
} else {
let releases = self.fetch_geoip_releases().await?;
let latest_release = releases.first().ok_or("No GeoIP database releases found")?;
self
.find_city_mmdb_asset(latest_release)
.ok_or("No compatible GeoIP database asset found")?
};
// Create cache directory
let cache_dir = Self::get_cache_dir();
+207 -187
View File
@@ -3,6 +3,7 @@ use std::env;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
#[cfg(not(feature = "e2e"))]
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_log::{Target, TargetKind};
@@ -14,6 +15,17 @@ static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
// to the confirmation dialog.
static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false);
fn e2e_automation_enabled() -> bool {
#[cfg(feature = "e2e")]
{
tauri_plugin_cross_platform_webdriver::automation_enabled()
}
#[cfg(not(feature = "e2e"))]
{
false
}
}
mod api_client;
mod api_server;
mod app_auto_updater;
@@ -1263,6 +1275,7 @@ fn hide_to_tray(app_handle: tauri::AppHandle) -> Result<(), String> {
Ok(())
}
#[cfg(not(feature = "e2e"))]
fn show_main_window(app_handle: &tauri::AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
@@ -1302,6 +1315,7 @@ fn update_tray_menu(
/// Build the system tray. Best-effort: on Linux the tray depends on
/// libayatana-appindicator at runtime, so any failure here must not abort app
/// startup — the caller logs and continues without a tray.
#[cfg(not(feature = "e2e"))]
fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
use std::sync::atomic::Ordering;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
@@ -1391,54 +1405,65 @@ pub fn run() {
}),
};
tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::new()
.clear_targets() // Clear default targets to avoid duplicates
.target(Target::new(TargetKind::Stdout))
.target(Target::new(TargetKind::Webview))
.target(file_log_target)
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
// truncated useful context in customer support reports; 50 MB
// turned out to be excessive disk pressure.
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
.level(log::LevelFilter::Info)
.format(|out, message, record| {
use chrono::Local;
let now = Local::now();
let timestamp = format!(
"{}.{:03}",
now.format("%Y-%m-%d %H:%M:%S"),
now.timestamp_subsec_millis()
);
out.finish(format_args!(
"[{}][{}][{}] {}",
timestamp,
record.target(),
record.level(),
message
))
})
.build(),
)
.plugin(tauri_plugin_single_instance::init(
|app_handle, args, _cwd| {
log::info!("Single instance triggered with args: {args:?}");
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
let _ = window.unminimize();
}
},
))
let builder = tauri::Builder::default();
#[cfg(feature = "e2e")]
let builder = builder.plugin(tauri_plugin_cross_platform_webdriver::init());
let builder = builder.plugin(
tauri_plugin_log::Builder::new()
.clear_targets() // Clear default targets to avoid duplicates
.target(Target::new(TargetKind::Stdout))
.target(Target::new(TargetKind::Webview))
.target(file_log_target)
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
// truncated useful context in customer support reports; 50 MB
// turned out to be excessive disk pressure.
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
.level(log::LevelFilter::Info)
.format(|out, message, record| {
use chrono::Local;
let now = Local::now();
let timestamp = format!(
"{}.{:03}",
now.format("%Y-%m-%d %H:%M:%S"),
now.timestamp_subsec_millis()
);
out.finish(format_args!(
"[{}][{}][{}] {}",
timestamp,
record.target(),
record.level(),
message
))
})
.build(),
);
#[cfg(not(feature = "e2e"))]
let builder = builder.plugin(tauri_plugin_single_instance::init(
|app_handle, args, _cwd| {
log::info!("Single instance triggered with args: {args:?}");
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
let _ = window.unminimize();
}
},
));
let builder = builder
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_macos_permissions::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_clipboard_manager::init());
#[cfg(not(feature = "e2e"))]
let builder = builder
// Persist window size/position across restarts. VISIBLE is excluded
// because the app hides to tray: restoring visibility would otherwise
// relaunch with an invisible window after quitting from the tray while
@@ -1453,8 +1478,9 @@ pub fn run() {
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
)
.build(),
)
.setup(|app| {
);
builder.setup(|app| {
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
ephemeral_dirs::recover_ephemeral_dirs();
@@ -1476,6 +1502,20 @@ pub fn run() {
.focused(true)
.visible(true);
#[cfg(feature = "e2e")]
let win_builder =
match tauri_plugin_cross_platform_webdriver::automation_profile_dir() {
Some(profile_dir) => win_builder
.data_directory(profile_dir.join("webview"))
// WKWebView ignores data_directory on macOS. Incognito gives every
// launched app process a non-persistent data store there, and also
// prevents WebView2/WebKitGTK caches from escaping the session on
// the other platforms. Durable app state is still exercised via
// DONUTBROWSER_DATA_ROOT; only browser-engine storage is ephemeral.
.incognito(true),
None => win_builder,
};
#[cfg(target_os = "windows")]
let win_builder = win_builder.decorations(false);
@@ -1486,8 +1526,11 @@ pub fn run() {
// dialog's "Minimize" action hides the window. Best-effort: a tray
// failure (e.g. missing libayatana-appindicator on Linux) must never
// prevent the app from launching, so we log and continue without it.
if let Err(e) = setup_system_tray(app.handle()) {
log::warn!("System tray unavailable, continuing without it: {e}");
#[cfg(not(feature = "e2e"))]
{
if let Err(e) = setup_system_tray(app.handle()) {
log::warn!("System tray unavailable, continuing without it: {e}");
}
}
// Intercept the window close so the frontend can ask the user whether
@@ -1530,7 +1573,7 @@ pub fn run() {
log::warn!("Failed to set global event emitter: {e}");
}
#[cfg(windows)]
#[cfg(all(windows, not(feature = "e2e")))]
{
// For Windows, register all deep links at runtime
if let Err(e) = app.deep_link().register_all() {
@@ -1538,7 +1581,7 @@ pub fn run() {
}
}
#[cfg(target_os = "macos")]
#[cfg(all(target_os = "macos", not(feature = "e2e")))]
{
// On macOS, try to register deep links for development builds
if let Err(e) = app.deep_link().register_all() {
@@ -1548,28 +1591,28 @@ pub fn run() {
}
}
app.deep_link().on_open_url({
let handle = handle.clone();
move |event| {
let urls = event.urls();
log::info!("Deep link event received with {} URLs", urls.len());
#[cfg(not(feature = "e2e"))]
{
app.deep_link().on_open_url({
let handle = handle.clone();
move |event| {
let urls = event.urls();
log::info!("Deep link event received with {} URLs", urls.len());
for url in urls {
let url_string = url.to_string();
log::info!("Deep link received: {url_string}");
for url in urls {
let url_string = url.to_string();
log::info!("Deep link received: {url_string}");
let handle_clone = handle.clone();
// Clone the handle for each async task
let handle_clone = handle.clone();
// Handle the URL asynchronously
tauri::async_runtime::spawn(async move {
if let Err(e) = handle_url_open(handle_clone, url_string.clone()).await {
log::error!("Failed to handle deep link URL: {e}");
}
});
tauri::async_runtime::spawn(async move {
if let Err(e) = handle_url_open(handle_clone, url_string.clone()).await {
log::error!("Failed to handle deep link URL: {e}");
}
});
}
}
}
});
});
}
if let Some(startup_url) = startup_url {
let handle_clone = handle.clone();
@@ -1581,30 +1624,29 @@ pub fn run() {
});
}
// Initialize and start background version updater
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let version_updater = get_version_updater();
if !e2e_automation_enabled() {
// Initialize and start background version updater
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let version_updater = get_version_updater();
// Set the app handle
{
let mut updater_guard = version_updater.lock().await;
updater_guard.set_app_handle(app_handle);
}
// Run startup check without holding the lock
{
let updater_guard = version_updater.lock().await;
if let Err(e) = updater_guard.start_background_updates().await {
log::error!("Failed to start background updates: {e}");
{
let mut updater_guard = version_updater.lock().await;
updater_guard.set_app_handle(app_handle);
}
}
});
// Start the background update task separately
tauri::async_runtime::spawn(async move {
version_updater::VersionUpdater::run_background_task().await;
});
{
let updater_guard = version_updater.lock().await;
if let Err(e) = updater_guard.start_background_updates().await {
log::error!("Failed to start background updates: {e}");
}
}
});
tauri::async_runtime::spawn(async move {
version_updater::VersionUpdater::run_background_task().await;
});
}
// Auto-start MCP server if it was previously enabled. Always log the
// decision so customer logs reveal whether MCP is actually running —
@@ -1765,12 +1807,12 @@ pub fn run() {
}
}
let app_handle_auto_updater = app.handle().clone();
// Start the auto-update check task separately
tauri::async_runtime::spawn(async move {
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
});
if !e2e_automation_enabled() {
let app_handle_auto_updater = app.handle().clone();
tauri::async_runtime::spawn(async move {
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
});
}
// Handle any pending URLs that were received before the window was ready
let handle_pending = handle.clone();
@@ -1793,107 +1835,85 @@ pub fn run() {
}
});
// Start periodic cleanup task for unused binaries
// Only runs when sync is not in progress to avoid deleting browsers
// that might be needed for profiles being synced from the cloud
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200)); // Every 12 hours
loop {
interval.tick().await;
// Check if sync is in progress before running cleanup
if let Some(scheduler) = sync::get_global_scheduler() {
if scheduler.is_sync_in_progress().await {
log::debug!("Skipping cleanup: sync is in progress");
continue;
}
}
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::error!("Periodic cleanup failed: {e}");
} else {
log::debug!("Periodic cleanup completed successfully");
}
}
});
// DNS blocklist refresh task (every 12 hours)
tauri::async_runtime::spawn(async move {
let manager = dns_blocklist::BlocklistManager::instance();
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
interval.tick().await; // Skip the immediate first tick
loop {
interval.tick().await;
manager.refresh_all_stale().await;
}
});
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60));
loop {
interval.tick().await;
log::info!("Checking for app updates...");
// Route through check_for_app_updates (not the raw check_for_updates)
// so the background loop respects portable mode and the
// disable_auto_updates setting. Previously it bypassed both, so a
// portable install would auto-download and run the NSIS installer,
// clobbering the portable folder instead of updating in place.
match app_auto_updater::check_for_app_updates().await {
Ok(Some(update_info)) => {
log::info!(
"App update available: {} -> {}",
update_info.current_version,
update_info.new_version
);
if let Err(e) = events::emit("app-update-available", &update_info) {
log::error!("Failed to emit app update event: {e}");
if !e2e_automation_enabled() {
// Start periodic cleanup task for unused binaries.
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
loop {
interval.tick().await;
if let Some(scheduler) = sync::get_global_scheduler() {
if scheduler.is_sync_in_progress().await {
log::debug!("Skipping cleanup: sync is in progress");
continue;
}
}
Ok(None) => {
log::debug!("No app updates available");
}
Err(e) => {
log::error!("Failed to check for app updates: {e}");
}
}
}
});
// Check and download GeoIP database at startup if needed
let app_handle_geoip = app.handle().clone();
tauri::async_runtime::spawn(async move {
// Wait a bit for the app to fully initialize
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let geoip_downloader = crate::geoip_downloader::GeoIPDownloader::instance();
match geoip_downloader.check_missing_geoip_database() {
Ok(true) => {
log::info!(
"GeoIP database is missing for Wayfern profiles, downloading at startup..."
);
let geoip_downloader = GeoIPDownloader::instance();
if let Err(e) = geoip_downloader
.download_geoip_database(&app_handle_geoip)
.await
{
log::error!("Failed to download GeoIP database at startup: {e}");
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::error!("Periodic cleanup failed: {e}");
} else {
log::info!("GeoIP database downloaded successfully at startup");
log::debug!("Periodic cleanup completed successfully");
}
}
Ok(false) => {
// No Wayfern profiles or GeoIP database already available
});
tauri::async_runtime::spawn(async move {
let manager = dns_blocklist::BlocklistManager::instance();
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
interval.tick().await;
loop {
interval.tick().await;
manager.refresh_all_stale().await;
}
Err(e) => {
log::error!("Failed to check GeoIP database status at startup: {e}");
});
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60));
loop {
interval.tick().await;
log::info!("Checking for app updates...");
match app_auto_updater::check_for_app_updates().await {
Ok(Some(update_info)) => {
log::info!(
"App update available: {} -> {}",
update_info.current_version,
update_info.new_version
);
if let Err(e) = events::emit("app-update-available", &update_info) {
log::error!("Failed to emit app update event: {e}");
}
}
Ok(None) => log::debug!("No app updates available"),
Err(e) => log::error!("Failed to check for app updates: {e}"),
}
}
}
});
});
let app_handle_geoip = app.handle().clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let geoip_downloader = crate::geoip_downloader::GeoIPDownloader::instance();
match geoip_downloader.check_missing_geoip_database() {
Ok(true) => {
log::info!(
"GeoIP database is missing for Wayfern profiles, downloading at startup..."
);
let geoip_downloader = GeoIPDownloader::instance();
if let Err(e) = geoip_downloader
.download_geoip_database(&app_handle_geoip)
.await
{
log::error!("Failed to download GeoIP database at startup: {e}");
} else {
log::info!("GeoIP database downloaded successfully at startup");
}
}
Ok(false) => {}
Err(e) => log::error!("Failed to check GeoIP database status at startup: {e}"),
}
});
}
// Start proxy cleanup task for dead browser processes
let app_handle_proxy_cleanup = app.handle().clone();
+120 -150
View File
@@ -492,6 +492,9 @@ impl SyncEngine {
return Ok(());
}
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
let profile = &reconciled_profile;
// Derive encryption key if encrypted sync
let encryption_key = if profile.is_encrypted_sync() {
let password = encryption::load_e2e_password()
@@ -697,11 +700,6 @@ impl SyncEngine {
log::debug!("Deleted remote file: {}", path);
}
// Upload metadata.json (sanitized profile)
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
// If this sync changed the local profile directory (downloaded files and/or
// deleted local files), the manifest generated at the START of the sync is
// now stale. Uploading it would advertise wrong hashes/mtimes for the files
@@ -747,37 +745,18 @@ impl SyncEngine {
let _ = self.sync_vpn(vpn_id, Some(app_handle)).await;
}
// Download remote metadata and merge changes (name, tags, notes, etc.)
let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await {
let mut updated_profile = profile.clone();
// Merge fields that can be changed on other devices
updated_profile.name = remote_meta.name;
updated_profile.tags = remote_meta.tags;
updated_profile.note = remote_meta.note;
updated_profile.proxy_id = remote_meta.proxy_id;
updated_profile.vpn_id = remote_meta.vpn_id;
updated_profile.group_id = remote_meta.group_id;
updated_profile.extension_group_id = remote_meta.extension_group_id;
updated_profile.window_color = remote_meta.window_color;
updated_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
let _ = profile_manager.save_profile(&updated_profile);
} else {
// Fallback: just update last_sync
let mut updated_profile = profile.clone();
updated_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
let _ = profile_manager.save_profile(&updated_profile);
}
let mut updated_profile = profile.clone();
updated_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
profile_manager
.save_profile(&updated_profile)
.map_err(|e| {
SyncError::IoError(format!("Failed to save reconciled profile metadata: {e}"))
})?;
let _ = events::emit("profiles-changed", ());
let _ = events::emit(
@@ -881,6 +860,45 @@ impl SyncEngine {
Ok(profile)
}
async fn reconcile_profile_metadata(
&self,
profile: &BrowserProfile,
) -> SyncResult<BrowserProfile> {
let profile_id = profile.id.to_string();
let key_prefix = Self::get_team_key_prefix(profile).await;
let remote_key = format!("{key_prefix}profiles/{profile_id}/metadata.json");
let stat = self.client.stat(&remote_key).await?;
if !stat.exists {
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
return Ok(profile.clone());
}
let local_updated = profile.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if local_updated > remote_updated {
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
return Ok(profile.clone());
}
if remote_updated <= local_updated {
return Ok(profile.clone());
}
let mut remote = self.download_profile_metadata(&remote_key).await?;
// Process state is device-local and deliberately stripped from uploads.
remote.process_id = profile.process_id;
remote.last_launch = profile.last_launch;
remote.last_sync = profile.last_sync;
ProfileManager::instance()
.save_profile(&remote)
.map_err(|e| SyncError::IoError(format!("Failed to save remote profile metadata: {e}")))?;
Ok(remote)
}
/// Sync only metadata for cross-OS profiles (tags, notes, proxies, groups).
/// No browser files are synced.
async fn sync_cross_os_metadata(
@@ -889,34 +907,8 @@ impl SyncEngine {
profile: &BrowserProfile,
) -> SyncResult<()> {
let profile_id = profile.id.to_string();
let key_prefix = Self::get_team_key_prefix(profile).await;
let profile_manager = ProfileManager::instance();
// Upload our metadata
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
// Download remote metadata and merge if remote has changes
let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await {
let mut updated = profile.clone();
updated.name = remote_meta.name;
updated.tags = remote_meta.tags;
updated.note = remote_meta.note;
updated.proxy_id = remote_meta.proxy_id;
updated.vpn_id = remote_meta.vpn_id;
updated.group_id = remote_meta.group_id;
updated.extension_group_id = remote_meta.extension_group_id;
updated.window_color = remote_meta.window_color;
updated.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
let _ = profile_manager.save_profile(&updated);
}
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
let profile = &reconciled_profile;
// Sync associated entities
if let Some(proxy_id) = &profile.proxy_id {
@@ -954,18 +946,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&sanitized)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal profile metadata: {e}")))?;
let remote_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, sanitized.updated_at.unwrap_or(0))
.await?;
Ok(())
@@ -4023,28 +4006,55 @@ pub async fn rollover_encryption_for_all_entities(
) -> Result<(), String> {
let _ = events::emit("e2e-rollover-started", ());
let internal_error = |detail: String| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": detail } }).to_string()
};
let engine = SyncEngine::create_from_settings(&app_handle)
.await
.map_err(&internal_error)?;
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
.map_err(|e| internal_error(format!("Failed to list profiles: {e}")))?;
let synced_profiles: Vec<_> = profiles
.iter()
.filter(|p| p.sync_mode != SyncMode::Disabled)
.collect();
let total_profiles = synced_profiles.len();
let mut running_profile_ids: std::collections::HashSet<uuid::Uuid> =
std::collections::HashSet::new();
if synced_profiles
.iter()
.any(|profile| profile.process_id.is_some())
{
return Err(serde_json::json!({ "code": "PROFILE_RUNNING" }).to_string());
}
let total_profiles = synced_profiles.len();
for (i, profile) in synced_profiles.iter().enumerate() {
if profile.process_id.is_some() {
running_profile_ids.insert(profile.id);
}
let id_str = profile.id.to_string();
if let Err(e) = trigger_sync_for_profile(app_handle.clone(), id_str.clone()).await {
log::warn!("Rollover: profile {} re-sync failed: {e}", id_str);
}
// The remote manifest may be encrypted with the previous password. Delete
// only that manifest so the normal sync path treats every local file as an
// upload and rewrites it with the current password. Existing remote files
// remain available until their replacements have uploaded.
let key_prefix = SyncEngine::get_team_key_prefix(profile).await;
engine
.upload_profile_metadata(&id_str, profile, &key_prefix)
.await
.map_err(|e| {
internal_error(format!(
"Failed to roll over profile metadata {id_str}: {e}"
))
})?;
let manifest_key = format!("{key_prefix}profiles/{id_str}/manifest.json");
engine
.client
.delete(&manifest_key, None)
.await
.map_err(|e| internal_error(format!("Failed to reset profile manifest: {e}")))?;
engine
.sync_profile(&app_handle, profile)
.await
.map_err(|e| internal_error(format!("Failed to roll over profile {id_str}: {e}")))?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({
@@ -4055,37 +4065,14 @@ pub async fn rollover_encryption_for_all_entities(
);
}
// Determine which entity ids are referenced by running profiles, so we can
// defer their re-upload (changing their files mid-session would cause the
// running browser to see a different proxy/extension config than what it
// launched with).
let mut deferred_proxy_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut deferred_vpn_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut deferred_group_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
for p in &profiles {
if running_profile_ids.contains(&p.id) {
if let Some(id) = &p.proxy_id {
deferred_proxy_ids.insert(id.clone());
}
if let Some(id) = &p.vpn_id {
deferred_vpn_ids.insert(id.clone());
}
if let Some(id) = &p.group_id {
deferred_group_ids.insert(id.clone());
}
}
}
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
let synced_proxies: Vec<_> = proxies.iter().filter(|p| p.sync_enabled).collect();
let total_proxies = synced_proxies.len();
let mut deferred = Vec::new();
for (i, proxy) in synced_proxies.iter().enumerate() {
if deferred_proxy_ids.contains(&proxy.id) {
deferred.push(proxy.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_proxy_sync(proxy.id.clone()).await;
}
engine
.upload_proxy(proxy)
.await
.map_err(|e| internal_error(format!("Failed to roll over proxy {}: {e}", proxy.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "proxies", "done": i + 1, "total": total_proxies}),
@@ -4095,17 +4082,15 @@ pub async fn rollover_encryption_for_all_entities(
let groups = {
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
gm.get_all_groups()
.map_err(|e| format!("Failed to get groups: {e}"))?
.map_err(|e| internal_error(format!("Failed to get groups: {e}")))?
};
let synced_groups: Vec<_> = groups.iter().filter(|g| g.sync_enabled).collect();
let total_groups = synced_groups.len();
let mut deferred_groups = Vec::new();
for (i, group) in synced_groups.iter().enumerate() {
if deferred_group_ids.contains(&group.id) {
deferred_groups.push(group.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_group_sync(group.id.clone()).await;
}
engine
.upload_group(group)
.await
.map_err(|e| internal_error(format!("Failed to roll over group {}: {e}", group.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "groups", "done": i + 1, "total": total_groups}),
@@ -4116,17 +4101,15 @@ pub async fn rollover_encryption_for_all_entities(
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.list_configs()
.map_err(|e| format!("Failed to list VPN configs: {e}"))?
.map_err(|e| internal_error(format!("Failed to list VPN configs: {e}")))?
};
let synced_vpns: Vec<_> = vpns.iter().filter(|v| v.sync_enabled).collect();
let total_vpns = synced_vpns.len();
let mut deferred_vpns = Vec::new();
for (i, config) in synced_vpns.iter().enumerate() {
if deferred_vpn_ids.contains(&config.id) {
deferred_vpns.push(config.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_vpn_sync(config.id.clone()).await;
}
engine
.upload_vpn(config)
.await
.map_err(|e| internal_error(format!("Failed to roll over VPN {}: {e}", config.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "vpns", "done": i + 1, "total": total_vpns}),
@@ -4136,14 +4119,15 @@ pub async fn rollover_encryption_for_all_entities(
let extensions = {
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
em.list_extensions()
.map_err(|e| format!("Failed to list extensions: {e}"))?
.map_err(|e| internal_error(format!("Failed to list extensions: {e}")))?
};
let synced_exts: Vec<_> = extensions.iter().filter(|e| e.sync_enabled).collect();
let total_exts = synced_exts.len();
for (i, ext) in synced_exts.iter().enumerate() {
if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_extension_sync(ext.id.clone()).await;
}
engine
.upload_extension(ext)
.await
.map_err(|e| internal_error(format!("Failed to roll over extension {}: {e}", ext.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "extensions", "done": i + 1, "total": total_exts}),
@@ -4153,37 +4137,23 @@ pub async fn rollover_encryption_for_all_entities(
let ext_groups = {
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
em.list_groups()
.map_err(|e| format!("Failed to list extension groups: {e}"))?
.map_err(|e| internal_error(format!("Failed to list extension groups: {e}")))?
};
let synced_ext_groups: Vec<_> = ext_groups.iter().filter(|g| g.sync_enabled).collect();
let total_eg = synced_ext_groups.len();
for (i, group) in synced_ext_groups.iter().enumerate() {
if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_extension_group_sync(group.id.clone()).await;
}
engine.upload_extension_group(group).await.map_err(|e| {
internal_error(format!(
"Failed to roll over extension group {}: {e}",
group.id
))
})?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "extension_groups", "done": i + 1, "total": total_eg}),
);
}
if !deferred.is_empty() || !deferred_groups.is_empty() || !deferred_vpns.is_empty() {
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if let Some(scheduler) = super::get_global_scheduler() {
for id in deferred {
scheduler.queue_proxy_sync(id).await;
}
for id in deferred_groups {
scheduler.queue_group_sync(id).await;
}
for id in deferred_vpns {
scheduler.queue_vpn_sync(id).await;
}
}
});
}
let _ = events::emit("e2e-rollover-completed", ());
Ok(())
}
+8 -137
View File
@@ -8,7 +8,6 @@ use std::path::Path;
use std::time::SystemTime;
use super::types::{SyncError, SyncResult};
use crate::profile::types::BrowserProfile;
/// Default exclude patterns for volatile browser profile files.
/// Patterns use `**/` prefix to match at any directory depth, since the sync
@@ -61,6 +60,10 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/DawnWebGPUCache/**",
"**/BrowserMetrics*",
"**/.DS_Store",
// Profile metadata is a separately reconciled LWW config object. Including
// it in the browser-file manifest creates two competing sync mechanisms and
// lets a stale in-memory profile overwrite a metadata download.
"metadata.json",
".donut-sync/**",
// Orphaned local-only marker from earlier rollover-based fingerprint
// regeneration. Keep excluding it so any markers left on disk from
@@ -224,39 +227,6 @@ fn hash_file(path: &Path) -> Result<Option<String>, SyncError> {
Ok(Some(hasher.finalize().to_hex().to_string()))
}
/// Compute blake3 hash of metadata.json after sanitizing volatile fields.
/// This prevents infinite sync loops where updating last_sync triggers a new sync.
fn hash_sanitized_metadata(path: &Path) -> Result<Option<String>, SyncError> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(SyncError::IoError(format!(
"Failed to read metadata at {}: {e}",
path.display()
)));
}
};
let mut profile: BrowserProfile = serde_json::from_str(&content).map_err(|e| {
SyncError::SerializationError(format!("Failed to parse metadata for hashing: {e}"))
})?;
// Sanitize volatile fields that should not trigger a re-sync
profile.last_sync = None;
profile.process_id = None;
profile.last_launch = None;
let sanitized_json = serde_json::to_string(&profile).map_err(|e| {
SyncError::SerializationError(format!("Failed to serialize sanitized metadata: {e}"))
})?;
let mut hasher = blake3::Hasher::new();
hasher.update(sanitized_json.as_bytes());
Ok(Some(hasher.finalize().to_hex().to_string()))
}
/// Get mtime as unix timestamp
/// Returns None if the file doesn't exist (was deleted)
fn get_mtime(path: &Path) -> Result<Option<i64>, SyncError> {
@@ -372,19 +342,7 @@ pub fn generate_manifest(
*max_mtime = (*max_mtime).max(mtime);
// Check cache for existing hash
let hash = if relative_path == "metadata.json" {
// Special case: sanitize metadata.json before hashing to prevent sync loops
match hash_sanitized_metadata(&path)? {
Some(computed_hash) => computed_hash,
None => {
log::debug!(
"File disappeared during manifest generation, skipping: {}",
path.display()
);
continue;
}
}
} else if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
let hash = if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
cached_hash.to_string()
} else {
match hash_file(&path)? {
@@ -651,21 +609,15 @@ mod tests {
fs::create_dir_all(profile_dir.join("profile/Crashpad")).unwrap();
fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap();
// metadata.json at root
let profile = BrowserProfile::default();
fs::write(
profile_dir.join("metadata.json"),
serde_json::to_string(&profile).unwrap(),
)
.unwrap();
fs::write(profile_dir.join("metadata.json"), "{}").unwrap();
let mut cache = HashCache::default();
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect();
assert!(
paths.contains(&"metadata.json"),
"metadata.json should be synced"
!paths.contains(&"metadata.json"),
"metadata.json is reconciled separately from browser files"
);
assert!(
paths.contains(&"profile/Default/Cookies"),
@@ -865,85 +817,4 @@ mod tests {
assert!(diff.files_to_delete_remote.is_empty());
assert!(diff.files_to_delete_local.is_empty());
}
#[test]
fn test_generate_manifest_sanitizes_metadata() {
let temp_dir = TempDir::new().unwrap();
let profile_dir = temp_dir.path().join("profile");
fs::create_dir_all(&profile_dir).unwrap();
let profile_id = uuid::Uuid::new_v4();
let metadata_path = profile_dir.join("metadata.json");
let profile = BrowserProfile {
id: profile_id,
name: "test-profile".to_string(),
last_sync: Some(100),
process_id: Some(1234),
..Default::default()
};
fs::write(&metadata_path, serde_json::to_string(&profile).unwrap()).unwrap();
let mut cache = HashCache::default();
let manifest1 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
let hash1 = manifest1
.files
.iter()
.find(|f| f.path == "metadata.json")
.unwrap()
.hash
.clone();
// Update volatile fields
let profile2 = BrowserProfile {
id: profile_id,
name: "test-profile".to_string(),
last_sync: Some(200),
process_id: Some(5678),
..Default::default()
};
fs::write(&metadata_path, serde_json::to_string(&profile2).unwrap()).unwrap();
let manifest2 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
let hash2 = manifest2
.files
.iter()
.find(|f| f.path == "metadata.json")
.unwrap()
.hash
.clone();
// Hash should be identical because volatile fields are sanitized
assert_eq!(
hash1, hash2,
"Metadata hash should be stable across last_sync/process_id updates"
);
// Change a non-volatile field
let profile3 = BrowserProfile {
id: profile_id,
name: "changed-name".to_string(),
last_sync: Some(200),
..Default::default()
};
fs::write(&metadata_path, serde_json::to_string(&profile3).unwrap()).unwrap();
let manifest3 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
let hash3 = manifest3
.files
.iter()
.find(|f| f.path == "metadata.json")
.unwrap()
.hash
.clone();
// Hash should be different because name changed
assert_ne!(
hash1, hash3,
"Metadata hash should change when non-volatile fields change"
);
}
}