From bbb8372d34b8947926fbd22548f3775d481507c1 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:46:39 +0400 Subject: [PATCH] fix: key files, linux routing --- src-tauri/src/api_server.rs | 18 ++-- src-tauri/src/browser_runner.rs | 13 ++- src-tauri/src/cookie_bot.rs | 11 ++- src-tauri/src/ephemeral_dirs.rs | 49 ++++++++-- src-tauri/src/profile/clear_on_close.rs | 10 +- src-tauri/src/profile/encryption.rs | 49 +++++++++- src-tauri/src/profile/encryption_tests.rs | 41 ++++++++ src-tauri/src/profile/password.rs | 13 ++- src-tauri/src/profile/portable.rs | 15 +++ src-tauri/src/profile/types.rs | 50 ++++++++++ src-tauri/src/profile_import/keyring.rs | 19 ++++ src-tauri/src/remote_session.rs | 13 +-- src-tauri/src/sync/engine.rs | 9 ++ src-tauri/src/wayfern_identity_storage.rs | 98 ++++++++++--------- .../wayfern_identity_storage_browser_tests.rs | 15 +++ 15 files changed, 342 insertions(+), 81 deletions(-) diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 7e7f60f2..05c7523e 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -486,7 +486,8 @@ pub struct RunRemoteResponse { pub profile_id: String, /// Remote session id, for polling or closing the session. pub session_id: String, - /// Operating system the session was scheduled onto — always the profile's own. + /// Operating system of the host the session was scheduled onto: `linux` for + /// a profile that runs on every OS, else the OS that created the profile. pub platform: String, pub status: String, } @@ -518,8 +519,9 @@ struct ApiRemoteSessionsResponse { struct SetCookieBotScheduleRequest { /// Defaults to the profile's local name. profile_name: Option, - /// `windows`, `macos` or `linux`. Defaults to the profile's own operating - /// system, and must match it when supplied. + /// `windows`, `macos` or `linux`: the OS of the host the bot runs the + /// profile on, `linux` for a profile that runs on every OS, else the OS that + /// created the profile. Defaults to that OS, and must match it when supplied. platform: Option, /// Whether the nightly run is armed. A disabled schedule keeps its settings. enabled: bool, @@ -3626,7 +3628,7 @@ async fn run_profile( })) } -// API Handler - Launch this profile on a REMOTE VM of its own operating system +// API Handler - Launch this profile on a REMOTE VM of the OS `remote_platform` picks #[utoipa::path( post, path = "/v1/profiles/{id}/run-remote", @@ -3840,7 +3842,7 @@ pub fn remote_launch_profile_rules( .to_string(), ); } - if profile.resolved_os().is_none() { + if profile.remote_platform().is_none() { return Err( "profile has no recorded operating system, so it cannot be scheduled \ onto a matching host" @@ -4377,7 +4379,7 @@ async fn set_cookie_bot_schedule( // than the request is what stops a caller enrolling a macOS profile onto a // Windows host. let platform = profile - .resolved_os() + .remote_platform() .ok_or(( StatusCode::BAD_REQUEST, serde_json::json!({ "code": "COOKIE_BOT_UNKNOWN_PLATFORM" }).to_string(), @@ -5758,8 +5760,8 @@ mod tests { #[test] fn remote_launch_requires_a_known_operating_system() { - // Without one there is no way to pick a matching host, and guessing would - // be the cross-OS mismatch this whole design exists to prevent. + // Without one a Linux host has no OS to present the profile as, and a + // guess would change the OS the profile reports to sites. assert!(remote_launch_profile_rules(&profile_with(SyncMode::Regular, None)).is_err()); } diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 62b22c53..4c553b95 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -346,10 +346,12 @@ impl BrowserRunner { let launch_started = std::time::Instant::now(); let _launch_in_progress = crate::wayfern_identity_storage::yield_to_launch().await; let converted; + let mut launch_only = false; let profile = if crate::wayfern_identity_storage::needs_conversion(profile) { converted = crate::wayfern_identity_storage::convert_for_launch(profile) .await .map_err(|e| -> Box { e.into() })?; + launch_only = crate::wayfern_identity_storage::is_launch_only(&converted); &converted } else { profile @@ -855,7 +857,16 @@ impl BrowserRunner { updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()); // Save the updated profile - self.save_process_info(&updated_profile)?; + if launch_only { + let mut persisted = updated_profile.clone(); + if let Some(disk) = crate::wayfern_identity_storage::read_profile(&updated_profile.id) { + persisted.wayfern_config = disk.wayfern_config; + persisted.host_os = disk.host_os; + } + self.save_process_info(&persisted)?; + } else { + self.save_process_info(&updated_profile)?; + } let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { let _ = tm.rebuild_from_profiles(&self.profile_manager.list_profiles().unwrap_or_default()); }); diff --git a/src-tauri/src/cookie_bot.rs b/src-tauri/src/cookie_bot.rs index a0e0b5f4..784d823b 100644 --- a/src-tauri/src/cookie_bot.rs +++ b/src-tauri/src/cookie_bot.rs @@ -632,7 +632,7 @@ pub fn bot_precondition( // ciphertext and push the corruption back over the real profile. return Err(error("COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED", &[])); } - let Some(platform) = profile.resolved_os() else { + let Some(platform) = profile.remote_platform() else { return Err(error("COOKIE_BOT_UNKNOWN_PLATFORM", &[])); }; if !BOT_PLATFORMS.contains(&platform) { @@ -1047,7 +1047,7 @@ pub fn profile_identity(profile: &BrowserProfile) -> ProfileIdentity { .map(|zone| zone.trim().to_string()) .filter(|zone| !zone.is_empty()), platform: profile - .resolved_os() + .remote_platform() .filter(|os| BOT_PLATFORMS.contains(os)) .map(str::to_string), profile_name: (!name.is_empty() && name.encode_utf16().count() <= MAX_PROFILE_NAME_UNITS) @@ -1567,19 +1567,20 @@ mod tests { #[test] fn a_report_names_the_zone_the_platform_and_the_profile() { + // A Wayfern profile created on macOS runs on a Linux host. let identity = profile_identity(&profile_in_zone("Europe/Berlin")); assert_eq!( identity, ProfileIdentity { timezone: Some("Europe/Berlin".to_string()), - platform: Some("macos".to_string()), + platform: Some("linux".to_string()), profile_name: Some("warm me".to_string()), } ); let body = profile_state_body(profile_state(&eligible_profile()), &identity); assert_eq!(body["timezone"], "Europe/Berlin"); - assert_eq!(body["platform"], "macos"); + assert_eq!(body["platform"], "linux"); assert_eq!(body["profile_name"], "warm me"); assert_eq!(body["has_proxy"], true); assert_eq!(body["sync_enabled"], true); @@ -1789,7 +1790,9 @@ mod tests { #[test] fn an_os_the_fleet_cannot_lease_is_refused_at_enrolment_rather_than_at_two_in_the_morning() { + // A profile bound to its OS runs on a host of the OS that created it. let mut profile = eligible_profile(); + profile.browser = "camoufox".to_string(); profile.host_os = Some("android".to_string()); let err = bot_precondition(&profile, &ExitReachability::Remote) .expect_err("android has no host to lease"); diff --git a/src-tauri/src/ephemeral_dirs.rs b/src-tauri/src/ephemeral_dirs.rs index bf54c12d..4ee1d964 100644 --- a/src-tauri/src/ephemeral_dirs.rs +++ b/src-tauri/src/ephemeral_dirs.rs @@ -359,15 +359,14 @@ pub fn recover_ephemeral_dirs() { if entry.path().is_dir() { if let Some(name) = entry.file_name().to_str() { if uuid::Uuid::parse_str(name).is_ok() { - dirs.insert( - name.to_string(), - EphemeralEntry { - path: entry.path(), - // Judge a recovered directory by the base it was found under, not - // by what some earlier run happened to resolve. - backing, - }, - ); + // A disk-backed copy kept by the sweep above is the one with + // unsealed changes, so it is not displaced. + dirs.entry(name.to_string()).or_insert(EphemeralEntry { + path: entry.path(), + // Judge a recovered directory by the base it was found under, not + // by what some earlier run happened to resolve. + backing, + }); log::info!("Ephemeral dir recovered profile={name}"); } } @@ -384,6 +383,38 @@ fn sweep_disk_fallback_residue(current_base: &Path) { return; } + // A decrypted copy that still has its baseline holds changes its sealed + // profile does not have yet. It stays where it is and is mapped, so the next + // launch of that profile seals it, and nothing here is erased until then. + let mut unsealed = 0usize; + if let (Ok(entries), Ok(mut dirs)) = (std::fs::read_dir(&fallback), EPHEMERAL_DIRS.lock()) { + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if uuid::Uuid::parse_str(&name).is_err() + || !path + .join(crate::profile::encryption::BASELINE_FILE_NAME) + .is_file() + { + continue; + } + dirs.entry(name).or_insert(EphemeralEntry { + path, + backing: EphemeralBacking::Disk, + }); + unsealed += 1; + } + } + if unsealed > 0 { + log::warn!( + "Disk-backed decrypted copies kept until sealed count={unsealed} path={}", + fallback.display() + ); + return; + } + match crate::fs_secure::secure_remove_dir_all(&fallback, true) { Ok(files) if files > 0 => log::info!( "Erased disk-backed ephemeral residue files={files} path={}", diff --git a/src-tauri/src/profile/clear_on_close.rs b/src-tauri/src/profile/clear_on_close.rs index ac67d8c9..1d507dda 100644 --- a/src-tauri/src/profile/clear_on_close.rs +++ b/src-tauri/src/profile/clear_on_close.rs @@ -32,7 +32,9 @@ const PROFILE_KEEP: &[&str] = &[ ]; /// Entries kept at the user-data-dir top level (outside profile subdirs). -const TOP_KEEP: &[&str] = &["Local State", "First Run"]; +/// `os_crypt_key` and `Local State` hold the key the kept extension data is +/// encrypted with; without them that data cannot be read. +const TOP_KEEP: &[&str] = &["Local State", "os_crypt_key", "First Run"]; fn is_kept(name: &str) -> bool { PROFILE_KEEP.contains(&name) || TOP_KEEP.contains(&name) @@ -89,8 +91,8 @@ fn clear_chromium_profile_dir(profile_dir: &Path) -> usize { /// layouts: profile content at the root (imported profiles copy a Chromium /// profile dir directly) and the standard `Default` / `Profile N` subdirs /// Chromium creates itself. Top-level cache dirs (ShaderCache, GrShaderCache, -/// component_crx_cache, …) are removed; `Local State` stays because it holds -/// the os_crypt key extensions may rely on. +/// component_crx_cache, …) are removed; `Local State` and `os_crypt_key` stay +/// because they hold the key extensions' encrypted data needs. pub fn clear_user_data_dir(user_data_dir: &Path) -> usize { if !user_data_dir.exists() { return 0; @@ -227,6 +229,7 @@ mod tests { let tmp = TempDir::new().unwrap(); let dir = tmp.path(); touch(dir, "Local State"); + touch(dir, "os_crypt_key"); mkdir(dir, "ShaderCache"); mkdir(dir, "Default"); let default = dir.join("Default"); @@ -240,6 +243,7 @@ mod tests { clear_user_data_dir(dir); assert!(dir.join("Local State").exists()); + assert!(dir.join("os_crypt_key").exists()); assert!(!dir.join("ShaderCache").exists()); assert!(default.join("Preferences").exists()); assert!(default.join("Bookmarks").exists()); diff --git a/src-tauri/src/profile/encryption.rs b/src-tauri/src/profile/encryption.rs index 89ceaa5c..f0cbfc63 100644 --- a/src-tauri/src/profile/encryption.rs +++ b/src-tauri/src/profile/encryption.rs @@ -88,6 +88,18 @@ pub const SEALED_EXCLUDE_PATTERNS: &[&str] = &[ /// erased, so a directory without it is never mistaken for a profile. pub const BASELINE_FILE_NAME: &str = ".donut-baseline.json"; +/// The files that hold the key every encrypted cookie and password in the +/// profile is sealed with. A healthy profile never loses them or holds them +/// blank, so a decrypted copy that lacks one, or holds one empty or +/// zero-filled, was damaged from outside (a temp-folder cleaner, an erase that +/// zero-fills before it unlinks). Sealing that state would lose the key and, +/// with it, every encrypted value, so a seal keeps the sealed file instead. +const KEY_MATERIAL_PATHS: &[&str] = &["os_crypt_key", "Local State"]; + +fn is_blank(bytes: &[u8]) -> bool { + bytes.iter().all(|byte| *byte == 0) +} + /// A file's size and modification time, enough to tell that it changed. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct FileStamp { @@ -416,15 +428,24 @@ pub fn reencrypt_changed_files( } let bytes = std::fs::read(&abs)?; + if KEY_MATERIAL_PATHS.contains(&relpath.as_str()) && is_blank(&bytes) { + // Left out of the baseline, so the next seal looks at it again. + sealed.remove(&relpath); + log::error!("Blank key file not sealed, the sealed one is kept path={relpath}"); + continue; + } let encrypted = encrypt_profile_file(key, &relpath, &bytes)?; let on_disk = encrypted_dir.join(hmac_filename(key, &relpath)); atomic_write(&on_disk, &encrypted)?; rewrote += 1; } - // Delete on-disk files for plaintext paths that no longer exist + // Delete on-disk files for plaintext paths that no longer exist. A key file + // is never deleted this way: its absence from the copy is damage. let valid_names: HashSet = current_paths .iter() + .map(String::as_str) + .chain(KEY_MATERIAL_PATHS.iter().copied()) .map(|p| hmac_filename(key, p)) .collect(); @@ -449,6 +470,32 @@ pub fn reencrypt_changed_files( Ok((rewrote, sealed)) } +/// Put back, from the sealed copy, each key file the decrypted copy lacks or +/// holds blank. Returns how many were put back. +pub fn restore_key_material( + key: &[u8; 32], + encrypted_dir: &Path, + plaintext_dir: &Path, +) -> PasswordResult { + let mut restored = 0usize; + for relpath in KEY_MATERIAL_PATHS { + let dest = plaintext_dir.join(relpath); + if std::fs::read(&dest).is_ok_and(|bytes| !is_blank(&bytes)) { + continue; + } + let Ok(bytes) = std::fs::read(encrypted_dir.join(hmac_filename(key, relpath))) else { + continue; + }; + let (path, content) = decrypt_profile_file(key, &bytes)?; + if path != *relpath || is_blank(&content) { + continue; + } + atomic_write(&dest, &content)?; + restored += 1; + } + Ok(restored) +} + /// Re-encrypt every file under `encrypted_dir` from `old_key` to `new_key` in /// place. Used when changing a profile password without launching it. /// Returns how many damaged files were dropped: they open with neither key. diff --git a/src-tauri/src/profile/encryption_tests.rs b/src-tauri/src/profile/encryption_tests.rs index ececbcbe..024c885a 100644 --- a/src-tauri/src/profile/encryption_tests.rs +++ b/src-tauri/src/profile/encryption_tests.rs @@ -356,3 +356,44 @@ fn the_sealed_exclusions_keep_what_a_database_needs() { assert!(excludes.is_match(dropped), "{dropped} must not be sealed"); } } + +#[test] +fn a_seal_never_drops_or_blanks_the_key_files() { + const LOCAL_STATE: &[u8] = b"{\"os_crypt\":{}}"; + let key = make_key(); + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(plain.join("Default")).unwrap(); + std::fs::write(plain.join("os_crypt_key"), [7u8; 32]).unwrap(); + std::fs::write(plain.join("Local State"), LOCAL_STATE).unwrap(); + std::fs::write(plain.join("Default/Cookies"), b"rows").unwrap(); + encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); + + // Damage from outside the browser: one key file zero-filled in place, the + // other deleted. + let copy = work.path().join("copy"); + let baseline = decrypt_profile_dir(&key, &enc, ©).unwrap().baseline; + std::fs::write(copy.join("os_crypt_key"), [0u8; 32]).unwrap(); + std::fs::remove_file(copy.join("Local State")).unwrap(); + reencrypt_changed_files(&key, ©, &enc, &[], &baseline).unwrap(); + + let fresh = work.path().join("fresh"); + decrypt_profile_dir(&key, &enc, &fresh).unwrap(); + assert_eq!( + std::fs::read(fresh.join("os_crypt_key")).unwrap(), + [7u8; 32] + ); + assert_eq!( + std::fs::read(fresh.join("Local State")).unwrap(), + LOCAL_STATE + ); + + assert_eq!(restore_key_material(&key, &enc, ©).unwrap(), 2); + assert_eq!(std::fs::read(copy.join("os_crypt_key")).unwrap(), [7u8; 32]); + assert_eq!( + std::fs::read(copy.join("Local State")).unwrap(), + LOCAL_STATE + ); + assert_eq!(restore_key_material(&key, &enc, ©).unwrap(), 0); +} diff --git a/src-tauri/src/profile/password.rs b/src-tauri/src/profile/password.rs index 7d0e466f..ef1effdd 100644 --- a/src-tauri/src/profile/password.rs +++ b/src-tauri/src/profile/password.rs @@ -9,8 +9,8 @@ use crate::events; use crate::profile::encryption::{ cache_key, decrypt_profile_dir, drop_cached_key, encrypt_profile_dir, fresh_salt, get_cached_key, - has_cached_key, has_changes, reencrypt_changed_files, rekey_profile_dir, sealed_stamp, - unlock as unlock_dir, verify_key_against_dir, Baseline, FileStamp, PasswordError, + has_cached_key, has_changes, reencrypt_changed_files, rekey_profile_dir, restore_key_material, + sealed_stamp, unlock as unlock_dir, verify_key_against_dir, Baseline, FileStamp, PasswordError, BASELINE_FILE_NAME, SEALED_EXCLUDE_PATTERNS, }; use crate::profile::ProfileManager; @@ -838,6 +838,15 @@ pub fn prepare_for_launch(profile: &crate::profile::BrowserProfile) -> Result {} + Ok(restored) => { + log::warn!("Key files put back into the decrypted copy profile={id} count={restored}") + } + Err(e) => log::error!("Key file restore failed profile={id} err=\"{e}\""), + } return Ok(dir); } } diff --git a/src-tauri/src/profile/portable.rs b/src-tauri/src/profile/portable.rs index dca8d993..8c99db1b 100644 --- a/src-tauri/src/profile/portable.rs +++ b/src-tauri/src/profile/portable.rs @@ -352,6 +352,21 @@ pub fn imported_profile( object.insert("password_protected".to_string(), serde_json::json!(false)); object.insert("temporary".to_string(), serde_json::json!(false)); object.insert("sync_mode".to_string(), serde_json::json!("Disabled")); + let source_os = object + .get("host_os") + .and_then(|v| v.as_str()) + .filter(|os| crate::profile::types::is_host_os(os)) + .map(str::to_string); + if let (Some(source_os), Some(config)) = ( + source_os, + object + .get_mut("wayfern_config") + .and_then(|v| v.as_object_mut()), + ) { + if config.get("os").is_none_or(|os| os.is_null()) { + config.insert("os".to_string(), serde_json::json!(source_os)); + } + } object.insert( "host_os".to_string(), serde_json::json!(crate::profile::types::get_host_os()), diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index 1e2540c0..fb083144 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -153,6 +153,24 @@ impl BrowserProfile { self.browser == "wayfern" } + /// The host OS a remote session or a cookie-bot run is scheduled on, or + /// `None` when the profile cannot be scheduled. + /// + /// A profile that runs on every OS goes to a Linux host, which presents the + /// fingerprint's `os`, else the OS of the machine that created the profile. + /// Unlike `native_os` there is no fallback to this machine, which is not the + /// host, and a `host_os` that is not a real machine counts as unknown: with + /// neither OS the profile has none to present and no host. Any other profile + /// goes to the OS that created it. + pub fn remote_platform(&self) -> Option<&str> { + if self.runs_on_any_os() { + let fingerprint_os = self.wayfern_config.as_ref().and_then(|c| c.os.as_deref()); + let creating_os = self.host_os.as_deref().filter(|os| is_host_os(os)); + return fingerprint_os.or(creating_os).map(|_| "linux"); + } + self.resolved_os() + } + /// Returns true when the profile cannot run here because a browser bound to /// its OS created it on a different OS. A Wayfern profile is never cross-OS: /// it syncs its data and launches on every OS. @@ -258,6 +276,38 @@ mod tests { assert!(!profile_from("camoufox", None).is_cross_os()); } + #[test] + fn a_profile_that_runs_on_any_os_is_scheduled_on_linux_only_with_an_os_to_present() { + for os in ["macos", "windows", "linux"] { + assert_eq!( + profile_from("wayfern", Some(os)).remote_platform(), + Some("linux"), + "a Wayfern profile created on {os} runs on a Linux host" + ); + } + let mut mobile = profile_from("wayfern", None); + mobile.wayfern_config = Some(WayfernConfig { + os: Some("android".to_string()), + ..WayfernConfig::default() + }); + assert_eq!(mobile.remote_platform(), Some("linux")); + + // Nothing says which OS the profile is, so no host can run it. + assert_eq!(profile_from("wayfern", None).remote_platform(), None); + // A fingerprint OS stored as the creating machine is not a machine. + assert_eq!( + profile_from("wayfern", Some("android")).remote_platform(), + None + ); + + // A profile bound to its OS still goes to the OS that created it. + assert_eq!( + profile_from("camoufox", Some("macos")).remote_platform(), + Some("macos") + ); + assert_eq!(profile_from("camoufox", None).remote_platform(), None); + } + #[test] fn native_os_is_the_creating_machine_not_this_one() { let other = other_host_os(); diff --git a/src-tauri/src/profile_import/keyring.rs b/src-tauri/src/profile_import/keyring.rs index ee7b8b03..931f58ad 100644 --- a/src-tauri/src/profile_import/keyring.rs +++ b/src-tauri/src/profile_import/keyring.rs @@ -236,6 +236,25 @@ fn windows_has_app_bound_key(dir: &Path) -> bool { .is_some_and(|k| !k.is_empty()) } +/// Writes `os_crypt_key` from the DPAPI-wrapped key in `Local State` when the +/// profile has no key file yet, so its encrypted records open on every OS. +/// Returns true when it wrote the file. +#[cfg(target_os = "windows")] +pub fn write_key_file_from_local_state(user_data_dir: &Path) -> Result { + let key_file = user_data_dir.join(super::os_crypt::KEY_FILE_NAME); + if std::fs::metadata(&key_file).is_ok_and(|meta| meta.len() > 0) { + return Ok(false); + } + let Some(key) = windows_local_state_key(user_data_dir)? else { + return Ok(false); + }; + std::fs::write(&key_file, key).map_err(|e| format!("Failed to write os_crypt_key: {e}"))?; + if std::fs::read(&key_file).map_err(|e| e.to_string())? != key { + return Err("os_crypt_key verification failed after write".to_string()); + } + Ok(true) +} + #[cfg(target_os = "windows")] fn windows_local_state_key(dir: &Path) -> Result, String> { use base64::Engine; diff --git a/src-tauri/src/remote_session.rs b/src-tauri/src/remote_session.rs index 9a57c59d..c49f226c 100644 --- a/src-tauri/src/remote_session.rs +++ b/src-tauri/src/remote_session.rs @@ -76,7 +76,8 @@ pub struct RemoteSessionOutcome { #[derive(Debug, Serialize)] struct StartRemoteRequest { profile_id: String, - /// The profile's own OS. The backend refuses to schedule it anywhere else. + /// The host OS from `BrowserProfile::remote_platform`. The backend refuses + /// to schedule the session anywhere else. platform: String, /// Set when the caller wants a page opened once the browser is up. #[serde(skip_serializing_if = "Option::is_none")] @@ -164,10 +165,10 @@ fn control_client(total: std::time::Duration) -> reqwest::Client { /// without a network or a sync scheduler, and so both the REST and the MCP /// entry points get the identical answer from one place. /// -/// - The platform must be one a leased host serves. `resolved_os` can return a -/// fingerprint OS like `android` for a profile with no recorded host OS, and -/// no remote host runs that; refusing here gives a clear message instead of -/// leasing a host and taking a generic failure back. +/// - The platform must be one a leased host serves. `remote_platform` can +/// return a fingerprint OS like `android` for a profile bound to the OS that +/// created it, and no remote host runs that; refusing here gives a clear +/// message instead of leasing a host and taking a generic failure back. /// - Sync must have completed at least once. A remote session opens the synced /// copy and hands it back when it stops, so a profile with sync enabled but /// `last_sync` still None (the first upload failed, or is only queued) would @@ -252,7 +253,7 @@ pub async fn start_remote_session( url: Option, ) -> Result { let platform = profile - .resolved_os() + .remote_platform() .ok_or_else(|| { RemoteSessionError::Other("profile has no recorded operating system".to_string()) })? diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index efdc0c3e..6a2be6c8 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -943,6 +943,15 @@ impl SyncEngine { // before we generate the manifest (WAL files are excluded from sync) checkpoint_sqlite_wal_files(&profile_dir); + #[cfg(target_os = "windows")] + match crate::profile_import::keyring::write_key_file_from_local_state( + &profile_dir.join("profile"), + ) { + Ok(true) => log::info!("Key file written from Local State profile={profile_id}"), + Ok(false) => {} + Err(e) => log::warn!("Key file not written profile={profile_id} err=\"{e}\""), + } + // Load or create hash cache let cache_path = get_cache_path(&profile_dir); let mut hash_cache = HashCache::load(&cache_path); diff --git a/src-tauri/src/wayfern_identity_storage.rs b/src-tauri/src/wayfern_identity_storage.rs index 286e82bc..8a9f3238 100644 --- a/src-tauri/src/wayfern_identity_storage.rs +++ b/src-tauri/src/wayfern_identity_storage.rs @@ -21,20 +21,12 @@ pub enum StoredShape { #[derive(Debug)] enum ConversionError { Deferred(String), + LaunchOnly(Box), Changed, Yielded, Failed(String), } -impl ConversionError { - fn coded(self) -> String { - match self { - Self::Deferred(coded) | Self::Failed(coded) => coded, - Self::Changed | Self::Yielded => crate::backend_error("WAYFERN_BROWSER_BUSY"), - } - } -} - const COMMAND: &str = "Wayfern.matchLegacyProfile"; const PASS_DELAY: Duration = Duration::from_secs(3); const RETRY_DELAY: Duration = Duration::from_secs(600); @@ -182,7 +174,7 @@ pub fn converted_profile( Ok(updated) } -fn read_profile(id: &uuid::Uuid) -> Option { +pub(crate) fn read_profile(id: &uuid::Uuid) -> Option { let path = ProfileManager::instance() .get_profiles_dir() .join(id.to_string()) @@ -192,7 +184,9 @@ fn read_profile(id: &uuid::Uuid) -> Option { } async fn is_running(profile: &BrowserProfile) -> bool { - if profile.process_id.is_some() { + if profile.process_id.is_some() + || crate::remote_handoff::state_for(&profile.id.to_string()).is_some() + { return true; } let profiles_dir = ProfileManager::instance().get_profiles_dir(); @@ -206,17 +200,25 @@ async fn is_running(profile: &BrowserProfile) -> bool { fn write_backup(profile: &BrowserProfile) -> Result<(), String> { let dir = crate::app_dirs::data_subdir().join("wayfern-config-backups"); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - let path = dir.join(format!("{}.json", profile.id)); - if path.exists() { - return Ok(()); - } let body = serde_json::to_vec_pretty(&json!({ "profileId": profile.id.to_string(), "version": profile.version, "wayfernConfig": profile.wayfern_config, })) .map_err(|e| e.to_string())?; - crate::app_dirs::write_owner_only(&path, &body).map_err(|e| e.to_string()) + for index in 0u32.. { + let path = if index == 0 { + dir.join(format!("{}.json", profile.id)) + } else { + dir.join(format!("{}-{index}.json", profile.id)) + }; + match std::fs::read(&path) { + Ok(existing) if existing == body => return Ok(()), + Ok(_) => continue, + Err(_) => return crate::app_dirs::write_owner_only(&path, &body).map_err(|e| e.to_string()), + } + } + Err("no free backup name".to_string()) } fn config_value(profile: &BrowserProfile) -> Value { @@ -272,24 +274,21 @@ async fn convert_and_save( session: &HeadlessWayfern, profile: &BrowserProfile, interruptible: bool, - wait_for_withheld: bool, + token_resolved: bool, ) -> Result { let config = profile.wayfern_config.clone().unwrap_or_default(); let response = request(session, conversion_params(profile, &config), interruptible).await?; - if wait_for_withheld - && response.get("withheld").and_then(Value::as_bool) == Some(true) - && !CLOUD_AUTH.is_logged_in().await - { - return Err(ConversionError::Deferred(crate::backend_error( - "WAYFERN_PLAN_CHECK_UNAVAILABLE", - ))); - } let updated = converted_profile(profile, &response).map_err(|reason| { ConversionError::Failed(crate::backend_error_with_detail( "WAYFERN_FINGERPRINT_APPLY_FAILED", reason, )) })?; + if response.get("withheld").and_then(Value::as_bool) == Some(true) + && !(token_resolved && CLOUD_AUTH.is_logged_in().await) + { + return Err(ConversionError::LaunchOnly(Box::new(updated))); + } let Some(current) = read_profile(&profile.id) else { return Err(ConversionError::Failed(crate::backend_error( @@ -342,11 +341,9 @@ pub async fn convert_for_launch(profile: &BrowserProfile) -> Result Result { - convert_and_save(&session, &latest, false, false).await + convert_and_save(&session, &latest, false, token_resolved).await } Some(latest) if !needs_conversion(&latest) => Ok(latest), Some(_) => Err(ConversionError::Changed), @@ -372,20 +369,13 @@ pub async fn convert_for_launch(profile: &BrowserProfile) -> Result, current: BrowserProfile, ) -> Result { match result { Ok(converted) => Ok(converted), + Err(ConversionError::LaunchOnly(launch_copy)) => Ok(*launch_copy), Err(ConversionError::Failed(reason)) | Err(ConversionError::Deferred(reason)) => { log::warn!( "Identity conversion failed, launching on the stored device profile={} err=\"{reason}\"", @@ -393,10 +383,18 @@ fn launch_outcome( ); Ok(current) } - Err(other) => Err(other.coded()), + Err(ConversionError::Changed) | Err(ConversionError::Yielded) => { + Ok(read_profile(¤t.id).unwrap_or(current)) + } } } +/// True when `launched` carries a config that the disk copy does not hold yet. +pub fn is_launch_only(launched: &BrowserProfile) -> bool { + !needs_conversion(launched) + && read_profile(&launched.id).is_some_and(|disk| needs_conversion(&disk)) +} + fn any_wayfern_running(profiles: &[BrowserProfile]) -> bool { profiles .iter() @@ -496,7 +494,7 @@ async fn run_pass() -> bool { deferred = true; break; } - Err(ConversionError::Changed) => deferred = true, + Err(ConversionError::Changed) | Err(ConversionError::LaunchOnly(_)) => deferred = true, Err(ConversionError::Deferred(reason)) => { log::debug!("Identity conversion deferred profile={id} reason=\"{reason}\""); deferred = true; @@ -700,7 +698,6 @@ mod tests { camoufox.browser = "camoufox".to_string(); assert!(!needs_conversion(&camoufox)); - // A profile created on another OS launches here, so it converts here too. let mut foreign = profile_with("152.0.7977.64", payload_only); foreign.host_os = Some( if get_host_os() == "windows" { @@ -901,10 +898,6 @@ mod tests { #[test] fn a_launch_never_fails_because_the_conversion_could_not_finish() { - // The rule this pins: plan state, a transient verifier failure and a - // browser that did not answer must all still open the profile on its - // stored device. Returning an error here is a launch outage, and it has - // reached a user twice. let stored = profile_with("152.0.7977.64", WayfernConfig::default()); for refusal in [ @@ -920,6 +913,17 @@ mod tests { ); } + let launch_copy = profile_with("152.0.7977.64", WayfernConfig::default()); + let outcome = launch_outcome( + Err(ConversionError::LaunchOnly(Box::new(launch_copy.clone()))), + stored.clone(), + ); + assert_eq!( + outcome.expect("a withheld answer still launches").id, + launch_copy.id + ); + assert!(launch_outcome(Err(ConversionError::Changed), stored.clone()).is_ok()); + // A successful conversion is what the caller gets instead. let converted = profile_with("152.0.7977.64", WayfernConfig::default()); let converted_id = converted.id; diff --git a/src-tauri/src/wayfern_identity_storage_browser_tests.rs b/src-tauri/src/wayfern_identity_storage_browser_tests.rs index bbed73fa..453999e2 100644 --- a/src-tauri/src/wayfern_identity_storage_browser_tests.rs +++ b/src-tauri/src/wayfern_identity_storage_browser_tests.rs @@ -69,6 +69,21 @@ async fn stored_profiles_convert_through_a_real_browser() { }; let first_answer = answer(&before, token).await; + if token.is_none() && first_answer["withheld"] == json!(true) { + let launched = convert_for_launch(&before) + .await + .unwrap_or_else(|e| panic!("profile {} did not launch: {e}", before.id)); + assert!(!needs_conversion(&launched)); + let on_disk = read_profile(&before.id).expect("the stored profile is on disk"); + assert_eq!( + config_value(&on_disk), + config_value(&before), + "profile {} keeps its stored config while the plan is unknown", + before.id + ); + continue; + } + let converted = match token { Some(token) => { let session = session_for(&before, Some(token)).await;