mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-09 03:08:58 +02:00
refactor: cleanup
This commit is contained in:
+20
-16
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+122
-177
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub randomize_fingerprint_on_launch: Option<bool>,
|
||||
@@ -55,8 +59,9 @@ pub struct WayfernConfig {
|
||||
pub identity_id: Option<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
pub url: Option<String>,
|
||||
pub cdp_port: Option<u16>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String, serde_json::Value>,
|
||||
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<String> = 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<String> = 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::<Vec<_>>()
|
||||
);
|
||||
|
||||
(
|
||||
"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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user