diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs index 0c14828..e907606 100644 --- a/e2e/tests/browser.test.mjs +++ b/e2e/tests/browser.test.mjs @@ -223,8 +223,8 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc "Wayfern returned an incomplete fingerprint", ); // A browser with the identity API must hand back the UUID the device was - // derived from, plus the pre-edit baseline the launch path diffs against. - // Without both, the profile stores a device it cannot reproduce. + // derived from. Without it the profile cannot reproduce the device, since + // it stores none. const identityCapable = Number.parseInt(prepared.version.split(".")[0], 10) >= 151; assert.equal( @@ -232,28 +232,32 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc identityCapable, "identity_id must be present exactly on browsers with the identity API", ); - assert.equal( - typeof sample.identity_baseline === "string", - identityCapable, - "identity_baseline must be present exactly on browsers with the identity API", - ); const profile = await createRealProfile( app, prepared.version, `Real Wayfern (${prepared.source})`, ); - assert.ok(profile.wayfern_config.fingerprint); - assert.ok( - Object.keys(JSON.parse(profile.wayfern_config.fingerprint)).length >= 10, - ); - // Profile creation stores the identity alongside the device it derived, or - // the launch path would treat the profile as un-migrated and replace it. + // An identity-backed profile stores the identity and never the device: the + // browser rebuilds the device from the id on every launch. A browser + // without the identity API has nowhere to put an id, so there the payload + // is still what gets stored. assert.equal( typeof profile.wayfern_config.identity_id === "string", identityCapable, "a created profile must carry the identity its device came from", ); + assert.equal( + profile.wayfern_config.fingerprint === undefined, + identityCapable, + "an identity-backed profile must store no device payload", + ); + if (!identityCapable) { + assert.ok( + Object.keys(JSON.parse(profile.wayfern_config.fingerprint)).length >= + 10, + ); + } assert.equal(await app.invoke("check_missing_geoip_database"), true); assert.equal(await app.invoke("is_geoip_database_available"), false); await app.invoke("download_geoip_database"); @@ -281,9 +285,9 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc "the identity must survive update_wayfern_config and an exit re-match", ); assert.equal( - stored.wayfern_config.identity_baseline, - profile.wayfern_config.identity_baseline, - "the baseline must survive with the identity it describes", + stored.wayfern_config.fingerprint, + undefined, + "neither call may leave a device payload behind", ); } // Pre-launch gate: local-only checks that must answer without starting a diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index e22abf9..19238ff 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -460,23 +460,24 @@ impl BrowserRunner { // Check if we need to generate a device for this launch. // - // Two cases share the block: the user asked for a fresh device on every - // launch, or the profile stores none at all. The second is how a clone - // arrives here — cloning clears the fingerprint and the identity so the - // clone gets an independent device instead of the browser's default — - // and it also covers any profile that reached disk without one, which - // used to launch on whatever device the browser drew for itself. + // Three cases share the block: the user asked for a fresh device on + // every launch, the profile stores none at all, or the profile is legacy + // — a whole device payload and no identity — on a browser that speaks + // the identity API. The second is how a clone arrives here, since + // cloning clears both the payload and the identity so the clone gets an + // independent device instead of the browser's default. // - // A profile that ALREADY stores a device keeps it across a browser - // upgrade: nothing here mints a replacement, and its stored payload is - // what the launch applies. The one thing that does replace a stored - // device is the user asking for it - `randomize_fingerprint_on_launch`, - // tested immediately below - which is a deliberate per-profile setting - // and not a consequence of the version. + // The third is the migration to identity-only storage: donutbrowser + // holds no device on disk, and a payload cannot become an identity + // locally, because only the browser mints an id and the id it mints + // derives its own device. That one-time rotation is the cost of the + // payload leaving disk, and it happens once because the minted id is + // persisted below. let mut updated_profile = profile.clone(); - // ONE-TIME MIGRATION: a profile that still stores a whole device beside - // its identity moves to identity-only storage here, before the launch - // reads it, and the migrated shape is what gets persisted below. + // A profile that stores a whole device BESIDE an identity needs no new + // device, only its payload folded into overrides and location. This runs + // before the launch reads the config, and the migrated shape is what + // gets persisted below. if crate::wayfern_manager::WayfernManager::migrate_identity_config(&mut wayfern_config) { let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default(); crate::wayfern_manager::WayfernManager::migrate_identity_config(&mut cfg); @@ -487,10 +488,18 @@ impl BrowserRunner { ); } let randomize_requested = wayfern_config.randomize_fingerprint_on_launch == Some(true); - let needs_device = - wayfern_config.fingerprint.is_none() && wayfern_config.identity_id.is_none(); + let migrating_payload = wayfern_config.identity_id.is_none() + && wayfern_config.fingerprint.is_some() + && crate::wayfern_manager::supports_identity_api(&profile.version); + let needs_device = migrating_payload + || (wayfern_config.fingerprint.is_none() && wayfern_config.identity_id.is_none()); if randomize_requested || needs_device { - if needs_device && !randomize_requested { + if migrating_payload && !randomize_requested { + log::info!( + "Migrating Wayfern profile {} from a stored device to an identity", + profile.name + ); + } else if needs_device && !randomize_requested { log::info!( "No stored device for Wayfern profile {}; generating one", profile.name @@ -736,27 +745,6 @@ impl BrowserRunner { guard.worker_id = None; } - // The apply command echoes back the device the browser actually used, - // which may differ from the stored one. Persist it so the next launch - // starts from that value — saved below via - // save_process_info(&updated_profile). - // LEGACY profiles only: an identity-backed profile never persists the - // device (the manager returns no echo for it), so this block is reached - // only by a whole-payload profile applied with setFingerprint. - if let Some(used_fp) = wayfern_result.used_fingerprint.clone() { - let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default(); - if cfg.identity_id.is_none() && cfg.fingerprint.as_deref() != Some(used_fp.as_str()) { - log::info!( - "Persisting applied fingerprint echoed by Wayfern for profile: {} (len {})", - profile.name, - used_fp.len() - ); - cfg.fingerprint = Some(used_fp); - cfg.identity_baseline = None; - updated_profile.wayfern_config = Some(cfg); - } - } - // 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/launch_gate_prefs.rs b/src-tauri/src/launch_gate_prefs.rs index ea03b2e..2c0fcb1 100644 --- a/src-tauri/src/launch_gate_prefs.rs +++ b/src-tauri/src/launch_gate_prefs.rs @@ -76,17 +76,31 @@ fn update(mutate: impl FnOnce(&mut LaunchGatePrefs)) { save(&prefs); } -/// Stable digest of a profile's stored fingerprint, so an acknowledgement stops -/// applying the moment the fingerprint is regenerated or matched to a new exit. +/// Stable digest of the device a profile publishes, so an acknowledgement +/// stops applying the moment that device is regenerated or matched to a new +/// exit. +/// +/// An identity-backed profile stores no device at all: the identity, the user's +/// overrides and the exit's location are the whole of it, and the published +/// device moves exactly when one of the three does. The legacy payload is +/// hashed alongside them for a profile that has not been migrated yet. pub fn fingerprint_hash(profile: &BrowserProfile) -> String { use sha2::{Digest, Sha256}; - let fingerprint = profile - .wayfern_config - .as_ref() - .and_then(|c| c.fingerprint.as_deref()) - .unwrap_or(""); let mut hasher = Sha256::new(); - hasher.update(fingerprint.as_bytes()); + if let Some(config) = profile.wayfern_config.as_ref() { + for field in [ + config.identity_id.as_deref(), + config.identity_overrides.as_deref(), + config.location.as_deref(), + config.fingerprint.as_deref(), + ] { + // Length-prefixed, so moving a boundary between two fields cannot + // produce the digest of a different pair. + let value = field.unwrap_or(""); + hasher.update(value.len().to_le_bytes()); + hasher.update(value.as_bytes()); + } + } hasher .finalize() .iter() diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 9094f34..8fb3e95 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -181,6 +181,21 @@ impl ProfileManager { // behavior; for generated ones this comes from the geolocation lookup. let mut geolocation_applied = true; + // A caller-supplied device is a set of explicit field choices, not a + // payload to store. On a browser with the identity API it becomes the + // identity's overrides and its location, and the device is minted from a + // freshly created identity below like any other profile's. A browser + // without that API has nowhere to put the choices, so there it stays the + // stored payload. + let supplied_device = if crate::wayfern_manager::supports_identity_api(version) { + config + .fingerprint + .take() + .and_then(|json| crate::wayfern_manager::WayfernManager::fingerprint_object(&json)) + } else { + None + }; + // Generate a device if the profile has neither a legacy payload nor an // identity. if config.fingerprint.is_none() && config.identity_id.is_none() { @@ -248,6 +263,19 @@ impl ProfileManager { log::info!("Using provided fingerprint for Wayfern profile: {name}"); } + if let Some(object) = supplied_device { + let overrides = + crate::wayfern_manager::WayfernManager::overrides_from_explicit_fingerprint(&object); + if !overrides.is_empty() { + config.identity_overrides = serde_json::to_string(&overrides).ok(); + } + // A location the caller named wins over the one resolved for the exit; + // whatever it leaves out keeps the resolved value. + if let Some(location) = crate::wayfern_manager::WayfernManager::location_of(&object) { + config.location = Some(location); + } + } + // Record which proxy/geoip the fingerprint's location data was computed // for. On launch this is compared against the profile's current routing // so a proxy that was changed after creation triggers a location refresh @@ -1128,7 +1156,7 @@ impl ProfileManager { updated_at: Some(crate::proxy_manager::now_secs()), }; - // Donut: a clone must NOT be linkable to its source. The source + // A clone must NOT be linkable to its source. The source // wayfern_config embeds the persisted fingerprint JSON (including the // canvas_noise_seed), so copying it verbatim makes the clone emit // BYTE-IDENTICAL canvas/WebGL/audio readback hashes and identical device diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 49b39e9..e29ac7b 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -923,6 +923,19 @@ impl ProfileImporter { let final_wayfern_config = if mapped == "wayfern" { let mut config = wayfern_config.unwrap_or_default(); + // A caller-supplied device is a set of explicit field choices, not a + // payload to store: on a browser with the identity API it becomes the + // identity's overrides and its location, and the device is minted from a + // freshly created identity below. + let supplied_device = if crate::wayfern_manager::supports_identity_api(&version) { + config + .fingerprint + .take() + .and_then(|json| crate::wayfern_manager::WayfernManager::fingerprint_object(&json)) + } else { + None + }; + if let Some(ref proxy_id_val) = proxy_id { if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) { let proxy_url = if let (Some(username), Some(password)) = @@ -1012,6 +1025,17 @@ impl ProfileImporter { } } + if let Some(object) = supplied_device { + let overrides = + crate::wayfern_manager::WayfernManager::overrides_from_explicit_fingerprint(&object); + if !overrides.is_empty() { + config.identity_overrides = serde_json::to_string(&overrides).ok(); + } + if let Some(location) = crate::wayfern_manager::WayfernManager::location_of(&object) { + config.location = Some(location); + } + } + config.proxy = None; Some(config) } else { diff --git a/src-tauri/src/proxy_server.rs b/src-tauri/src/proxy_server.rs index 34a3c97..af2f6ea 100644 --- a/src-tauri/src/proxy_server.rs +++ b/src-tauri/src/proxy_server.rs @@ -1191,7 +1191,7 @@ fn build_reqwest_client_with_proxy( Proxy::http(upstream_url)? } "socks5" => { - // Donut: force REMOTE (proxy-side) DNS for plaintext HTTP over a SOCKS5 + // Force REMOTE (proxy-side) DNS for plaintext HTTP over a SOCKS5 // upstream. reqwest maps the bare `socks5` scheme to DnsResolve::Local, // which resolves the destination hostname on the HOST (getaddrinfo) BEFORE // connecting — leaking the destination domain to the host's DNS resolver diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 4a0fb41..a2d4099 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -3522,7 +3522,7 @@ pub async fn set_profile_sync_mode( // tokio::spawn here allowed the tombstone-write to land *after* a fast // user-triggered re-enable's tombstone-clear, re-introducing the // tombstone and tripping the reconcile-pass deletion of a profile the - // user had just re-enabled (e.g. Personal (z.ai) on 2026-05-20). + // user had just re-enabled. if old_mode != SyncMode::Disabled { match SyncEngine::create_from_settings(&app_handle).await { Ok(engine) => { diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index d6a0b9a..0beec8f 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -15,7 +15,11 @@ use tokio_tungstenite::{connect_async, tungstenite::Message}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct WayfernConfig { - #[serde(default)] + /// LEGACY device payload, carried only by a profile whose browser has no + /// identity API. Every other profile is rebuilt from `identity_id`, so this + /// is read from older metadata and from a caller that supplies a whole + /// device, and is never written once the profile has an identity. + #[serde(default, skip_serializing_if = "Option::is_none")] pub fingerprint: Option, #[serde(default)] pub randomize_fingerprint_on_launch: Option, @@ -55,8 +59,9 @@ pub struct WayfernConfig { pub identity_id: Option, /// LEGACY, read only by `migrate_identity_config`: the derived device an /// older build snapshotted so the user's edits could be diffed out of the - /// stored payload. Cleared by the migration; never written again. - #[serde(default)] + /// stored payload. Cleared by the migration and never serialized again, so + /// a migrated profile carries no trace of it. + #[serde(default, skip_serializing_if = "Option::is_none")] pub identity_baseline: Option, /// The user's own edits to an identity-backed device, as a JSON object of /// fingerprint fields. Sent verbatim as `setIdentity` overrides; everything @@ -158,16 +163,6 @@ pub struct WayfernLaunchResult { pub profilePath: Option, pub url: Option, pub cdp_port: Option, - /// The fingerprint the browser echoed back after applying it. It may differ - /// from what was sent, so it is this value that gets persisted. Internal - /// only — never sent to the frontend. - #[serde(default, skip_serializing)] - pub used_fingerprint: Option, - /// The refreshed baseline to persist alongside `used_fingerprint`. Keeping - /// it in step is what stops an unedited field from being mistaken for a user - /// edit on the next launch. Internal only. - #[serde(default, skip_serializing)] - pub used_identity_baseline: Option, } struct WayfernInstance { @@ -500,34 +495,6 @@ impl WayfernManager { params } - /// Fill in any location field the applied device does not already carry. - /// - /// `setIdentity` takes the location as its own parameters rather than inside - /// the identity, so the view it echoes back may omit part of it — and donut's - /// stored fingerprint must always carry the whole block, because the launch - /// gate reads it before any browser is running and a stored device with no - /// timezone turns the exit-vs-fingerprint check into a no-op. - /// - /// Only ABSENT fields are filled. Anything the browser did send back is its - /// own and is kept: it re-roots the `languages` ladder onto the exit's - /// language, which is a better answer than the two-entry list donut computes. - fn carry_over_locale( - from: &serde_json::Map, - into: &mut serde_json::Value, - ) { - let Some(target) = into.as_object_mut() else { - return; - }; - for key in LOCALE_CARRY_OVER_KEYS { - if target.get(key).is_some_and(|v| !v.is_null()) { - continue; - } - if let Some(value) = from.get(key) { - target.insert(key.to_string(), value.clone()); - } - } - } - /// One of Wayfern's five `operatingSystem` names, or `None` for anything /// else. Unknown names are not guessed at: the caller treats `None` as "donut /// does not know what this profile claims" and lets the browser decide. @@ -1468,11 +1435,6 @@ impl WayfernManager { let page_targets: Vec<_> = targets.iter().filter(|t| t.target_type == "page").collect(); log::info!("Found {} page targets", page_targets.len()); - // Apply fingerprint if configured - let mut used_fingerprint: Option = None; - // Always None: nothing writes a baseline any more. The field stays on the - // result for the one launch that still migrates a legacy identity profile. - let used_identity_baseline: Option = None; // 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. @@ -1616,77 +1578,20 @@ impl WayfernManager { // Include wayfern token if available (enables cross-OS fingerprinting for paid users) let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; - // The device as donut holds it: the diff source for the overrides below, - // and the fallback for any location field the echo does not return. + // The device as donut holds it, for the diagnostic below. let stored = fingerprint_for_cdp.as_object().cloned().unwrap_or_default(); - // Which command applies this profile's device. It is a property of the - // PROFILE, not of the browser version, so a profile that stores a whole - // payload keeps being applied with the payload command. - // - // `webglProfileId` is the discriminator: only a whole-payload profile - // carries it, and the browser refuses it as an override. Sending it would - // fail the call on every launch. - let apply_by_identity = supports_identity_api(&profile.version) - && config.identity_id.is_some() - && stored.get("webglProfileId").is_none(); - - // On the identity path only the user's own edits are sent; everything - // else comes from the identity itself. - let (apply_method, apply_params, _previous_baseline, overrides) = - match config.identity_id.as_deref().filter(|_| apply_by_identity) { - Some(identity_id) => { - let previous_baseline = config - .identity_baseline - .as_deref() - .and_then(Self::fingerprint_object) - .unwrap_or_default(); - let overrides = Self::identity_overrides(&stored, &previous_baseline); - - let mut params = serde_json::Map::new(); - params.insert("identityId".to_string(), json!(identity_id)); - if !overrides.is_empty() { - params.insert( - "overrides".to_string(), - serde_json::Value::Object(overrides.clone()), - ); - } - // Location is a property of the exit, not of the identity, so it - // travels in setIdentity's own parameters rather than as an override. - params.extend(Self::geo_params(&stored)); - if let Some(ref token) = wayfern_token { - params.insert("wayfernToken".to_string(), json!(token)); - } - - log::info!( - "Applying Wayfern identity {} with {} override(s): {:?}", - identity_id, - overrides.len(), - overrides.keys().collect::>() - ); - - ( - "Wayfern.setIdentity", - serde_json::Value::Object(params), - previous_baseline, - overrides, - ) - } - None => { - let mut params = fingerprint_for_cdp.clone(); - if let Some(ref token) = wayfern_token { - if let Some(obj) = params.as_object_mut() { - obj.insert("wayfernToken".to_string(), json!(token)); - } - } - ( - "Wayfern.setFingerprint", - params, - serde_json::Map::new(), - serde_json::Map::new(), - ) - } - }; + // `setFingerprint` is the only command that reproduces a whole payload + // exactly, and on a browser without the identity API it is the only + // command there is. A profile whose browser HAS that API never reaches + // here: the launch path mints it an identity and drops the payload + // first, so a stored device is never sent as a device again. + let mut apply_params = fingerprint_for_cdp.clone(); + if let Some(ref token) = wayfern_token { + if let Some(obj) = apply_params.as_object_mut() { + obj.insert("wayfernToken".to_string(), json!(token)); + } + } // An apply that never lands is the worst outcome this launch has: the // window opens on an unmanaged device while every surface in the app @@ -1699,45 +1604,12 @@ impl WayfernManager { if let Some(ws_url) = &target.websocket_debugger_url { log::info!("Applying fingerprint to page target"); match self - .send_cdp_command(ws_url, apply_method, apply_params.clone()) + .send_cdp_command(ws_url, "Wayfern.setFingerprint", apply_params.clone()) .await { - Ok(result) => { - // The device is on the target. Whether the ECHO parses is a - // separate question — it only decides what we persist. + Ok(_) => { applied_ok = true; log::info!("Successfully applied fingerprint to page target"); - // Both commands echo back the device the browser actually used, - // which may differ from what we sent. Capture it once, from the - // first target that succeeds, so the caller can persist it. - if used_fingerprint.is_none() { - // setIdentity wraps the object as { identity: {...} }, - // setFingerprint as { fingerprint: {...} }; tolerate a bare - // object too. - let applied = result - .get("identity") - .or_else(|| result.get("fingerprint")) - .cloned() - .unwrap_or(result); - if let Some(applied_obj) = applied.as_object() { - let mut persisted = applied; - if apply_by_identity { - // The location travelled as setIdentity parameters rather - // than inside the identity, so make sure it survives into - // what we store. The launch gate and the pre-launch window - // sizing both read the stored fingerprint before any - // browser is running, and a stored device with no timezone - // silently turns the exit-vs-fingerprint check into a no-op. - Self::carry_over_locale(&stored, &mut persisted); - } - match serde_json::to_string(&Self::normalize_fingerprint(persisted)) { - Ok(s) => used_fingerprint = Some(s), - Err(e) => { - log::warn!("Failed to serialize used fingerprint: {e}") - } - } - } - } } Err(e) => { log::error!("Failed to apply fingerprint to target: {e}"); @@ -1829,8 +1701,6 @@ impl WayfernManager { profilePath: Some(profile_path.to_string()), url: url.map(|s| s.to_string()), cdp_port: Some(port), - used_fingerprint, - used_identity_baseline, }) } @@ -1958,8 +1828,6 @@ impl WayfernManager { profilePath: instance.profile_path.clone(), url: instance.url.clone(), cdp_port: instance.cdp_port, - used_fingerprint: None, - used_identity_baseline: None, }); } else { log::info!( @@ -2002,8 +1870,6 @@ impl WayfernManager { profilePath: Some(found_profile_path), url: None, cdp_port, - used_fingerprint: None, - used_identity_baseline: None, }); } @@ -2283,31 +2149,110 @@ mod tests { } #[test] - fn the_launch_echo_only_fills_location_the_browser_left_out() { - // setIdentity carries the location in its own parameters, so the applied - // view may not echo all of it back. The stored fingerprint has to keep it: - // the launch gate reads `timezone` before any browser is running, and a - // stored device without one turns that check into a no-op. - let stored = obj( - r#"{"timezone": "Europe/Berlin", "timezoneOffset": -60, - "language": "de-DE", "languages": ["de-DE", "de"]}"#, - ); - // The browser returned its own, richer `languages` ladder and dropped the - // rest. - let mut applied = json!({"languages": ["de-DE", "de", "en-US", "en"]}); + fn migration_moves_a_stored_payload_into_overrides_and_location() { + let mut config = WayfernConfig { + identity_id: Some("id-1".to_string()), + identity_baseline: Some(r#"{"hardwareConcurrency": 8, "platform": "Win32"}"#.to_string()), + fingerprint: Some( + r#"{"hardwareConcurrency": 16, "platform": "Win32", "timezone": "Europe/Berlin"}"# + .to_string(), + ), + ..Default::default() + }; - WayfernManager::carry_over_locale(&stored, &mut applied); - let applied = applied.as_object().unwrap(); + assert!(WayfernManager::migrate_identity_config(&mut config)); + assert!(config.fingerprint.is_none()); + assert!(config.identity_baseline.is_none()); - assert_eq!(applied.get("timezone"), Some(&json!("Europe/Berlin"))); - assert_eq!(applied.get("timezoneOffset"), Some(&json!(-60))); - assert_eq!(applied.get("language"), Some(&json!("de-DE"))); - // What the browser DID return wins: it re-roots the ladder onto the exit's - // language, which is a better answer than the two-entry list donut builds. + let overrides = obj(config.identity_overrides.as_deref().unwrap()); + assert_eq!(overrides.get("hardwareConcurrency"), Some(&json!(16))); + assert!(overrides.get("platform").is_none()); + // Location is the one piece of device state a migrated profile keeps: it + // follows the exit, not the identity. + let location = obj(config.location.as_deref().unwrap()); + assert_eq!(location.get("timezone"), Some(&json!("Europe/Berlin"))); + + // Running again must change nothing, because a profile is migrated on + // whichever launch reaches it first and every later launch repeats it. + let after_first = serde_json::to_string(&config).unwrap(); + assert!(!WayfernManager::migrate_identity_config(&mut config)); + assert_eq!(serde_json::to_string(&config).unwrap(), after_first); + } + + #[test] + fn migration_is_a_no_op_for_an_already_identity_only_profile() { + let mut config = WayfernConfig { + identity_id: Some("id-1".to_string()), + identity_overrides: Some(r#"{"doNotTrack":"1"}"#.to_string()), + location: Some(r#"{"timezone":"Europe/Berlin"}"#.to_string()), + ..Default::default() + }; + + assert!(!WayfernManager::migrate_identity_config(&mut config)); + assert!(config.fingerprint.is_none()); assert_eq!( - applied.get("languages"), - Some(&json!(["de-DE", "de", "en-US", "en"])) + config.identity_overrides.as_deref(), + Some(r#"{"doNotTrack":"1"}"#) ); + assert_eq!( + config.location.as_deref(), + Some(r#"{"timezone":"Europe/Berlin"}"#) + ); + } + + #[test] + fn migration_leaves_a_payload_only_profile_for_the_launch_path() { + // A legacy profile has no identity for its edits to sit on, and only the + // browser can mint one. The payload stays until the launch path replaces + // it with a fresh identity, so the profile is never left with neither. + let mut config = WayfernConfig { + fingerprint: Some(r#"{"platform":"Win32"}"#.to_string()), + ..Default::default() + }; + + assert!(!WayfernManager::migrate_identity_config(&mut config)); + assert_eq!(config.fingerprint.as_deref(), Some(r#"{"platform":"Win32"}"#)); + assert!(config.identity_id.is_none()); + assert!(config.identity_overrides.is_none()); + } + + #[test] + fn migration_has_nothing_to_do_for_a_config_with_neither() { + let mut config = WayfernConfig::default(); + + assert!(!WayfernManager::migrate_identity_config(&mut config)); + assert!(config.fingerprint.is_none()); + assert!(config.identity_id.is_none()); + assert!(config.identity_overrides.is_none()); + assert!(config.location.is_none()); + } + + #[test] + fn migration_clears_a_baseline_left_behind_without_a_payload() { + let mut config = WayfernConfig { + identity_id: Some("id-1".to_string()), + identity_baseline: Some(r#"{"platform":"Win32"}"#.to_string()), + ..Default::default() + }; + + assert!(WayfernManager::migrate_identity_config(&mut config)); + assert!(config.identity_baseline.is_none()); + assert!(!WayfernManager::migrate_identity_config(&mut config)); + } + + #[test] + fn a_migrated_config_writes_no_device_to_disk() { + let mut config = WayfernConfig { + identity_id: Some("id-1".to_string()), + fingerprint: Some(r#"{"platform":"Win32","timezone":"Europe/Berlin"}"#.to_string()), + ..Default::default() + }; + + assert!(WayfernManager::migrate_identity_config(&mut config)); + let written = serde_json::to_string(&config).unwrap(); + assert!(!written.contains("\"fingerprint\"")); + assert!(!written.contains("\"identity_baseline\"")); + assert!(written.contains("\"location\"")); } #[test] diff --git a/src/components/wayfern-config-form.tsx b/src/components/wayfern-config-form.tsx index dc2b302..a91f1f3 100644 --- a/src/components/wayfern-config-form.tsx +++ b/src/components/wayfern-config-form.tsx @@ -120,7 +120,6 @@ export function WayfernConfigForm({ result.identity_id ? undefined : result.fingerprint, ); onConfigChange("identity_overrides", undefined); - onConfigChange("identity_baseline", undefined); } catch (error) { console.error("Failed to generate fingerprint:", error); } finally { diff --git a/src/hooks/use-group-events.ts b/src/hooks/use-group-events.ts index 9ff8684..3f3a6e4 100644 --- a/src/hooks/use-group-events.ts +++ b/src/hooks/use-group-events.ts @@ -74,14 +74,10 @@ export function useGroupEvents() { void setupListeners(); - // Cleanup listeners on unmount. - // NOTE: the previous version stored both unlisten fns by reassigning - // `groupsUnlisten` to a wrapper that called itself, which produced a - // `Maximum call stack size exceeded` crash whenever this effect tore - // down. React's reconciler then bailed out mid-commit and left stale - // overlay nodes in the DOM, blocking every subsequent click in the - // window. Holding the two unlisten fns in separate locals avoids both - // problems. + // Cleanup listeners on unmount. The two unlisten fns stay in separate + // locals: merging them into one wrapper that reassigns the local it then + // calls makes that wrapper call itself, and the stack overflow aborts the + // teardown mid-commit, leaving stale overlay nodes that swallow clicks. return () => { if (groupsUnlisten) groupsUnlisten(); if (profilesUnlisten) profilesUnlisten(); diff --git a/src/types.ts b/src/types.ts index 41e0238..bf65c1c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -414,7 +414,6 @@ export interface WayfernConfig { os?: WayfernOS; // Operating system for fingerprint generation geo_proxy_signature?: string; // Internal: routing the fingerprint's location was computed for identity_id?: string; // Internal: UUID the device is derived from on browsers with the identity API - identity_baseline?: string; // Legacy: read once by the migration to identity-only storage, never written identity_overrides?: string; // JSON object of the user's own edits to an identity-backed device location?: string; // JSON object of the exit-derived location fields (timezone, language, coordinates) }