From 2346bbf2ba60d2ccdab10ba87ed0b47ed9499ed2 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:22:35 +0400 Subject: [PATCH] feat: browser-side migration --- src-tauri/src/browser_runner.rs | 6 + src-tauri/src/extension_manager.rs | 215 ++++++++++++ src-tauri/src/profile/encryption.rs | 1 + src-tauri/src/profile/encryption_tests.rs | 1 + src-tauri/src/profile/portable.rs | 9 +- src-tauri/src/sync/manifest.rs | 1 + src-tauri/src/wayfern_identity_storage.rs | 389 +++++++++++++++++++++- src-tauri/src/wayfern_manager.rs | 222 +++++++++++- 8 files changed, 834 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 4c553b95..6f817703 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -852,6 +852,12 @@ impl BrowserRunner { launch_started.elapsed().as_millis() ); + // The browser returned a config to store, and it is on disk already. + if let Some(stored) = wayfern_result.stored_profile.as_ref() { + updated_profile.wayfern_config = stored.wayfern_config.clone(); + updated_profile.host_os = stored.host_os.clone(); + } + // Update profile with the process info updated_profile.process_id = Some(process_id); updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()); diff --git a/src-tauri/src/extension_manager.rs b/src-tauri/src/extension_manager.rs index 9e8eb993..77fe6b6e 100644 --- a/src-tauri/src/extension_manager.rs +++ b/src-tauri/src/extension_manager.rs @@ -184,6 +184,138 @@ pub(crate) fn resolve_archive_i18n( /// Read an unpacked extension's `manifest.json` off disk. The directory /// equivalent of `read_manifest_from_archive`, so both payload shapes feed the /// same metadata and icon extraction below. +/// The manifest `key` of a staged copy. Chromium derives the id of an unpacked +/// extension from its absolute path unless the manifest has a `key`, and the +/// staging path differs on every machine and OS, so a path id leaves the +/// extension's storage and settings on the machine that staged it. The key is +/// derived from the profile and the extension, so the id is the same on every +/// machine and still differs between profiles. +fn portable_manifest_key(profile_id: &str, ext_id: &str) -> Vec { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(b"donut-staged-extension-key\0"); + hasher.update(profile_id.as_bytes()); + hasher.update([0u8]); + hasher.update(ext_id.as_bytes()); + hasher.finalize().to_vec() +} + +/// The extension id Chromium derives from a manifest `key`: the first 16 bytes +/// of its SHA-256, each nibble written as a letter from `a` to `p`. +fn extension_id_for_key(key: &[u8]) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(key)[..16] + .iter() + .flat_map(|byte| [byte >> 4, byte & 0x0f]) + .map(|nibble| char::from(b'a' + nibble)) + .collect() +} + +/// Whether two spellings name the same directory, as the browser records it. +fn same_dir(recorded: &str, dir: &Path) -> bool { + let recorded = Path::new(recorded); + let canonical = dir.canonicalize().ok(); + [Some(dir.to_path_buf()), canonical] + .into_iter() + .flatten() + .any(|candidate| { + let candidate = candidate.to_string_lossy().to_string(); + let candidate = candidate.strip_prefix(r"\\?\").unwrap_or(&candidate); + if cfg!(windows) { + recorded.to_string_lossy().eq_ignore_ascii_case(candidate) + } else { + recorded == Path::new(candidate) + } + }) +} + +/// The id under which the profile already keeps state for the extension staged +/// at `dir`, when that id is not `portable_id`. Such state was recorded before +/// staged copies carried a key. +fn recorded_path_id(profile_data_path: &Path, dir: &Path, portable_id: &str) -> Option { + let mut recorded_path_ids = Vec::new(); + for file in ["Preferences", "Secure Preferences"] { + let Ok(contents) = fs::read_to_string(profile_data_path.join("Default").join(file)) else { + continue; + }; + let Ok(prefs) = serde_json::from_str::(&contents) else { + continue; + }; + let Some(settings) = prefs + .pointer("/extensions/settings") + .and_then(|v| v.as_object()) + else { + continue; + }; + if settings.contains_key(portable_id) { + return None; + } + recorded_path_ids.extend( + settings + .iter() + .filter(|(_, entry)| { + entry + .get("path") + .and_then(|p| p.as_str()) + .is_some_and(|p| same_dir(p, dir)) + }) + .map(|(id, _)| id.clone()), + ); + } + recorded_path_ids.into_iter().next() +} + +/// Give the staged copy at `dir` a manifest `key`, so the browser gives it the +/// same id on every machine. A manifest that already has a key keeps it. A +/// profile that already keeps state under the copy's path id keeps that id, so +/// an update never resets an extension on the machine it runs on. +fn give_portable_id(dir: &Path, profile_id: &str, ext_id: &str, profile_data_path: &Path) { + use base64::Engine; + + let manifest_path = dir.join("manifest.json"); + let Ok(contents) = fs::read_to_string(&manifest_path) else { + return; + }; + let Ok(mut manifest) = + serde_json::from_str::(contents.trim_start_matches('\u{feff}')) + else { + log::warn!("Extension keeps a path id, its manifest is not plain JSON extension={ext_id}"); + return; + }; + let Some(object) = manifest.as_object_mut() else { + return; + }; + if object.contains_key("key") { + return; + } + let key = portable_manifest_key(profile_id, ext_id); + let portable_id = extension_id_for_key(&key); + if let Some(recorded) = recorded_path_id(profile_data_path, dir, &portable_id) { + log::info!( + "Extension keeps the id its data is under extension={ext_id} id={recorded} profile={profile_id}" + ); + return; + } + object.insert( + "key".to_string(), + serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(key)), + ); + match serde_json::to_vec_pretty(&manifest) { + Ok(bytes) => { + if let Err(e) = fs::write(&manifest_path, bytes) { + log::warn!( + "Extension keeps a path id, manifest write failed extension={ext_id} err=\"{e}\"" + ); + } + } + Err(e) => { + log::warn!( + "Extension keeps a path id, manifest encode failed extension={ext_id} err=\"{e}\"" + ); + } + } +} + pub(crate) fn read_manifest_from_dir(dir: &Path) -> Option { let contents = fs::read_to_string(dir.join("manifest.json")).ok()?; serde_json::from_str(&contents).ok() @@ -1582,6 +1714,12 @@ impl ExtensionManager { // Extract .crx or .zip match Self::unpack_extension(&src_file, &unpack_dir) { Ok(()) => { + give_portable_id( + &unpack_dir, + &profile.id.to_string(), + ext_id, + _profile_data_path, + ); extension_paths.push(unpack_dir.to_string_lossy().to_string()); } Err(e) => { @@ -2011,6 +2149,83 @@ pub async fn get_extension_group_for_profile( mod tests { use super::*; + #[test] + fn extension_ids_follow_the_browser_derivation() { + assert_eq!( + extension_id_for_key(b"test"), + "jpignaibiiemhngfjkcpokkamffknabf" + ); + assert_eq!( + extension_id_for_key(b"_"), + "ncocknphbhhlhkikpnnlmbcnbgdempcd" + ); + } + + fn staged_copy(root: &Path, name: &str, manifest: &str) -> PathBuf { + let dir = root.join(name); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("manifest.json"), manifest).unwrap(); + dir + } + + fn manifest_key(dir: &Path) -> Option { + read_manifest_from_dir(dir)? + .get("key")? + .as_str() + .map(str::to_string) + } + + #[test] + fn a_staged_copy_gets_one_key_per_profile_on_every_machine() { + let machine_a = tempfile::tempdir().unwrap(); + let machine_b = tempfile::tempdir().unwrap(); + let manifest = r#"{"name":"x","version":"1","manifest_version":3}"#; + let on_a = staged_copy(machine_a.path(), "staged", manifest); + let on_b = staged_copy(machine_b.path(), "elsewhere/staged", manifest); + let other_profile = staged_copy(machine_a.path(), "other", manifest); + let data = tempfile::tempdir().unwrap(); + + give_portable_id(&on_a, "profile-1", "ext-1", data.path()); + give_portable_id(&on_b, "profile-1", "ext-1", data.path()); + give_portable_id(&other_profile, "profile-2", "ext-1", data.path()); + + let key = manifest_key(&on_a).expect("a key is written"); + assert_eq!(Some(key.clone()), manifest_key(&on_b)); + assert_ne!(Some(key), manifest_key(&other_profile)); + } + + #[test] + fn a_manifest_key_and_recorded_extension_state_are_kept() { + let root = tempfile::tempdir().unwrap(); + let keyed = staged_copy( + root.path(), + "keyed", + r#"{"name":"x","version":"1","manifest_version":3,"key":"AAAA"}"#, + ); + let data = tempfile::tempdir().unwrap(); + give_portable_id(&keyed, "profile-1", "ext-1", data.path()); + assert_eq!(manifest_key(&keyed).as_deref(), Some("AAAA")); + + let used = staged_copy( + root.path(), + "used", + r#"{"name":"x","version":"1","manifest_version":3}"#, + ); + fs::create_dir_all(data.path().join("Default")).unwrap(); + let prefs = serde_json::json!({ + "extensions": {"settings": {"abcdefghijklmnopabcdefghijklmnop": { + "path": used.to_string_lossy(), + }}} + }); + fs::write( + data.path().join("Default").join("Preferences"), + prefs.to_string(), + ) + .unwrap(); + give_portable_id(&used, "profile-1", "ext-2", data.path()); + assert_eq!(manifest_key(&used), None, "the recorded path id stays"); + } + #[test] fn test_get_file_type() { assert_eq!(get_file_type("ext.crx"), Some("crx".to_string())); diff --git a/src-tauri/src/profile/encryption.rs b/src-tauri/src/profile/encryption.rs index f0cbfc63..0b23faac 100644 --- a/src-tauri/src/profile/encryption.rs +++ b/src-tauri/src/profile/encryption.rs @@ -79,6 +79,7 @@ pub const SEALED_EXCLUDE_PATTERNS: &[&str] = &[ "*.tmp", "**/*.donut-tmp", "**/wayfern-identity.json", + "**/wayfern-identity-result.json", "**/wayfern-persona.json", BASELINE_FILE_NAME, ]; diff --git a/src-tauri/src/profile/encryption_tests.rs b/src-tauri/src/profile/encryption_tests.rs index 024c885a..a48478d1 100644 --- a/src-tauri/src/profile/encryption_tests.rs +++ b/src-tauri/src/profile/encryption_tests.rs @@ -350,6 +350,7 @@ fn the_sealed_exclusions_keep_what_a_database_needs() { "Default/Local Storage/leveldb/LOCK", "SingletonLock", "wayfern-identity.json", + "wayfern-identity-result.json", BASELINE_FILE_NAME, "Default/x.donut-tmp", ] { diff --git a/src-tauri/src/profile/portable.rs b/src-tauri/src/profile/portable.rs index 8c99db1b..762f4328 100644 --- a/src-tauri/src/profile/portable.rs +++ b/src-tauri/src/profile/portable.rs @@ -153,8 +153,9 @@ pub fn is_excluded(relative: &str) -> bool { "CacheStorage", "ScriptCache", ]; - const LAUNCH_FILES: [&str; 3] = [ + const LAUNCH_FILES: [&str; 4] = [ "wayfern-identity.json", + "wayfern-identity-result.json", "wayfern-persona.json", "window-icon.png", ]; @@ -246,6 +247,12 @@ pub fn export_to( .map_err(|e| err("profile", e))?; if carries_data { + // A profile created before it had a key file holds its key only in a form + // this Windows account can open; the archive must carry the key file. + #[cfg(target_os = "windows")] + if let Err(e) = crate::profile_import::keyring::write_key_file_from_local_state(data_dir) { + log::warn!("Profile export carries no key file err=\"{e}\""); + } let mut written = 0u64; for (relative, absolute) in collect_files(data_dir)? { let data = match fs::read(&absolute) { diff --git a/src-tauri/src/sync/manifest.rs b/src-tauri/src/sync/manifest.rs index 8f0f8dbb..fea27be8 100644 --- a/src-tauri/src/sync/manifest.rs +++ b/src-tauri/src/sync/manifest.rs @@ -57,6 +57,7 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[ // Rewritten by donut before every launch from the profile metadata that // already syncs; uploading it would only duplicate that state. "**/wayfern-identity.json", + "**/wayfern-identity-result.json", "**/wayfern-persona.json", "**/SingletonSocket", "**/SingletonCookie", diff --git a/src-tauri/src/wayfern_identity_storage.rs b/src-tauri/src/wayfern_identity_storage.rs index 8a9f3238..7250e5b2 100644 --- a/src-tauri/src/wayfern_identity_storage.rs +++ b/src-tauri/src/wayfern_identity_storage.rs @@ -2,10 +2,11 @@ use crate::cloud_auth::CLOUD_AUTH; use crate::profile::types::get_host_os; use crate::profile::{BrowserProfile, ProfileManager}; use crate::wayfern_manager::{ - cdp_error_is_invalid_params, is_temporary_wayfern_failure, supports_wayfern_152, wayfern_failure, - HeadlessWayfern, WayfernConfig, WayfernManager, + cdp_error_is_invalid_params, is_temporary_wayfern_failure, supports_launch_result, + supports_wayfern_152, wayfern_failure, HeadlessWayfern, WayfernConfig, WayfernManager, }; use serde_json::{json, Map, Value}; +use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::LazyLock; use std::time::Duration; @@ -91,6 +92,7 @@ pub fn stored_shape(config: &WayfernConfig) -> StoredShape { pub fn needs_conversion(profile: &BrowserProfile) -> bool { profile.browser == "wayfern" && supports_wayfern_152(&profile.version) + && !supports_launch_result(&profile.version) && profile.wayfern_config.as_ref().is_some_and(|config| { config.randomize_fingerprint_on_launch != Some(true) && matches!( @@ -221,6 +223,170 @@ fn write_backup(profile: &BrowserProfile) -> Result<(), String> { Err("no free backup name".to_string()) } +/// The config fields the launch document carries, exactly as stored. +fn stored_fields(config: &WayfernConfig) -> Map { + [ + ("fingerprint", &config.fingerprint), + ("identity_id", &config.identity_id), + ("identity_baseline", &config.identity_baseline), + ("identity_overrides", &config.identity_overrides), + ("os", &config.os), + ("location", &config.location), + ] + .into_iter() + .filter_map(|(key, value)| Some((key.to_string(), json!(value.as_ref()?)))) + .collect() +} + +/// The launch document keys that let the browser report the config to store. +pub fn launch_document_keys( + profile: &BrowserProfile, + config: &WayfernConfig, + result_file: &Path, + account_known: bool, +) -> Map { + let mut keys = Map::new(); + keys.insert("profileSalt".to_string(), json!(profile.id.to_string())); + keys.insert( + "resultFile".to_string(), + json!(result_file.to_string_lossy()), + ); + keys.insert("nativeOs".to_string(), json!(profile.native_os())); + keys.insert("accountKnown".to_string(), json!(account_known)); + keys.insert("stored".to_string(), Value::Object(stored_fields(config))); + keys +} + +/// What the browser reported about a launch. +#[derive(Debug, Clone, PartialEq)] +pub struct LaunchResult { + pub status: String, + /// The identity the browser launched on, when it launched on one. + pub identity_id: Option, + pub operating_system: Option, + config: Option>, +} + +/// This launch's result for `profile_id`, or `None` when there is no +/// readable result for that profile. +pub fn read_launch_result(path: &Path, profile_id: &uuid::Uuid) -> Option { + let content = std::fs::read_to_string(path).ok()?; + let result: Value = match serde_json::from_str(&content) { + Ok(result) => result, + Err(e) => { + log::warn!("Launch result unreadable profile={profile_id} err=\"{e}\""); + return None; + } + }; + if result.get("profileSalt").and_then(Value::as_str) != Some(profile_id.to_string().as_str()) { + log::warn!("Launch result is for another profile profile={profile_id}"); + return None; + } + let text = |key: &str| { + result + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }; + let status = text("status")?; + log::debug!("Launch result profile={profile_id} status={status}"); + Some(LaunchResult { + identity_id: text("identityId"), + operating_system: text("operatingSystem"), + config: result.get("config").and_then(Value::as_object).cloned(), + status, + }) +} + +/// `profile` with the config the browser returned, or `None` when the result +/// carries no config to store. +fn profile_with_result(profile: &BrowserProfile, result: &LaunchResult) -> Option { + if result.status != "converted" { + return None; + } + let returned = result.config.as_ref()?; + let text = |key: &str| { + returned + .get(key) + .and_then(Value::as_str) + .map(str::to_string) + }; + let identity_id = text("identity_id").filter(|id| !id.trim().is_empty())?; + let os = text("os")?; + let os = WayfernManager::normalize_os_name(&os)?; + + let mut updated = profile.clone(); + let config = updated + .wayfern_config + .get_or_insert_with(WayfernConfig::default); + config.identity_id = Some(identity_id); + config.os = Some(os.to_string()); + config.identity_overrides = text("identity_overrides"); + if let Some(location) = text("location") { + config.location = Some(location); + } + config.fingerprint = None; + config.identity_baseline = None; + + let host_os = get_host_os(); + if updated.host_os.is_none() && os != host_os { + updated.host_os = Some(host_os); + } + Some(updated) +} + +/// Store the config the browser returned, and return the stored profile. +/// Nothing is stored when the profile changed since the launch began. +pub fn store_launch_result( + profile: &BrowserProfile, + sent: &WayfernConfig, + result: &LaunchResult, +) -> Option { + if result.status != "converted" { + return None; + } + let current = read_profile(&profile.id)?; + let held = current.wayfern_config.clone().unwrap_or_default(); + if stored_fields(&held) != stored_fields(sent) { + log::debug!( + "Launch result not stored, profile changed profile={}", + profile.id + ); + return None; + } + let stored = profile_with_result(¤t, result)?; + if let Err(e) = write_backup(¤t) { + log::warn!( + "Launch result not stored, backup failed profile={} err=\"{e}\"", + profile.id + ); + return None; + } + match ProfileManager::instance().save_profile_unchanged(¤t, &stored) { + Ok(true) => { + log::info!("Profile stored as an identity profile={}", stored.id); + let _ = crate::events::emit_empty("profiles-changed"); + Some(stored) + } + Ok(false) => { + log::debug!( + "Launch result not stored, profile changed profile={}", + profile.id + ); + None + } + Err(e) => { + log::warn!( + "Launch result not stored profile={} err=\"{e}\"", + profile.id + ); + None + } + } +} + fn config_value(profile: &BrowserProfile) -> Value { serde_json::to_value(&profile.wayfern_config).unwrap_or(Value::Null) } @@ -680,6 +846,10 @@ mod tests { "150.0.7801.12", payload_only.clone() ))); + assert!(!needs_conversion(&profile_with( + "155.0.8059.79", + payload_only.clone() + ))); let mut randomized = payload_only.clone(); randomized.randomize_fingerprint_on_launch = Some(true); @@ -935,6 +1105,221 @@ mod tests { ); } + #[test] + fn the_launch_document_carries_the_stored_config_verbatim() { + let config = WayfernConfig { + fingerprint: Some(format!(r#"{{"fingerprint":{}}}"#, payload())), + identity_id: Some(format!(" {LEGACY_ID} ")), + identity_overrides: Some("not json".to_string()), + persona: Some("p".to_string()), + proxy: Some("socks5://127.0.0.1:1".to_string()), + ..Default::default() + }; + let profile = profile_with("155.0.8059.79", config.clone()); + let result_file = std::path::PathBuf::from("/tmp/p/wayfern-identity-result.json"); + let keys = launch_document_keys(&profile, &config, &result_file, true); + + assert_eq!(keys["profileSalt"], json!(profile.id.to_string())); + assert_eq!( + keys["resultFile"], + json!("/tmp/p/wayfern-identity-result.json") + ); + assert_eq!(keys["nativeOs"], json!(profile.native_os())); + assert_eq!(keys["accountKnown"], json!(true)); + let stored = keys["stored"].as_object().unwrap(); + assert_eq!(stored["fingerprint"], json!(config.fingerprint.unwrap())); + assert_eq!(stored["identity_id"], json!(format!(" {LEGACY_ID} "))); + assert_eq!(stored["identity_overrides"], json!("not json")); + let mut names: Vec<&str> = stored.keys().map(String::as_str).collect(); + names.sort_unstable(); + assert_eq!(names, ["fingerprint", "identity_id", "identity_overrides"]); + + let empty = launch_document_keys(&profile, &WayfernConfig::default(), &result_file, false); + assert_eq!(empty["stored"], json!({})); + assert_eq!(empty["accountKnown"], json!(false)); + } + + fn result_body(profile: &BrowserProfile, status: &str, config: Value) -> Value { + json!({ + "version": 1, + "profileSalt": profile.id.to_string(), + "status": status, + "identityId": CURRENT_ID, + "operatingSystem": "windows", + "config": config, + }) + } + + fn write_result(dir: &Path, body: &Value) -> std::path::PathBuf { + let path = dir.join(crate::wayfern_manager::LAUNCH_RESULT_FILE); + std::fs::write(&path, body.to_string()).unwrap(); + path + } + + #[test] + fn only_a_result_for_this_profile_is_read() { + let dir = tempfile::tempdir().unwrap(); + let profile = profile_with("155.0.8059.79", WayfernConfig::default()); + let missing = dir.path().join("absent.json"); + assert!(read_launch_result(&missing, &profile.id).is_none()); + + let path = write_result(dir.path(), &result_body(&profile, "unchanged", Value::Null)); + let result = read_launch_result(&path, &profile.id).unwrap(); + assert_eq!(result.status, "unchanged"); + assert_eq!(result.identity_id.as_deref(), Some(CURRENT_ID)); + assert_eq!(result.operating_system.as_deref(), Some("windows")); + + let mut other = result_body(&profile, "converted", json!({})); + other["profileSalt"] = json!(LEGACY_ID); + let path = write_result(dir.path(), &other); + assert!(read_launch_result(&path, &profile.id).is_none()); + + std::fs::write(&path, "{").unwrap(); + assert!(read_launch_result(&path, &profile.id).is_none()); + + let mut failed = result_body(&profile, "failed", Value::Null); + let object = failed.as_object_mut().unwrap(); + object.remove("identityId"); + object.remove("operatingSystem"); + let path = write_result(dir.path(), &failed); + let result = read_launch_result(&path, &profile.id).unwrap(); + assert_eq!(result.status, "failed"); + assert!(result.identity_id.is_none()); + } + + #[test] + fn a_converted_result_is_stored_as_returned() { + let dir = tempfile::tempdir().unwrap(); + let _data = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf()); + let sent = WayfernConfig { + fingerprint: Some(payload()), + identity_baseline: Some(payload()), + identity_overrides: Some(r#"{"doNotTrack":"1"}"#.to_string()), + location: Some(r#"{"timezone":"Europe/Berlin"}"#.to_string()), + persona: Some("p".to_string()), + ..Default::default() + }; + let profile = profile_with("155.0.8059.79", sent.clone()); + ProfileManager::instance().save_profile(&profile).unwrap(); + + let returned = json!({ + "identity_id": CURRENT_ID, + "os": "windows", + "identity_overrides": null, + }); + let path = write_result(dir.path(), &result_body(&profile, "converted", returned)); + let result = read_launch_result(&path, &profile.id).unwrap(); + let mut launch_copy = sent.clone(); + launch_copy.proxy = Some("socks5://127.0.0.1:1".to_string()); + let stored = store_launch_result(&profile, &launch_copy, &result).expect("stored"); + + let on_disk = read_profile(&profile.id).unwrap(); + assert_eq!(config_value(&on_disk), config_value(&stored)); + let config = on_disk.wayfern_config.unwrap(); + assert_eq!(config.identity_id.as_deref(), Some(CURRENT_ID)); + assert_eq!(config.os.as_deref(), Some("windows")); + assert!(config.identity_overrides.is_none()); + assert!(config.fingerprint.is_none()); + assert!(config.identity_baseline.is_none()); + assert_eq!( + config.location, sent.location, + "an absent location keeps the stored one" + ); + assert_eq!(config.persona.as_deref(), Some("p")); + assert!(config.proxy.is_none()); + + let backup = crate::app_dirs::data_subdir() + .join("wayfern-config-backups") + .join(format!("{}.json", profile.id)); + let backup: Value = serde_json::from_slice(&std::fs::read(backup).unwrap()).unwrap(); + assert_eq!(backup["wayfernConfig"]["fingerprint"], json!(payload())); + + // The stored config is no longer the one that was sent: nothing is stored. + assert!(store_launch_result(&profile, &launch_copy, &result).is_none()); + assert_eq!( + config_value(&read_profile(&profile.id).unwrap()), + config_value(&stored) + ); + } + + #[test] + fn only_a_converted_result_with_a_config_is_stored() { + let dir = tempfile::tempdir().unwrap(); + let _data = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf()); + let sent = WayfernConfig { + fingerprint: Some(payload()), + ..Default::default() + }; + let profile = profile_with("155.0.8059.79", sent.clone()); + ProfileManager::instance().save_profile(&profile).unwrap(); + let returned = json!({ "identity_id": CURRENT_ID, "os": "windows" }); + + for status in ["unchanged", "deferred", "failed"] { + let path = write_result(dir.path(), &result_body(&profile, status, returned.clone())); + let result = read_launch_result(&path, &profile.id).unwrap(); + assert!( + store_launch_result(&profile, &sent, &result).is_none(), + "{status}" + ); + } + for unusable in [ + Value::Null, + json!({ "os": "windows" }), + json!({ "identity_id": CURRENT_ID, "os": "beos" }), + ] { + let path = write_result( + dir.path(), + &result_body(&profile, "converted", unusable.clone()), + ); + let result = read_launch_result(&path, &profile.id).unwrap(); + assert!( + store_launch_result(&profile, &sent, &result).is_none(), + "{unusable}" + ); + } + + let mut edited = profile.clone(); + edited.wayfern_config.as_mut().unwrap().identity_overrides = + Some(r#"{"doNotTrack":"1"}"#.to_string()); + ProfileManager::instance().save_profile(&edited).unwrap(); + let path = write_result(dir.path(), &result_body(&profile, "converted", returned)); + let result = read_launch_result(&path, &profile.id).unwrap(); + assert!( + store_launch_result(&profile, &sent, &result).is_none(), + "an edit made during the launch wins" + ); + assert_eq!( + config_value(&read_profile(&profile.id).unwrap()), + config_value(&edited) + ); + } + + #[test] + fn a_returned_claim_off_the_host_keeps_the_profile_launchable_here() { + let other = if get_host_os() == "macos" { + "windows" + } else { + "macos" + }; + let mut profile = profile_with("155.0.8059.79", WayfernConfig::default()); + profile.host_os = None; + let result = LaunchResult { + status: "converted".to_string(), + identity_id: Some(CURRENT_ID.to_string()), + operating_system: Some(other.to_string()), + config: json!({ "identity_id": CURRENT_ID, "os": other, "location": "{}" }) + .as_object() + .cloned(), + }; + let updated = profile_with_result(&profile, &result).unwrap(); + assert_eq!(updated.host_os.as_deref(), Some(get_host_os().as_str())); + assert!(!updated.is_cross_os()); + assert_eq!( + updated.wayfern_config.unwrap().location.as_deref(), + Some("{}") + ); + } + #[test] fn protocol_refusals_are_permanent_and_everything_else_waits() { let invalid = diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index 138a689c..412b3dde 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -412,10 +412,26 @@ pub fn supports_wayfern_152(version: &str) -> bool { crate::api_client::compare_versions(version, WAYFERN_152_MIN_VERSION) != std::cmp::Ordering::Less } +/// First Wayfern version that reads the stored config from the launch document +/// and writes the config to store back to a result file. Older browsers keep +/// the launcher-side path in `wayfern_identity_storage`. +const LAUNCH_RESULT_MIN_VERSION: &str = "155"; + +/// Whether `version` takes the stored config in the launch document and +/// reports the config to store. Same rule as [`supports_wayfern_152`]. +pub fn supports_launch_result(version: &str) -> bool { + crate::api_client::compare_versions(version, LAUNCH_RESULT_MIN_VERSION) + != std::cmp::Ordering::Less +} + /// Where the launch-time identity document lives inside the profile directory. /// Rewritten before every launch; the sync manifest excludes it. pub const LAUNCH_IDENTITY_FILE: &str = "wayfern-identity.json"; +/// Where the browser writes the config to store after a launch. Deleted before +/// every launch, so a file that exists afterwards came from that launch. +pub const LAUNCH_RESULT_FILE: &str = "wayfern-identity-result.json"; + /// What kind of launch this is, from the browser's point of view. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LaunchKind { @@ -797,10 +813,60 @@ pub fn persona_switch( Some(format!("--wayfern-profile-persona={}", path.display())) } +/// The folder in the profile directory that holds a copy of the camera source, +/// so the source travels with the profile to every machine. +const CARRIED_CAMERA_DIR: &str = "Camera Source"; + +/// The last component of a path written on any OS. +fn path_file_name(path: &str) -> Option<&str> { + path + .rsplit(['/', '\\']) + .next() + .filter(|name| !name.is_empty() && *name != "." && *name != "..") +} + +/// The camera source for a launch. The configured file is an absolute path on +/// the machine where it was chosen. When it is here, a copy goes into the +/// profile directory; when it is not, the copy that came with the profile is +/// used. +fn camera_source(profile_path: &Path, file: &str) -> String { + let Some(name) = path_file_name(file) else { + return file.to_string(); + }; + let carried_dir = profile_path.join(CARRIED_CAMERA_DIR); + let carried = carried_dir.join(name); + let source = Path::new(file); + if source.is_file() { + let current = match (std::fs::metadata(source), std::fs::metadata(&carried)) { + (Ok(source), Ok(copy)) => { + source.len() == copy.len() + && matches!( + (source.modified(), copy.modified()), + (Ok(source), Ok(copy)) if copy >= source + ) + } + _ => false, + }; + if !current { + let _ = std::fs::remove_dir_all(&carried_dir); + let copied = + std::fs::create_dir_all(&carried_dir).and_then(|_| std::fs::copy(source, &carried)); + if let Err(e) = copied { + log::warn!("Camera source not copied into the profile file={file} err=\"{e}\""); + } + } + return file.to_string(); + } + if carried.is_file() { + return carried.to_string_lossy().to_string(); + } + file.to_string() +} + /// The camera switches for a launch. A file that is not there is still passed, /// so the camera serves dark frames rather than the computer's own camera; the /// log line here names the file. -pub fn camera_switches(version: &str, config: &WayfernConfig) -> Vec { +pub fn camera_switches(version: &str, profile_path: &Path, config: &WayfernConfig) -> Vec { if !supports_wayfern_152(version) { return Vec::new(); } @@ -812,7 +878,8 @@ pub fn camera_switches(version: &str, config: &WayfernConfig) -> Vec { else { return Vec::new(); }; - if !Path::new(file).is_file() { + let file = camera_source(profile_path, file); + if !Path::new(&file).is_file() { log::warn!("Camera source missing, camera serves dark frames file={file}"); } let mut switches = vec![format!("--wayfern-camera-file={file}")]; @@ -1327,6 +1394,10 @@ pub struct WayfernLaunchResult { pub profilePath: Option, pub url: Option, pub cdp_port: Option, + /// The profile as stored after this launch, when the browser returned a + /// config to store. The caller persists this copy instead of its own. + #[serde(skip)] + pub stored_profile: Option, } struct WayfernInstance { @@ -1882,6 +1953,50 @@ impl WayfernManager { Ok(path) } + /// Delete the result file a previous launch left and return the absolute + /// path the browser writes this launch's result to. `None` when the old file + /// cannot be removed, so it can never be read as this launch's result. + fn clear_launch_result(profile_path: &str, profile: &BrowserProfile) -> Option { + let dir = PathBuf::from(profile_path); + if let Err(e) = std::fs::create_dir_all(&dir) { + log::warn!( + "Profile directory not created for the launch result profile={} err=\"{e}\"", + profile.id + ); + return None; + } + let path = dir.canonicalize().unwrap_or(dir).join(LAUNCH_RESULT_FILE); + match std::fs::remove_file(&path) { + Ok(()) => Some(path), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(path), + Err(e) => { + log::warn!( + "Old launch result not removed profile={} err=\"{e}\"", + profile.id + ); + None + } + } + } + + /// Read this launch's result and store the config it returns. + fn store_launch_result( + profile: &BrowserProfile, + sent: &WayfernConfig, + result_file: Option<&Path>, + ) -> ( + Option, + Option, + ) { + let Some(result) = result_file + .and_then(|path| crate::wayfern_identity_storage::read_launch_result(path, &profile.id)) + else { + return (None, None); + }; + let stored = crate::wayfern_identity_storage::store_launch_result(profile, sent, &result); + (Some(result), stored) + } + /// Whether the browser started on the identity the launcher handed it. /// /// The browser's own stderr verdict is authoritative when present: a @@ -3002,12 +3117,20 @@ impl WayfernManager { // the profile's device rather than the host's. Older browsers, legacy // device payloads and a profile whose location carries no timezone keep // the post-launch CDP apply below. - let launch_identity = if supports_wayfern_152(&profile.version) { + let identity_document = if supports_wayfern_152(&profile.version) { Self::launch_identity_document(config, &profile.native_os()) } else { None }; - if launch_identity.is_none() + // A browser that reports a result also gets the stored config, and the + // launcher stores what the result says after the launch. + let result_file = if supports_launch_result(&profile.version) { + Self::clear_launch_result(profile_path, profile) + } else { + None + }; + if identity_document.is_none() + && result_file.is_none() && supports_wayfern_152(&profile.version) && config.identity_id.is_some() && config.fingerprint.is_none() @@ -3017,6 +3140,27 @@ impl WayfernManager { profile.id ); } + let launch_identity = match result_file.as_deref() { + Some(result_file) => { + let account_known = (wayfern_token.is_some() + || !crate::cloud_auth::CLOUD_AUTH + .is_entitled_to_wayfern_token() + .await) + && crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; + let mut document = identity_document + .as_ref() + .and_then(|document| document.as_object().cloned()) + .unwrap_or_default(); + document.extend(crate::wayfern_identity_storage::launch_document_keys( + profile, + config, + result_file, + account_known, + )); + Some(serde_json::Value::Object(document)) + } + None => identity_document.clone(), + }; let identity_file = match &launch_identity { Some(document) => Some( Self::write_launch_identity(profile_path, document) @@ -3025,7 +3169,7 @@ impl WayfernManager { None => None, }; let identity_at_launch = - identity_file.is_some() || (config.identity_id.is_none() && config.fingerprint.is_none()); + identity_document.is_some() || (config.identity_id.is_none() && config.fingerprint.is_none()); let pages = launch_pages(url, &profile.start_urls, kind); let restore_session = match session_restore_verdict( config, @@ -3093,7 +3237,11 @@ impl WayfernManager { &persona_seed, config.persona.as_deref(), )); - args.extend(camera_switches(&profile.version, config)); + args.extend(camera_switches( + &profile.version, + Path::new(profile_path), + config, + )); args.extend(widevine_switch( &profile.version, &crate::app_dirs::data_dir(), @@ -3150,6 +3298,7 @@ impl WayfernManager { } kill_browser_process(pid); } + Self::store_launch_result(profile, config, result_file.as_deref()); return Err(e); } }; @@ -3161,6 +3310,30 @@ impl WayfernManager { page_targets.len() ); + let (launch_result, stored_profile) = + Self::store_launch_result(profile, config, result_file.as_deref()); + // The browser names the identity it launched on; that is the one to check. + let verify_document = match launch_result + .as_ref() + .and_then(|result| result.identity_id.as_deref()) + { + Some(identity_id) => { + let mut document = identity_document + .as_ref() + .and_then(|document| document.as_object().cloned()) + .unwrap_or_default(); + document.insert("identityId".to_string(), json!(identity_id)); + if let Some(os) = launch_result + .as_ref() + .and_then(|result| result.operating_system.as_deref()) + { + document.insert("operatingSystem".to_string(), json!(os)); + } + Some(serde_json::Value::Object(document)) + } + None => identity_document.clone(), + }; + // An identity-backed profile: the id, the user's overrides and the exit's // location are all the browser needs, and all the profile stores. The // device comes back in the response and is deliberately NOT persisted. @@ -3171,7 +3344,7 @@ impl WayfernManager { && !supports_wayfern_152(&profile.version) && config.identity_id.is_some() && config.fingerprint.is_some(); - if let Some(document) = launch_identity.as_ref().filter(|_| identity_file.is_some()) { + if let Some(document) = verify_document.as_ref() { // The identity travelled on the command line. The browser never fails // its own launch over it (a refusal only logs), so the launcher checks, // and a browser running on the wrong device is closed rather than @@ -3496,6 +3669,7 @@ impl WayfernManager { profilePath: Some(profile_path.to_string()), url: url.map(|s| s.to_string()), cdp_port: Some(port), + stored_profile, }) } @@ -3722,6 +3896,7 @@ impl WayfernManager { profilePath: instance.profile_path.clone(), url: instance.url.clone(), cdp_port: instance.cdp_port, + stored_profile: None, }); } else { log::info!( @@ -3777,6 +3952,7 @@ impl WayfernManager { profilePath: Some(found_profile_path), url: None, cdp_port, + stored_profile: None, }); } @@ -4903,6 +5079,9 @@ mod tests { camera_crop: crop.map(str::to_string), ..Default::default() }; + let profile = tempfile::tempdir().unwrap(); + let camera_switches = + |version: &str, config: &WayfernConfig| camera_switches(version, profile.path(), config); assert!(camera_switches("151.0.7922.76", &config(Some(&path), None)).is_empty()); assert!(camera_switches("152.0.7977.64", &config(None, Some("0,0,10,10"))).is_empty()); assert_eq!( @@ -4927,6 +5106,35 @@ mod tests { assert!(valid_camera_crop(" 1, 2, 30, 40 ")); } + #[test] + fn the_camera_source_travels_with_the_profile() { + let chosen_on = tempfile::tempdir().unwrap(); + let file = chosen_on.path().join("frame.png"); + std::fs::write(&file, b"frame").unwrap(); + let path = file.to_string_lossy().to_string(); + let profile = tempfile::tempdir().unwrap(); + + assert_eq!(camera_source(profile.path(), &path), path); + let carried = profile.path().join(CARRIED_CAMERA_DIR).join("frame.png"); + assert_eq!(std::fs::read(&carried).unwrap(), b"frame"); + + // Another machine has neither the folder nor the file, only the profile. + drop(chosen_on); + assert_eq!( + camera_source(profile.path(), &path), + carried.to_string_lossy().to_string() + ); + assert_eq!( + camera_source(profile.path(), r"C:\Users\me\Pictures\frame.png"), + carried.to_string_lossy().to_string(), + "a path written on Windows names the same copy" + ); + assert_eq!( + camera_source(profile.path(), "/nope/other.png"), + "/nope/other.png" + ); + } + #[test] fn widevine_travels_only_when_a_cdm_has_been_provisioned() { let root = tempfile::tempdir().unwrap();