mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-28 13:50:26 +02:00
2424 lines
92 KiB
Rust
2424 lines
92 KiB
Rust
use crate::browser_runner::BrowserRunner;
|
|
use crate::profile::BrowserProfile;
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::process::Stdio;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tauri::AppHandle;
|
|
use tokio::process::Command as TokioCommand;
|
|
use tokio::sync::Mutex as AsyncMutex;
|
|
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct WayfernConfig {
|
|
#[serde(default)]
|
|
pub fingerprint: Option<String>,
|
|
#[serde(default)]
|
|
pub randomize_fingerprint_on_launch: Option<bool>,
|
|
#[serde(default)]
|
|
pub os: Option<String>,
|
|
#[serde(default)]
|
|
pub screen_max_width: Option<u32>,
|
|
#[serde(default)]
|
|
pub screen_max_height: Option<u32>,
|
|
#[serde(default)]
|
|
pub screen_min_width: Option<u32>,
|
|
#[serde(default)]
|
|
pub screen_min_height: Option<u32>,
|
|
#[serde(default)]
|
|
pub geoip: Option<serde_json::Value>, // For compatibility with shared config form
|
|
#[serde(default)]
|
|
pub block_images: Option<bool>, // For compatibility with shared config form
|
|
#[serde(default)]
|
|
pub block_webrtc: Option<bool>,
|
|
#[serde(default)]
|
|
pub block_webgl: Option<bool>,
|
|
#[serde(default, skip_serializing)]
|
|
pub proxy: Option<String>,
|
|
/// Stable signature of the proxy/VPN/geoip the fingerprint's location data
|
|
/// (timezone, latitude/longitude, language) was last computed for. Compared
|
|
/// on launch to detect that the routing changed since creation, so the
|
|
/// location can be refreshed instead of showing stale data.
|
|
#[serde(default)]
|
|
pub geo_proxy_signature: Option<String>,
|
|
/// Identity handle for this profile, when it has one. `None` means the
|
|
/// profile stores a whole fingerprint payload instead.
|
|
#[serde(default)]
|
|
pub identity_id: Option<String>,
|
|
/// The fingerprint as first received for `identity_id`, before geolocation
|
|
/// and before any user edit. Diffed against `fingerprint` on launch to
|
|
/// recover the user's own edits.
|
|
#[serde(default)]
|
|
pub identity_baseline: Option<String>,
|
|
}
|
|
|
|
/// First Wayfern version that ships `createIdentity`/`setIdentity`/
|
|
/// `getIdentity`. Those commands are ADDITIVE: `setFingerprint`,
|
|
/// `getFingerprint` and `refreshFingerprint` are all still declared in the 151
|
|
/// protocol and still implemented, so this constant means "the identity API is
|
|
/// available here", never "the legacy commands are gone". A profile that
|
|
/// stores a whole device payload keeps being applied with `setFingerprint` on
|
|
/// 151, which is the only command that reproduces such a payload exactly.
|
|
///
|
|
/// Written as a full version rather than a major so it can be pinned to an
|
|
/// exact build if the identity commands land part-way through the 151 line;
|
|
/// missing components compare as zero, so `"151"` means "any 151 or newer".
|
|
const IDENTITY_API_MIN_VERSION: &str = "151";
|
|
|
|
/// Whether `version` speaks the identity API.
|
|
///
|
|
/// `version` must be `BrowserProfile::version`, which is the field
|
|
/// `BrowserRunner::get_browser_executable_path` resolves the binary from, so it
|
|
/// is by construction the version that will actually launch. Read it at the
|
|
/// point of use and never cache it: `auto_updater` rewrites it when the browser
|
|
/// stops (`browser_runner.rs`, the pending-update block), so a value captured
|
|
/// before that point can describe a binary that is no longer on disk.
|
|
///
|
|
/// An unparsable version reads as 0.0.0.0 and therefore takes the legacy path,
|
|
/// which is the safe direction: the legacy commands exist on every Wayfern that
|
|
/// ever shipped, while `createIdentity` on an older build is an unknown method.
|
|
pub fn supports_identity_api(version: &str) -> bool {
|
|
crate::api_client::compare_versions(version, IDENTITY_API_MIN_VERSION) != std::cmp::Ordering::Less
|
|
}
|
|
|
|
/// Fingerprint fields the browser takes as dedicated parameters rather than as
|
|
/// overrides. They describe the exit IP, so they travel through their own
|
|
/// channel instead of being duplicated into `overrides`.
|
|
const GEO_PARAM_KEYS: [&str; 4] = ["timezone", "language", "latitude", "longitude"];
|
|
|
|
/// Location fields `apply_geolocation` also writes locally, so the stored
|
|
/// fingerprint is complete before any browser runs. A value donut synthesised
|
|
/// itself is filtered out of the override diff; only a value the user actually
|
|
/// edited travels.
|
|
const GEO_DERIVED_KEYS: [&str; 2] = ["timezoneOffset", "languages"];
|
|
|
|
/// Fields the browser refuses in `overrides`. It rejects the entire call when
|
|
/// one appears, so they must never diff into the override set — that would be a
|
|
/// permanent, every-launch failure rather than a one-off.
|
|
const DERIVED_PROVENANCE_KEYS: [&str; 3] =
|
|
["webglProfileId", "mediaProfile", "deviceProfileApplied"];
|
|
|
|
/// Location fields donutbrowser owns end to end (`apply_geolocation` writes all
|
|
/// of them). Any of these the browser does not echo back after an identity is
|
|
/// applied is refilled from the stored fingerprint, so what donut persists
|
|
/// always carries the location the launch gate reads.
|
|
const LOCALE_CARRY_OVER_KEYS: [&str; 7] = [
|
|
"timezone",
|
|
"timezoneOffset",
|
|
"language",
|
|
"languages",
|
|
"latitude",
|
|
"longitude",
|
|
"accuracy",
|
|
];
|
|
|
|
/// A freshly generated device, plus its identity handle when the browser
|
|
/// supports identities.
|
|
pub struct GeneratedFingerprint {
|
|
/// The fingerprint JSON to store in `WayfernConfig::fingerprint`. Both paths
|
|
/// produce a flat camelCase object, so everything that reads the stored
|
|
/// fingerprint keeps working either way.
|
|
pub fingerprint: String,
|
|
pub identity_id: Option<String>,
|
|
pub identity_baseline: Option<String>,
|
|
/// Whether fresh geolocation was resolved and applied. Callers must only
|
|
/// stamp `geo_proxy_signature` when this is true.
|
|
pub geolocation_applied: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[allow(non_snake_case)]
|
|
pub struct WayfernLaunchResult {
|
|
pub id: String,
|
|
#[serde(alias = "process_id")]
|
|
pub processId: Option<u32>,
|
|
#[serde(alias = "profile_path")]
|
|
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 {
|
|
id: String,
|
|
process_id: Option<u32>,
|
|
profile_path: Option<String>,
|
|
url: Option<String>,
|
|
cdp_port: Option<u16>,
|
|
}
|
|
|
|
struct WayfernManagerInner {
|
|
instances: HashMap<String, WayfernInstance>,
|
|
}
|
|
|
|
pub struct WayfernManager {
|
|
inner: Arc<AsyncMutex<WayfernManagerInner>>,
|
|
http_client: Client,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct CdpTarget {
|
|
#[serde(rename = "type")]
|
|
target_type: String,
|
|
#[serde(rename = "webSocketDebuggerUrl")]
|
|
websocket_debugger_url: Option<String>,
|
|
}
|
|
|
|
impl WayfernManager {
|
|
fn new() -> Self {
|
|
Self {
|
|
inner: Arc::new(AsyncMutex::new(WayfernManagerInner {
|
|
instances: HashMap::new(),
|
|
})),
|
|
// CDP is always on loopback. Disable env/system proxies so a Windows
|
|
// WinHTTP/IE proxy (or HTTP_PROXY) cannot intercept /json/version and
|
|
// return 502 Bad Gateway while the browser is actually listening.
|
|
http_client: Client::builder()
|
|
.timeout(Duration::from_secs(2))
|
|
.no_proxy()
|
|
.build()
|
|
.expect("Failed to build reqwest client for wayfern_manager"),
|
|
}
|
|
}
|
|
|
|
pub fn instance() -> &'static WayfernManager {
|
|
&WAYFERN_MANAGER
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn get_profiles_dir(&self) -> PathBuf {
|
|
crate::app_dirs::profiles_dir()
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn get_binaries_dir(&self) -> PathBuf {
|
|
crate::app_dirs::binaries_dir()
|
|
}
|
|
|
|
async fn find_free_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
|
let port = listener.local_addr()?.port();
|
|
drop(listener);
|
|
Ok(port)
|
|
}
|
|
|
|
/// Normalize fingerprint data from Wayfern CDP format to our storage format.
|
|
/// Wayfern returns fields like fonts, webglParameters as JSON strings which we keep as-is.
|
|
fn normalize_fingerprint(fingerprint: serde_json::Value) -> serde_json::Value {
|
|
// Our storage format matches what Wayfern returns:
|
|
// - fonts, plugins, mimeTypes, voices are JSON strings
|
|
// - webglParameters, webgl2Parameters, etc. are JSON strings
|
|
// The form displays them as JSON text areas, so no conversion needed.
|
|
fingerprint
|
|
}
|
|
|
|
/// Denormalize fingerprint data from our storage format to Wayfern CDP format.
|
|
/// Wayfern expects certain fields as JSON strings.
|
|
fn denormalize_fingerprint(fingerprint: serde_json::Value) -> serde_json::Value {
|
|
// Our storage format matches what Wayfern expects:
|
|
// - fonts, plugins, mimeTypes, voices are JSON strings
|
|
// - webglParameters, webgl2Parameters, etc. are JSON strings
|
|
// So no conversion is needed
|
|
fingerprint
|
|
}
|
|
|
|
/// Derive the on-screen window size Chromium should open at, from the stored
|
|
/// fingerprint. Applying a device over CDP only spoofs what the page
|
|
/// *reports* for `windowOuterWidth`/`screenWidth`/etc.; it does not move or
|
|
/// resize the real top-level window. Without `--window-size` the OS window keeps
|
|
/// Chromium's default, so the visible window contradicts the reported
|
|
/// dimensions — a detectable mismatch. We pass `--window-size` so the actual
|
|
/// window matches the fingerprint.
|
|
///
|
|
/// Keys are the camelCase fields Wayfern uses in its fingerprint
|
|
/// (`windowOuterWidth`, `screenAvailWidth`, …) — NOT the dotted
|
|
/// Preference order, matching how the fingerprint
|
|
/// describes the window:
|
|
/// 1. `windowOuterWidth` / `windowOuterHeight` — the real window size.
|
|
/// 2. `screenAvailWidth` / `screenAvailHeight` — usable screen area.
|
|
/// 3. `screenWidth` / `screenHeight` — full screen.
|
|
///
|
|
/// Returns `None` when the fingerprint carries no usable dimensions, leaving
|
|
/// Chromium's default untouched. The fingerprint JSON may be the bare object
|
|
/// or the legacy `{ "fingerprint": {...} }` wrapper.
|
|
fn window_size_from_fingerprint(fingerprint_json: &str) -> Option<(u32, u32)> {
|
|
let parsed: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?;
|
|
let fp = parsed.get("fingerprint").unwrap_or(&parsed);
|
|
let obj = fp.as_object()?;
|
|
|
|
// Accept both numeric and stringified numbers (Wayfern emits numbers, but a
|
|
// CDP echo or older saved fingerprint may stringify them).
|
|
let read = |key: &str| -> Option<u32> {
|
|
let v = obj.get(key)?;
|
|
v.as_u64()
|
|
.or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
|
|
.filter(|n| *n > 0)
|
|
.map(|n| n as u32)
|
|
};
|
|
let pair = |w: &str, h: &str| -> Option<(u32, u32)> { Some((read(w)?, read(h)?)) };
|
|
|
|
pair("windowOuterWidth", "windowOuterHeight")
|
|
.or_else(|| pair("screenAvailWidth", "screenAvailHeight"))
|
|
.or_else(|| pair("screenWidth", "screenHeight"))
|
|
}
|
|
|
|
/// Parse a stored fingerprint JSON into its object, tolerating the legacy
|
|
/// `{ "fingerprint": {...} }` wrapper some old profiles carry.
|
|
fn fingerprint_object(
|
|
fingerprint_json: &str,
|
|
) -> Option<serde_json::Map<String, serde_json::Value>> {
|
|
let parsed: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?;
|
|
let fp = parsed.get("fingerprint").unwrap_or(&parsed);
|
|
fp.as_object().cloned()
|
|
}
|
|
|
|
/// The user's edits, recovered as the difference between the fingerprint the
|
|
/// profile stores and the view the browser derived from the identity.
|
|
///
|
|
/// The fingerprint form writes the whole edited object back over
|
|
/// `WayfernConfig::fingerprint`, so the edits are not recorded anywhere on
|
|
/// their own; the baseline is what makes them recoverable. Everything absent
|
|
/// from the diff is supplied by the identity.
|
|
///
|
|
/// Three exclusions, each for its own reason:
|
|
///
|
|
/// - `GEO_PARAM_KEYS`, because `setIdentity` takes them as dedicated
|
|
/// parameters; sending them twice invites the two copies to disagree.
|
|
/// - `DERIVED_PROVENANCE_KEYS`, because the browser rejects the whole call
|
|
/// when one appears. A stored payload carrying them would otherwise diff
|
|
/// every one into the override set and fail on every launch.
|
|
/// - a `GEO_DERIVED_KEYS` value donut synthesised itself, because the
|
|
/// baseline is snapshotted BEFORE geolocation runs, so those two keys
|
|
/// always differ and would otherwise be pinned as user overrides for the
|
|
/// life of the profile. A value that is NOT what donut would have written
|
|
/// is a genuine edit and still travels.
|
|
fn identity_overrides(
|
|
current: &serde_json::Map<String, serde_json::Value>,
|
|
baseline: &serde_json::Map<String, serde_json::Value>,
|
|
) -> serde_json::Map<String, serde_json::Value> {
|
|
let synthesised = Self::donut_synthesised_geo_fields(current);
|
|
let mut overrides = serde_json::Map::new();
|
|
for (key, value) in current {
|
|
if GEO_PARAM_KEYS.contains(&key.as_str()) || DERIVED_PROVENANCE_KEYS.contains(&key.as_str()) {
|
|
continue;
|
|
}
|
|
if GEO_DERIVED_KEYS.contains(&key.as_str()) && synthesised.get(key) == Some(value) {
|
|
continue;
|
|
}
|
|
if baseline.get(key) != Some(value) {
|
|
overrides.insert(key.clone(), value.clone());
|
|
}
|
|
}
|
|
overrides
|
|
}
|
|
|
|
/// What `apply_geolocation` would write into `GEO_DERIVED_KEYS` for the
|
|
/// `timezone`/`language` this fingerprint already carries.
|
|
///
|
|
/// Built by calling the same helpers the writer calls, so the two cannot
|
|
/// compute a different answer for the same input. A key is absent when what
|
|
/// it is derived from is missing or unparsable, which leaves the value
|
|
/// looking like an edit — the conservative direction, since it only means an
|
|
/// override travels that did not have to.
|
|
fn donut_synthesised_geo_fields(
|
|
fingerprint: &serde_json::Map<String, serde_json::Value>,
|
|
) -> serde_json::Map<String, serde_json::Value> {
|
|
let mut derived = serde_json::Map::new();
|
|
if let Some(minutes) = fingerprint
|
|
.get("timezone")
|
|
.and_then(|v| v.as_str())
|
|
.and_then(Self::timezone_offset_minutes)
|
|
{
|
|
derived.insert("timezoneOffset".to_string(), json!(minutes));
|
|
}
|
|
if let Some(locale) = fingerprint.get("language").and_then(|v| v.as_str()) {
|
|
derived.insert(
|
|
"languages".to_string(),
|
|
json!([locale, Self::base_language(locale)]),
|
|
);
|
|
}
|
|
derived
|
|
}
|
|
|
|
/// The offset of `timezone` from UTC in minutes, in the sign convention
|
|
/// `Date.prototype.getTimezoneOffset` uses (positive west of UTC), or `None`
|
|
/// when the IANA name does not parse.
|
|
///
|
|
/// It reads the offset AT THE CURRENT INSTANT, so a zone that observes DST
|
|
/// answers differently either side of a transition. That is what a browser
|
|
/// reports too, and it is why `apply_geolocation` and `identity_overrides`
|
|
/// share this one implementation instead of each computing their own.
|
|
fn timezone_offset_minutes(timezone: &str) -> Option<i32> {
|
|
use chrono::Offset;
|
|
let tz = timezone.parse::<chrono_tz::Tz>().ok()?;
|
|
let offset_seconds = chrono::Utc::now()
|
|
.with_timezone(&tz)
|
|
.offset()
|
|
.fix()
|
|
.local_minus_utc();
|
|
Some(-(offset_seconds / 60))
|
|
}
|
|
|
|
/// The bare language subtag of a BCP 47 tag (`de-DE` -> `de`), which is the
|
|
/// second entry of the `languages` ladder donut builds. `Locale::language` is
|
|
/// itself the first `-`-separated part of the tag, so this reproduces it.
|
|
fn base_language(locale: &str) -> &str {
|
|
locale.split('-').next().unwrap_or(locale)
|
|
}
|
|
|
|
/// The baseline to persist after a successful `setIdentity`.
|
|
///
|
|
/// For every key the user did NOT override, adopt whatever the browser just
|
|
/// derived, so a value that changes on a newer browser flows through instead
|
|
/// of reading as a user edit forever. For overridden keys the applied view
|
|
/// holds the override rather than the derived value, so the previous baseline
|
|
/// is kept as the diff reference.
|
|
fn refreshed_identity_baseline(
|
|
applied: &serde_json::Map<String, serde_json::Value>,
|
|
previous_baseline: &serde_json::Map<String, serde_json::Value>,
|
|
overrides: &serde_json::Map<String, serde_json::Value>,
|
|
) -> serde_json::Map<String, serde_json::Value> {
|
|
let mut baseline = applied.clone();
|
|
for key in overrides.keys() {
|
|
match previous_baseline.get(key) {
|
|
Some(previous) => {
|
|
baseline.insert(key.clone(), previous.clone());
|
|
}
|
|
// The user added a field the baseline never carried, so there is
|
|
// nothing to fall back to and the key must stay absent from the
|
|
// baseline or the edit would diff away on the next launch.
|
|
None => {
|
|
baseline.remove(key);
|
|
}
|
|
}
|
|
}
|
|
baseline
|
|
}
|
|
|
|
/// The `setIdentity` geolocation parameters carried by a stored fingerprint.
|
|
fn geo_params(
|
|
fingerprint: &serde_json::Map<String, serde_json::Value>,
|
|
) -> serde_json::Map<String, serde_json::Value> {
|
|
let mut params = serde_json::Map::new();
|
|
for key in GEO_PARAM_KEYS {
|
|
if let Some(value) = fingerprint.get(key) {
|
|
if !value.is_null() {
|
|
params.insert(key.to_string(), value.clone());
|
|
}
|
|
}
|
|
}
|
|
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.
|
|
fn normalize_os_name(name: &str) -> Option<&'static str> {
|
|
match name.trim().to_ascii_lowercase().as_str() {
|
|
"windows" => Some("windows"),
|
|
"macos" => Some("macos"),
|
|
"linux" => Some("linux"),
|
|
"android" => Some("android"),
|
|
"ios" => Some("ios"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The OS a `navigator.platform` value describes.
|
|
///
|
|
/// Mirrors `WayfernHandler::IsCrossOSFromPlatform`, including the order of
|
|
/// the tests: `Linux armv8l` and `aarch64` must read as android before the
|
|
/// plain `Linux` test can claim them.
|
|
fn os_from_platform(platform: &str) -> Option<&'static str> {
|
|
if platform.contains("Win") {
|
|
Some("windows")
|
|
} else if platform.contains("Mac") {
|
|
Some("macos")
|
|
} else if platform.contains("iPhone") || platform.contains("iPad") {
|
|
Some("ios")
|
|
} else if platform.contains("Android")
|
|
|| platform.contains("Linux armv")
|
|
|| platform.contains("aarch64")
|
|
{
|
|
Some("android")
|
|
} else if platform.contains("Linux") {
|
|
Some("linux")
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// The OS this profile claims, or `None` when nothing on it says so.
|
|
///
|
|
/// `WayfernConfig::os` is authoritative because it is what generation was
|
|
/// asked for; the stored fingerprint's `platform` is the fallback for
|
|
/// profiles minted before that field existed. Deliberately conservative — an
|
|
/// unrecognised value on either yields `None` rather than a guess, because
|
|
/// the only caller uses this to REFUSE a launch.
|
|
fn claimed_operating_system(
|
|
config: &WayfernConfig,
|
|
stored: Option<&serde_json::Map<String, serde_json::Value>>,
|
|
) -> Option<&'static str> {
|
|
if let Some(os) = config.os.as_deref().and_then(Self::normalize_os_name) {
|
|
return Some(os);
|
|
}
|
|
stored
|
|
.and_then(|fp| fp.get("platform"))
|
|
.and_then(|v| v.as_str())
|
|
.and_then(Self::os_from_platform)
|
|
}
|
|
|
|
/// Translate a refused apply into a code the frontend can explain.
|
|
///
|
|
/// CDP carries a message, not a machine-readable code, so matching the text
|
|
/// is the only channel the browser has. The literals are the ones
|
|
/// `WayfernHandler` emits from its cross-OS gate and its quota branch; if one
|
|
/// is ever reworded this degrades to the generic code, which still carries
|
|
/// the raw text for support, rather than breaking.
|
|
fn apply_failure_error(detail: &str, claimed_os: Option<&str>) -> String {
|
|
if detail.contains("Cross-OS fingerprinting requires") {
|
|
return crate::backend_error_with_detail(
|
|
"WAYFERN_CROSS_OS_REQUIRES_PLAN",
|
|
claimed_os.unwrap_or("another operating system"),
|
|
);
|
|
}
|
|
// BOTH refusal texts - this maps failures from either release. 151 emits
|
|
// "Fingerprint generation limit reached for this account."; the shipped 150
|
|
// browser emits "Too many profiles are being created." A 150 user would
|
|
// otherwise fall through to the generic apply-failed message and lose the
|
|
// one piece of information that makes the failure actionable.
|
|
if detail.contains("generation limit reached") || detail.contains("Too many profiles") {
|
|
return crate::backend_error("WAYFERN_GENERATION_LIMIT_REACHED");
|
|
}
|
|
crate::backend_error_with_detail("WAYFERN_FINGERPRINT_APPLY_FAILED", detail)
|
|
}
|
|
|
|
async fn wait_for_cdp_ready(
|
|
&self,
|
|
port: u16,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let url = format!("http://127.0.0.1:{port}/json/version");
|
|
// On first launch, macOS Gatekeeper verifies the binary which can take 30+ seconds.
|
|
// Use a generous timeout (60s) to handle this.
|
|
let max_attempts = 120;
|
|
let delay = Duration::from_millis(500);
|
|
|
|
let mut last_error: Option<String> = None;
|
|
for attempt in 0..max_attempts {
|
|
match self.http_client.get(&url).send().await {
|
|
Ok(resp) if resp.status().is_success() => {
|
|
log::info!("CDP ready on port {port} after {attempt} attempts");
|
|
return Ok(());
|
|
}
|
|
Ok(resp) => {
|
|
last_error = Some(format!("HTTP {} from {url}", resp.status()));
|
|
tokio::time::sleep(delay).await;
|
|
}
|
|
Err(e) => {
|
|
last_error = Some(format!("request failed: {e}"));
|
|
tokio::time::sleep(delay).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
let detail = last_error.unwrap_or_else(|| "no attempts completed".to_string());
|
|
// Log at error level so we can diagnose Windows/AV/firewall-induced CDP hangs
|
|
// in customer reports without needing them to reproduce in the moment.
|
|
log::error!("CDP not ready after {max_attempts} attempts on port {port}: {detail}");
|
|
Err(format!("CDP not ready after {max_attempts} attempts on port {port}: {detail}").into())
|
|
}
|
|
|
|
async fn get_cdp_targets(
|
|
&self,
|
|
port: u16,
|
|
) -> Result<Vec<CdpTarget>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let url = format!("http://127.0.0.1:{port}/json");
|
|
let resp = self.http_client.get(&url).send().await?;
|
|
let targets: Vec<CdpTarget> = resp.json().await?;
|
|
Ok(targets)
|
|
}
|
|
|
|
async fn send_cdp_command(
|
|
&self,
|
|
ws_url: &str,
|
|
method: &str,
|
|
params: serde_json::Value,
|
|
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
|
let (mut ws_stream, _) = connect_async(ws_url).await?;
|
|
|
|
let command = json!({
|
|
"id": 1,
|
|
"method": method,
|
|
"params": params
|
|
});
|
|
|
|
use futures_util::sink::SinkExt;
|
|
use futures_util::stream::StreamExt;
|
|
|
|
ws_stream
|
|
.send(Message::Text(command.to_string().into()))
|
|
.await?;
|
|
|
|
while let Some(msg) = ws_stream.next().await {
|
|
match msg? {
|
|
Message::Text(text) => {
|
|
let response: serde_json::Value = serde_json::from_str(text.as_str())?;
|
|
if response.get("id") == Some(&json!(1)) {
|
|
if let Some(error) = response.get("error") {
|
|
return Err(format!("CDP error: {}", error).into());
|
|
}
|
|
return Ok(response.get("result").cloned().unwrap_or(json!({})));
|
|
}
|
|
}
|
|
Message::Close(_) => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
Err("No response received from CDP".into())
|
|
}
|
|
|
|
/// Stable signature describing what determines this profile's geolocation
|
|
/// (timezone, latitude/longitude, language): the geoip mode first, then the
|
|
/// VPN, the proxy, or a direct connection. Compared across creation and
|
|
/// launch to detect a change. The VPN case keys off `vpn_id` rather than the
|
|
/// per-launch local port, and the proxy case off type/host/port/username so
|
|
/// that editing the proxy is also caught.
|
|
pub fn geo_signature(
|
|
proxy: Option<&crate::browser::ProxySettings>,
|
|
vpn_id: Option<&str>,
|
|
geoip: Option<&serde_json::Value>,
|
|
) -> String {
|
|
// The "v2:" prefix invalidates every signature stamped before geolocation
|
|
// failures stopped being stamped: those may describe fingerprints that
|
|
// silently carry the host's location, so each pre-v2 profile gets one
|
|
// launch-time refresh and is re-stamped in the current format.
|
|
let base = match geoip {
|
|
Some(serde_json::Value::Bool(false)) => "off".to_string(),
|
|
Some(serde_json::Value::String(ip)) if !ip.is_empty() => format!("ip:{ip}"),
|
|
_ => {
|
|
if let Some(id) = vpn_id {
|
|
format!("vpn:{id}")
|
|
} else if let Some(p) = proxy {
|
|
format!(
|
|
"proxy:{}://{}@{}:{}",
|
|
p.proxy_type.to_lowercase(),
|
|
p.username.as_deref().unwrap_or(""),
|
|
p.host,
|
|
p.port
|
|
)
|
|
} else {
|
|
"direct".to_string()
|
|
}
|
|
}
|
|
};
|
|
format!("v2:{base}")
|
|
}
|
|
|
|
/// Apply timezone/geolocation fields to a fingerprint object from the proxy's
|
|
/// exit IP (or a fixed geoip IP). Mutates `fingerprint` in place. Returns true
|
|
/// if fresh geolocation was fetched and applied, false if geolocation is
|
|
/// disabled or could not be resolved (in which case only safe defaults are
|
|
/// filled in). Shared by fingerprint generation and the launch-time refresh
|
|
/// so both produce identical location data.
|
|
async fn apply_geolocation(
|
|
fingerprint: &mut serde_json::Value,
|
|
proxy: Option<&str>,
|
|
geoip: Option<&serde_json::Value>,
|
|
) -> bool {
|
|
// Default to auto-detect; only an explicit `false` disables geolocation.
|
|
let should_geolocate = !matches!(geoip, Some(serde_json::Value::Bool(false)));
|
|
if !should_geolocate {
|
|
return false;
|
|
}
|
|
|
|
let geo_result = async {
|
|
let ip = match geoip {
|
|
Some(serde_json::Value::String(ip_str)) => ip_str.clone(),
|
|
_ => crate::ip_utils::fetch_public_ip(proxy)
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch public IP: {e}"))?,
|
|
};
|
|
crate::geolocation::get_geolocation(&ip)
|
|
.map_err(|e| format!("Failed to get geolocation for IP {ip}: {e}"))
|
|
}
|
|
.await;
|
|
|
|
match geo_result {
|
|
Ok(geo) => {
|
|
if let Some(obj) = fingerprint.as_object_mut() {
|
|
obj.insert("timezone".to_string(), json!(geo.timezone));
|
|
// Both derived fields go through the same helpers `identity_overrides`
|
|
// uses to recognise them, so a value written here can never look like
|
|
// a user edit to the override diff.
|
|
if let Some(offset_minutes) = Self::timezone_offset_minutes(&geo.timezone) {
|
|
obj.insert("timezoneOffset".to_string(), json!(offset_minutes));
|
|
}
|
|
obj.insert("latitude".to_string(), json!(geo.latitude));
|
|
obj.insert("longitude".to_string(), json!(geo.longitude));
|
|
let locale_str = geo.locale.as_string();
|
|
obj.insert("language".to_string(), json!(&locale_str));
|
|
obj.insert(
|
|
"languages".to_string(),
|
|
json!([&locale_str, Self::base_language(&locale_str)]),
|
|
);
|
|
}
|
|
log::info!(
|
|
"Applied geolocation to Wayfern fingerprint: {} ({})",
|
|
geo.locale.as_string(),
|
|
geo.timezone
|
|
);
|
|
true
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Geolocation failed, using defaults: {e}");
|
|
if let Some(obj) = fingerprint.as_object_mut() {
|
|
if !obj.contains_key("timezone") {
|
|
obj.insert("timezone".to_string(), json!("America/New_York"));
|
|
}
|
|
if !obj.contains_key("timezoneOffset") {
|
|
obj.insert("timezoneOffset".to_string(), json!(300));
|
|
}
|
|
}
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Refresh ONLY the location fields (timezone, offset, latitude/longitude,
|
|
/// language) of an already-generated fingerprint to match the current proxy,
|
|
/// leaving every other fingerprint field untouched. `proxy` is the local
|
|
/// proxy URL the browser will use. Returns the updated fingerprint JSON on
|
|
/// success, or None if geolocation is disabled or could not be resolved, in
|
|
/// which case the caller keeps the existing fingerprint and retries on the
|
|
/// next launch.
|
|
pub async fn refresh_fingerprint_geolocation(
|
|
fingerprint_json: &str,
|
|
proxy: Option<&str>,
|
|
geoip: Option<&serde_json::Value>,
|
|
) -> Option<String> {
|
|
let mut fp: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?;
|
|
if Self::apply_geolocation(&mut fp, proxy, geoip).await {
|
|
serde_json::to_string(&fp).ok()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// True when `url` is a socks proxy on a remote (non-loopback) host — the
|
|
/// case where reqwest's SOCKS connector can't be trusted with the
|
|
/// geolocation fetch. Loopback socks URLs are the app's own donut-proxy
|
|
/// workers, whose single-segment replies don't trigger the connector bug.
|
|
fn is_remote_socks_url(url: &str) -> bool {
|
|
url.starts_with("socks")
|
|
&& url::Url::parse(url)
|
|
.ok()
|
|
.and_then(|u| match u.host() {
|
|
Some(url::Host::Ipv4(ip)) => Some(!ip.is_loopback()),
|
|
Some(url::Host::Ipv6(ip)) => Some(!ip.is_loopback()),
|
|
// socks is a non-special scheme, so the url crate keeps even
|
|
// IP-literal hosts as Domain — parse them before comparing.
|
|
Some(url::Host::Domain(domain)) => Some(
|
|
domain != "localhost"
|
|
&& domain
|
|
.parse::<std::net::IpAddr>()
|
|
.map(|ip| !ip.is_loopback())
|
|
.unwrap_or(true),
|
|
),
|
|
None => None,
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Generate a device for `config` on a headless Wayfern.
|
|
///
|
|
/// On a browser that ships the identity API this mints an identity and
|
|
/// returns its handle alongside the fingerprint; on older browsers it returns
|
|
/// the fingerprint alone. Which path runs is decided by `profile.version`,
|
|
/// the same field the executable path is resolved from, so the generated
|
|
/// device always matches the binary that ran.
|
|
///
|
|
/// Callers must only stamp `geo_proxy_signature` when
|
|
/// `geolocation_applied` is true: the device comes from a headless Wayfern
|
|
/// launched without a proxy, so on failure it silently carries the HOST
|
|
/// timezone/locale — stamping the signature then would tell the launch-time
|
|
/// refresh the location is already correct for this proxy and permanently
|
|
/// disable the one path that can repair it.
|
|
pub async fn generate_fingerprint_config(
|
|
&self,
|
|
_app_handle: &AppHandle,
|
|
profile: &BrowserProfile,
|
|
config: &WayfernConfig,
|
|
) -> Result<GeneratedFingerprint, Box<dyn std::error::Error + Send + Sync>> {
|
|
let executable_path = BrowserRunner::instance()
|
|
.get_browser_executable_path(profile)
|
|
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?;
|
|
|
|
let port = Self::find_free_port().await?;
|
|
log::info!("Launching headless Wayfern on port {port} for fingerprint generation");
|
|
|
|
let temp_profile_dir =
|
|
std::env::temp_dir().join(format!("wayfern_fingerprint_{}", uuid::Uuid::new_v4()));
|
|
std::fs::create_dir_all(&temp_profile_dir)?;
|
|
|
|
let mut cmd = TokioCommand::new(&executable_path);
|
|
cmd
|
|
.arg("--headless=new")
|
|
.arg(format!("--remote-debugging-port={port}"))
|
|
.arg("--remote-debugging-address=127.0.0.1")
|
|
.arg(format!("--user-data-dir={}", temp_profile_dir.display()))
|
|
.arg("--no-first-run")
|
|
.arg("--no-default-browser-check")
|
|
.arg("--disable-background-mode")
|
|
.arg("--use-mock-keychain")
|
|
.arg("--password-store=basic")
|
|
.arg("--disable-features=DialMediaRouteProvider");
|
|
|
|
#[cfg(target_os = "linux")]
|
|
cmd
|
|
.arg("--no-sandbox")
|
|
.arg("--disable-setuid-sandbox")
|
|
.arg("--disable-dev-shm-usage");
|
|
|
|
cmd.stdout(Stdio::null()).stderr(Stdio::piped());
|
|
|
|
let child = cmd.spawn().map_err(|e| {
|
|
// OS error 14001 = SxS / missing Visual C++ Redistributable
|
|
let hint = if e.raw_os_error() == Some(14001) {
|
|
". This usually means the Visual C++ Redistributable is not installed. \
|
|
Download it from https://aka.ms/vs/17/release/vc_redist.x64.exe"
|
|
} else {
|
|
""
|
|
};
|
|
format!("Failed to spawn headless Wayfern: {e}{hint}")
|
|
})?;
|
|
let child_id = child.id();
|
|
|
|
let cleanup = || async {
|
|
if let Some(id) = child_id {
|
|
#[cfg(unix)]
|
|
{
|
|
use nix::sys::signal::{kill, Signal};
|
|
use nix::unistd::Pid;
|
|
let _ = kill(Pid::from_raw(id as i32), Signal::SIGTERM);
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt;
|
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
let _ = std::process::Command::new("taskkill")
|
|
.args(["/PID", &id.to_string(), "/F"])
|
|
.creation_flags(CREATE_NO_WINDOW)
|
|
.output();
|
|
}
|
|
}
|
|
let _ = std::fs::remove_dir_all(&temp_profile_dir);
|
|
};
|
|
|
|
if let Err(e) = self.wait_for_cdp_ready(port).await {
|
|
// Try to capture stderr from the failed process for diagnostics
|
|
let stderr_output = if let Some(id) = child_id {
|
|
// Check if process is still running
|
|
let is_running = sysinfo::System::new_with_specifics(
|
|
sysinfo::RefreshKind::nothing().with_processes(sysinfo::ProcessRefreshKind::nothing()),
|
|
)
|
|
.process(sysinfo::Pid::from(id as usize))
|
|
.is_some();
|
|
|
|
if !is_running {
|
|
// Process exited — try to read its stderr
|
|
String::from("(process exited before CDP became ready)")
|
|
} else {
|
|
String::from("(process still running but not responding on CDP)")
|
|
}
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
log::error!(
|
|
"Fingerprint-generation Wayfern (headless, pid={child_id:?}) never became CDP-ready: {e}. {stderr_output}"
|
|
);
|
|
cleanup().await;
|
|
return Err(e);
|
|
}
|
|
|
|
let targets = match self.get_cdp_targets(port).await {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
cleanup().await;
|
|
return Err(e);
|
|
}
|
|
};
|
|
|
|
let page_target = targets
|
|
.iter()
|
|
.find(|t| t.target_type == "page" && t.websocket_debugger_url.is_some());
|
|
|
|
let ws_url = match page_target {
|
|
Some(target) => target.websocket_debugger_url.as_ref().unwrap().clone(),
|
|
None => {
|
|
cleanup().await;
|
|
return Err("No page target found for CDP".into());
|
|
}
|
|
};
|
|
|
|
let host_os = crate::profile::types::get_host_os();
|
|
let os = config.os.as_deref().unwrap_or(&host_os);
|
|
|
|
// Include wayfern token if available (enables cross-OS fingerprinting for paid users)
|
|
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
|
let mut generate_params = json!({ "operatingSystem": os });
|
|
if let Some(ref token) = wayfern_token {
|
|
generate_params
|
|
.as_object_mut()
|
|
.unwrap()
|
|
.insert("wayfernToken".to_string(), json!(token));
|
|
}
|
|
|
|
let use_identity_api = supports_identity_api(&profile.version);
|
|
|
|
// No geolocation override is passed here. Donut resolves the exit's
|
|
// location itself, below, through the profile's own proxy — the browser's
|
|
// C++ geo service cannot, because an authenticated upstream answers its
|
|
// SimpleURLLoader requests with HTTP 407.
|
|
let generate_result = if use_identity_api {
|
|
self
|
|
.send_cdp_command(&ws_url, "Wayfern.createIdentity", generate_params)
|
|
.await
|
|
} else {
|
|
match self
|
|
.send_cdp_command(&ws_url, "Wayfern.refreshFingerprint", generate_params)
|
|
.await
|
|
{
|
|
// The legacy pair is two commands: refresh mints the device, get reads
|
|
// it back. Only the identity API returns the device from one call.
|
|
Ok(_) => {
|
|
self
|
|
.send_cdp_command(&ws_url, "Wayfern.getFingerprint", json!({}))
|
|
.await
|
|
}
|
|
Err(e) => Err(e),
|
|
}
|
|
};
|
|
|
|
let (fingerprint, identity_id, identity_baseline, geolocation_applied) = match generate_result {
|
|
Ok(result) => {
|
|
// createIdentity returns { identityId, identity }; getFingerprint
|
|
// returns { fingerprint: {...} }. A bare object is tolerated so a
|
|
// response-shape change does not lose the device outright; an identity
|
|
// response missing identityId is rejected below instead, because a view
|
|
// with no UUID behind it is not reproducible.
|
|
let identity_id = result
|
|
.get("identityId")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string);
|
|
let fp = result
|
|
.get("identity")
|
|
.or_else(|| result.get("fingerprint"))
|
|
.cloned()
|
|
.unwrap_or(result);
|
|
// Normalize the fingerprint: convert JSON string fields to proper types
|
|
let mut normalized = Self::normalize_fingerprint(fp);
|
|
|
|
// Snapshot the derived view BEFORE geolocation is applied, so the
|
|
// location fields donut writes below are not mistaken for user edits
|
|
// when the overrides are recovered on launch.
|
|
let identity_baseline = if use_identity_api {
|
|
serde_json::to_string(&normalized).ok()
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// reqwest's SOCKS connector (hyper-util) corrupts its parse buffer
|
|
// when a proxy splits a handshake reply across TCP segments, so a
|
|
// socks upstream here can fail even though the proxy is healthy.
|
|
// Route the geolocation lookup through a temporary local donut-proxy
|
|
// worker — the same path the browser itself uses — and fall back to
|
|
// the upstream URL only if the worker can't start. Two exclusions:
|
|
// no worker when geolocation won't fetch through the proxy at all
|
|
// (disabled, or a fixed geoip IP), and none for loopback socks URLs —
|
|
// launch-time callers pass the already-running local worker's
|
|
// socks5://127.0.0.1 URL, whose single-segment replies don't trigger
|
|
// the bug, so chaining a second worker would only add latency.
|
|
let needs_proxied_geo_fetch = !matches!(
|
|
config.geoip.as_ref(),
|
|
Some(serde_json::Value::Bool(false)) | Some(serde_json::Value::String(_))
|
|
);
|
|
let remote_socks_upstream = config
|
|
.proxy
|
|
.as_deref()
|
|
.filter(|url| Self::is_remote_socks_url(url));
|
|
let (geo_proxy, temp_worker_id) = match remote_socks_upstream {
|
|
Some(url) if needs_proxied_geo_fetch => {
|
|
match crate::proxy_runner::start_proxy_process(Some(url.to_string()), None)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
{
|
|
Ok(worker) => {
|
|
let local_url = format!("http://127.0.0.1:{}", worker.local_port.unwrap_or(0));
|
|
(Some(local_url), Some(worker.id))
|
|
}
|
|
Err(e) => {
|
|
log::warn!(
|
|
"Could not start local proxy worker for geolocation ({e}); using the socks upstream directly"
|
|
);
|
|
(config.proxy.clone(), None)
|
|
}
|
|
}
|
|
}
|
|
_ => (config.proxy.clone(), None),
|
|
};
|
|
|
|
// Apply timezone/geolocation for the proxy this fingerprint is being
|
|
// generated against. Shared with the launch-time location refresh.
|
|
let geolocation_applied =
|
|
Self::apply_geolocation(&mut normalized, geo_proxy.as_deref(), config.geoip.as_ref())
|
|
.await;
|
|
|
|
if let Some(worker_id) = temp_worker_id {
|
|
let _ = crate::proxy_runner::stop_proxy_process(&worker_id).await;
|
|
}
|
|
|
|
(
|
|
normalized,
|
|
identity_id,
|
|
identity_baseline,
|
|
geolocation_applied,
|
|
)
|
|
}
|
|
Err(e) => {
|
|
cleanup().await;
|
|
let what = if use_identity_api {
|
|
"create identity"
|
|
} else {
|
|
"get fingerprint"
|
|
};
|
|
return Err(format!("Failed to {what}: {e}").into());
|
|
}
|
|
};
|
|
|
|
cleanup().await;
|
|
|
|
let fingerprint_json = serde_json::to_string(&fingerprint)
|
|
.map_err(|e| format!("Failed to serialize fingerprint: {e}"))?;
|
|
|
|
// Report the platform the engine actually produced alongside the one that
|
|
// was asked for. Logging only the request made this line useless for
|
|
// diagnosing a fingerprint that came back as something else.
|
|
log::info!(
|
|
"Generated Wayfern fingerprint for requested OS: {}, produced platform: {:?}, fields: {:?}",
|
|
os,
|
|
fingerprint.get("platform").and_then(|p| p.as_str()),
|
|
fingerprint
|
|
.as_object()
|
|
.map(|o| o.keys().collect::<Vec<_>>())
|
|
);
|
|
|
|
// Log timezone/geolocation fields specifically for debugging
|
|
if let Some(obj) = fingerprint.as_object() {
|
|
log::info!(
|
|
"Generated fingerprint - timezone: {:?}, timezoneOffset: {:?}, latitude: {:?}, longitude: {:?}, language: {:?}",
|
|
obj.get("timezone"),
|
|
obj.get("timezoneOffset"),
|
|
obj.get("latitude"),
|
|
obj.get("longitude"),
|
|
obj.get("language")
|
|
);
|
|
}
|
|
|
|
if use_identity_api && identity_id.is_none() {
|
|
// Without the handle the stored fingerprint is not reproducible, which
|
|
// would leave a profile that silently changes device on every launch.
|
|
// The headless browser is already torn down by the `cleanup()` above.
|
|
return Err("Wayfern.createIdentity returned no identityId".into());
|
|
}
|
|
|
|
Ok(GeneratedFingerprint {
|
|
fingerprint: fingerprint_json,
|
|
identity_id,
|
|
identity_baseline,
|
|
geolocation_applied,
|
|
})
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn launch_wayfern(
|
|
&self,
|
|
_app_handle: &AppHandle,
|
|
profile: &BrowserProfile,
|
|
profile_path: &str,
|
|
config: &WayfernConfig,
|
|
url: Option<&str>,
|
|
proxy_url: Option<&str>,
|
|
ephemeral: bool,
|
|
extension_paths: &[String],
|
|
remote_debugging_port: Option<u16>,
|
|
headless: bool,
|
|
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
let executable_path = BrowserRunner::instance()
|
|
.get_browser_executable_path(profile)
|
|
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?;
|
|
|
|
let port = match remote_debugging_port {
|
|
Some(p) => p,
|
|
None => Self::find_free_port().await?,
|
|
};
|
|
log::info!("Launching Wayfern on CDP port {port} (detached)");
|
|
|
|
// Diagnostic: verify critical profile files and test cookie decryption
|
|
{
|
|
let profile_path_buf = std::path::PathBuf::from(profile_path);
|
|
let key_path = profile_path_buf.join("os_crypt_key");
|
|
let cookies_path = {
|
|
let network = profile_path_buf
|
|
.join("Default")
|
|
.join("Network")
|
|
.join("Cookies");
|
|
if network.exists() {
|
|
network
|
|
} else {
|
|
profile_path_buf.join("Default").join("Cookies")
|
|
}
|
|
};
|
|
|
|
if key_path.exists() {
|
|
// Length only. The contents are the profile's encryption key, and this
|
|
// log is the first thing a user attaches to a bug report.
|
|
let key_len = std::fs::metadata(&key_path).map(|m| m.len()).unwrap_or(0);
|
|
log::info!("Pre-launch: os_crypt_key present ({key_len} bytes)");
|
|
} else {
|
|
log::warn!("Pre-launch: os_crypt_key NOT FOUND");
|
|
}
|
|
|
|
if cookies_path.exists() {
|
|
// Try to open Cookies DB and check if encrypted cookies can be decrypted
|
|
if let Ok(conn) = rusqlite::Connection::open_with_flags(
|
|
&cookies_path,
|
|
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
|
) {
|
|
let cookie_count: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM cookies WHERE length(encrypted_value) > 0",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap_or(0);
|
|
let total_count: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM cookies", [], |r| r.get(0))
|
|
.unwrap_or(0);
|
|
log::info!(
|
|
"Pre-launch: Cookies DB has {} total cookies, {} encrypted",
|
|
total_count,
|
|
cookie_count
|
|
);
|
|
|
|
// Try decrypting one cookie using the cookie_manager
|
|
if let Some(encryption_key) =
|
|
crate::cookie_manager::chrome_decrypt::get_encryption_key(&profile_path_buf)
|
|
{
|
|
if let Ok(mut stmt) = conn.prepare(
|
|
"SELECT name, host_key, encrypted_value FROM cookies WHERE length(encrypted_value) > 0 LIMIT 1",
|
|
) {
|
|
if let Ok(mut rows) = stmt.query([]) {
|
|
if let Ok(Some(row)) = rows.next() {
|
|
let name: String = row.get(0).unwrap_or_default();
|
|
let host: String = row.get(1).unwrap_or_default();
|
|
let encrypted: Vec<u8> = row.get(2).unwrap_or_default();
|
|
let decrypted = crate::cookie_manager::chrome_decrypt::decrypt(
|
|
&encrypted,
|
|
&host,
|
|
&encryption_key,
|
|
);
|
|
match decrypted {
|
|
Some(val) => log::info!(
|
|
"Pre-launch: Cookie decryption SUCCEEDED for '{}' (host: {}, decrypted {} bytes)",
|
|
name, host, val.len()
|
|
),
|
|
None => log::error!(
|
|
"Pre-launch: Cookie decryption FAILED for '{}' (host: {}, encrypted {} bytes)",
|
|
name, host, encrypted.len()
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
log::error!("Pre-launch: Failed to derive encryption key from os_crypt_key");
|
|
}
|
|
}
|
|
} else {
|
|
log::warn!("Pre-launch: Cookies NOT FOUND");
|
|
}
|
|
}
|
|
|
|
let mut args = vec![
|
|
format!("--remote-debugging-port={port}"),
|
|
"--remote-debugging-address=127.0.0.1".to_string(),
|
|
format!("--user-data-dir={profile_path}"),
|
|
"--no-first-run".to_string(),
|
|
"--no-default-browser-check".to_string(),
|
|
"--disable-background-mode".to_string(),
|
|
"--disable-component-update".to_string(),
|
|
"--disable-background-timer-throttling".to_string(),
|
|
"--crash-server-url=".to_string(),
|
|
"--disable-updater".to_string(),
|
|
"--disable-session-crashed-bubble".to_string(),
|
|
"--hide-crash-restore-bubble".to_string(),
|
|
"--disable-infobars".to_string(),
|
|
// Prefetch* / NoStatePrefetch: cross-site Speculation-Rules prefetch uses
|
|
// an isolated NetworkContext that defaults to DIRECT egress (real host IP
|
|
// leaks past the per-profile proxy). Disabling via a LAUNCH FLAG cannot be
|
|
// re-enabled by an imported/synced network_prediction_options pref (which a
|
|
// compile-time pref default could be).
|
|
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns,Prefetch,PrefetchProxy,SpeculationRulesPrefetchFuture,NoStatePrefetch".to_string(),
|
|
"--use-mock-keychain".to_string(),
|
|
"--password-store=basic".to_string(),
|
|
];
|
|
|
|
if headless {
|
|
args.push("--headless=new".to_string());
|
|
} else if let Some((w, h)) = config
|
|
.fingerprint
|
|
.as_deref()
|
|
.and_then(Self::window_size_from_fingerprint)
|
|
{
|
|
// Size the real OS window to match the fingerprint so the visible window
|
|
// agrees with the reported windowOuterWidth/screen dimensions. Anchor at
|
|
// 0,0 so the window also fits within the spoofed screen origin. Skipped in
|
|
// headless mode, where there is no on-screen window.
|
|
log::info!("Sizing Wayfern window to fingerprint dimensions: {w}x{h}");
|
|
args.push(format!("--window-size={w},{h}"));
|
|
args.push("--window-position=0,0".to_string());
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
args.push("--no-sandbox".to_string());
|
|
args.push("--disable-setuid-sandbox".to_string());
|
|
args.push("--disable-dev-shm-usage".to_string());
|
|
}
|
|
|
|
if ephemeral {
|
|
args.push("--disk-cache-size=1".to_string());
|
|
args.push("--disable-breakpad".to_string());
|
|
args.push("--disable-crash-reporter".to_string());
|
|
args.push("--no-service-autorun".to_string());
|
|
args.push("--disable-sync".to_string());
|
|
}
|
|
|
|
if !extension_paths.is_empty() {
|
|
args.push(format!("--load-extension={}", extension_paths.join(",")));
|
|
}
|
|
|
|
// Per-profile window label + distinct frame color so concurrent profile
|
|
// windows are easy to tell apart. Wayfern reads these in
|
|
// BrowserView::GetWindowTitle() (label) and BrowserFrameView::GetFrameColor()
|
|
// (color). The label is the profile name; the color is the user's
|
|
// window_color when set, otherwise deterministically derived from the
|
|
// profile id so every profile still gets a stable, distinct color.
|
|
if !profile.name.is_empty() {
|
|
args.push(format!("--wayfern-profile-label={}", profile.name));
|
|
}
|
|
// Profiles created before this feature have no stored color; persist the
|
|
// id-derived one so the info dialog shows the same frame color the window
|
|
// uses. It's deterministic per id, so no updated_at bump/sync is needed.
|
|
if profile
|
|
.window_color
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.unwrap_or("")
|
|
.is_empty()
|
|
{
|
|
let mut backfilled = profile.clone();
|
|
backfilled.window_color = Some(derive_profile_color(&backfilled.id));
|
|
let _ = crate::profile::ProfileManager::instance().save_profile(&backfilled);
|
|
}
|
|
let profile_color = profile
|
|
.window_color
|
|
.clone()
|
|
.filter(|c| !c.trim().is_empty())
|
|
.unwrap_or_else(|| derive_profile_color(&profile.id));
|
|
// Wayfern expects the frame color as bare RRGGBB hex, with no leading '#'
|
|
// (the stored/user value may include one).
|
|
let profile_color = profile_color.trim().trim_start_matches('#');
|
|
args.push(format!("--wayfern-profile-color={profile_color}"));
|
|
|
|
let mut wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
|
// Waiting is only meaningful for a plan a token can actually be minted for.
|
|
// On "any active plan" this stalled every Solo launch by the full three
|
|
// seconds waiting for a token the backend will never issue to them.
|
|
if wayfern_token.is_none()
|
|
&& crate::cloud_auth::CLOUD_AUTH
|
|
.is_entitled_to_wayfern_token()
|
|
.await
|
|
{
|
|
// Brief wait for the background token fetch — when the API is healthy
|
|
// the token usually lands in well under a second. If api.donutbrowser.com
|
|
// is unreachable we don't want to gate the whole launch on it; the
|
|
// browser still works without the token (cross-OS fingerprinting just
|
|
// won't be enabled for this session, and the next launch will pick it
|
|
// up once the token arrives).
|
|
log::info!("Wayfern token not ready for paid user, waiting briefly...");
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
|
if wayfern_token.is_some() {
|
|
break;
|
|
}
|
|
}
|
|
if wayfern_token.is_none() {
|
|
log::warn!(
|
|
"Wayfern token still unavailable after wait; launching without it (api.donutbrowser.com may be unreachable)"
|
|
);
|
|
}
|
|
}
|
|
|
|
// A cross-OS claim is authorized from the `wayfernToken` PARAMETER of
|
|
// setIdentity/setFingerprint. The browser's gate does not consult the
|
|
// WAYFERN_TOKEN env var this launch also sets, so with no token in hand the
|
|
// apply is refused and the window would sit there running the HOST device
|
|
// under a macOS or Android profile. Refuse before spawning rather than
|
|
// opening a window we are about to kill.
|
|
//
|
|
// "Cross-OS" is the browser's own test (`WayfernHandler::IsCrossOS` against
|
|
// `GetHostOperatingSystem`), so `android` and `ios` count on every desktop.
|
|
//
|
|
// Not a 151 regression: setFingerprint on 150 has the identical
|
|
// parameter-only gate. What changed is that the refusal is no longer
|
|
// swallowed as a log line (see the apply loop below).
|
|
//
|
|
// Deliberately conservative — this only pre-empts when the claim is
|
|
// certain. An unrecognised `os`, a platform that maps to nothing, and a
|
|
// profile with no stored device all fall through and let the browser
|
|
// decide, so a mistake here can only ever cost the clearer error message.
|
|
if wayfern_token.is_none() {
|
|
let stored_device = config
|
|
.fingerprint
|
|
.as_deref()
|
|
.and_then(Self::fingerprint_object);
|
|
if let Some(claimed) = Self::claimed_operating_system(config, stored_device.as_ref()) {
|
|
let host_os = crate::profile::types::get_host_os();
|
|
if claimed != host_os.as_str() {
|
|
log::error!(
|
|
"Refusing to launch profile {}: it claims {claimed} on a {host_os} host and no Wayfern token is available",
|
|
profile.name
|
|
);
|
|
return Err(
|
|
crate::backend_error_with_detail("WAYFERN_CROSS_OS_REQUIRES_PLAN", claimed).into(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(proxy) = proxy_url {
|
|
// Map the local proxy scheme to the matching PAC directive. SOCKS5 lets
|
|
// Chromium route UDP (QUIC/WebRTC) and resolve DNS through the proxy;
|
|
// PROXY is HTTP CONNECT (TCP only). The host:port is the same either way.
|
|
let (pac_directive, host_port) = if let Some(rest) = proxy.strip_prefix("socks5://") {
|
|
("SOCKS5", rest)
|
|
} else {
|
|
(
|
|
"PROXY",
|
|
proxy
|
|
.trim_start_matches("http://")
|
|
.trim_start_matches("https://"),
|
|
)
|
|
};
|
|
let pac_data = format!(
|
|
"data:application/x-ns-proxy-autoconfig,function FindProxyForURL(url,host){{return \"{pac_directive} {host_port}\";}}",
|
|
);
|
|
args.push(format!("--proxy-pac-url={pac_data}"));
|
|
args.push("--dns-prefetch-disable".to_string());
|
|
}
|
|
|
|
let mut command = TokioCommand::new(&executable_path);
|
|
command
|
|
.args(&args)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
if let Some(ref token) = wayfern_token {
|
|
command.env("WAYFERN_TOKEN", token);
|
|
log::info!("Wayfern authorization configured for browser process");
|
|
}
|
|
|
|
let child = command
|
|
.spawn()
|
|
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
|
let hint = if e.raw_os_error() == Some(14001) {
|
|
". This usually means the Visual C++ Redistributable is not installed. \
|
|
Download it from https://aka.ms/vs/17/release/vc_redist.x64.exe"
|
|
} else {
|
|
""
|
|
};
|
|
format!("Failed to spawn Wayfern: {e}{hint}").into()
|
|
})?;
|
|
let process_id = child.id();
|
|
drop(child);
|
|
|
|
self.wait_for_cdp_ready(port).await?;
|
|
|
|
let targets = self.get_cdp_targets(port).await?;
|
|
log::info!("Found {} CDP targets", targets.len());
|
|
|
|
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;
|
|
let mut used_identity_baseline: Option<String> = None;
|
|
if let Some(fingerprint_json) = &config.fingerprint {
|
|
log::info!(
|
|
"Applying fingerprint to Wayfern browser, fingerprint length: {} chars",
|
|
fingerprint_json.len()
|
|
);
|
|
|
|
let stored_value: serde_json::Value = serde_json::from_str(fingerprint_json)
|
|
.map_err(|e| format!("Failed to parse stored fingerprint JSON: {e}"))?;
|
|
|
|
// The stored fingerprint should be the fingerprint object directly (after our fix in generate_fingerprint_config)
|
|
// But for backwards compatibility, also handle the wrapped format
|
|
let mut fingerprint = if stored_value.get("fingerprint").is_some() {
|
|
// Old format: {"fingerprint": {...}} - extract the inner fingerprint
|
|
stored_value.get("fingerprint").cloned().unwrap()
|
|
} else {
|
|
// New format: fingerprint object directly {...}
|
|
stored_value.clone()
|
|
};
|
|
|
|
// Add default timezone if not present (for profiles created before timezone was added)
|
|
if let Some(obj) = fingerprint.as_object_mut() {
|
|
if !obj.contains_key("timezone") {
|
|
obj.insert("timezone".to_string(), json!("America/New_York"));
|
|
log::info!("Added default timezone to fingerprint");
|
|
}
|
|
if !obj.contains_key("timezoneOffset") {
|
|
obj.insert("timezoneOffset".to_string(), json!(300));
|
|
log::info!("Added default timezoneOffset to fingerprint");
|
|
}
|
|
}
|
|
|
|
// Denormalize fingerprint for Wayfern CDP (convert arrays/objects to JSON strings)
|
|
let mut fingerprint_for_cdp = Self::denormalize_fingerprint(fingerprint);
|
|
|
|
// Normalize languages: if it's a comma-separated string, convert to array
|
|
if let Some(obj) = fingerprint_for_cdp.as_object_mut() {
|
|
if let Some(serde_json::Value::String(s)) = obj.get("languages").cloned() {
|
|
let arr: Vec<&str> = s.split(',').map(|l| l.trim()).collect();
|
|
obj.insert("languages".to_string(), json!(arr));
|
|
}
|
|
}
|
|
|
|
log::info!(
|
|
"Fingerprint prepared for CDP command, fields: {:?}",
|
|
fingerprint_for_cdp
|
|
.as_object()
|
|
.map(|o| o.keys().collect::<Vec<_>>())
|
|
);
|
|
|
|
// Log timezone and geolocation fields specifically for debugging
|
|
if let Some(obj) = fingerprint_for_cdp.as_object() {
|
|
log::info!(
|
|
"Timezone/Geolocation fields - timezone: {:?}, timezoneOffset: {:?}, latitude: {:?}, longitude: {:?}, language: {:?}, languages: {:?}",
|
|
obj.get("timezone"),
|
|
obj.get("timezoneOffset"),
|
|
obj.get("latitude"),
|
|
obj.get("longitude"),
|
|
obj.get("language"),
|
|
obj.get("languages")
|
|
);
|
|
}
|
|
|
|
// 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.
|
|
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(),
|
|
)
|
|
}
|
|
};
|
|
|
|
// 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
|
|
// still shows the profile's stored one. Track it so the launch can fail
|
|
// instead of reporting success.
|
|
let mut applied_ok = false;
|
|
let mut last_apply_error: Option<String> = None;
|
|
|
|
for target in &page_targets {
|
|
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())
|
|
.await
|
|
{
|
|
Ok(result) => {
|
|
// The device is on the target. Whether the ECHO parses is a
|
|
// separate question — it only decides what we persist.
|
|
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() {
|
|
if apply_by_identity {
|
|
// The baseline is "what the browser derived", so it is
|
|
// computed from the untouched response. Move it forward for
|
|
// everything the user did not override: leaving it behind
|
|
// would make the next launch read a re-derived value as a
|
|
// user edit and pin it.
|
|
let baseline = Self::refreshed_identity_baseline(
|
|
applied_obj,
|
|
&previous_baseline,
|
|
&overrides,
|
|
);
|
|
match serde_json::to_string(&baseline) {
|
|
Ok(s) => used_identity_baseline = Some(s),
|
|
Err(e) => log::warn!("Failed to serialize identity baseline: {e}"),
|
|
}
|
|
}
|
|
|
|
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}");
|
|
last_apply_error = Some(e.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !applied_ok {
|
|
// Includes `page_targets` being empty: there was no target to send to,
|
|
// so the device was never applied either. Kill the browser rather than
|
|
// leave a window running a device the user did not choose and the app
|
|
// does not know about.
|
|
let detail = last_apply_error
|
|
.unwrap_or_else(|| "the browser exposed no page target to apply it to".to_string());
|
|
log::error!(
|
|
"Killing Wayfern (pid {process_id:?}) for profile {}: the fingerprint was never applied: {detail}",
|
|
profile.name
|
|
);
|
|
if let Some(pid) = process_id {
|
|
kill_browser_process(pid);
|
|
}
|
|
return Err(
|
|
Self::apply_failure_error(
|
|
&detail,
|
|
Self::claimed_operating_system(config, Some(&stored)),
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
} else {
|
|
log::warn!("No fingerprint found in config, browser will use default fingerprint");
|
|
}
|
|
|
|
// Geolocation is handled internally by the browser binary.
|
|
|
|
if let Some(url) = url {
|
|
log::info!("Navigating to URL via CDP");
|
|
if let Some(target) = page_targets.first() {
|
|
if let Some(ws_url) = &target.websocket_debugger_url {
|
|
if let Err(e) = self
|
|
.send_cdp_command(ws_url, "Page.navigate", json!({ "url": url }))
|
|
.await
|
|
{
|
|
log::error!("Failed to navigate to URL: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for target in &page_targets {
|
|
if let Some(ws_url) = &target.websocket_debugger_url {
|
|
let _ = self
|
|
.send_cdp_command(ws_url, "Emulation.clearDeviceMetricsOverride", json!({}))
|
|
.await;
|
|
let _ = self
|
|
.send_cdp_command(
|
|
ws_url,
|
|
"Emulation.setFocusEmulationEnabled",
|
|
json!({ "enabled": false }),
|
|
)
|
|
.await;
|
|
let _ = self
|
|
.send_cdp_command(
|
|
ws_url,
|
|
"Emulation.setEmulatedMedia",
|
|
json!({ "media": "", "features": [] }),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
let id = uuid::Uuid::new_v4().to_string();
|
|
let instance = WayfernInstance {
|
|
id: id.clone(),
|
|
process_id,
|
|
profile_path: Some(profile_path.to_string()),
|
|
url: url.map(|s| s.to_string()),
|
|
cdp_port: Some(port),
|
|
};
|
|
|
|
let mut inner = self.inner.lock().await;
|
|
inner.instances.insert(id.clone(), instance);
|
|
|
|
Ok(WayfernLaunchResult {
|
|
id,
|
|
processId: process_id,
|
|
profilePath: Some(profile_path.to_string()),
|
|
url: url.map(|s| s.to_string()),
|
|
cdp_port: Some(port),
|
|
used_fingerprint,
|
|
used_identity_baseline,
|
|
})
|
|
}
|
|
|
|
pub async fn stop_wayfern(
|
|
&self,
|
|
id: &str,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut inner = self.inner.lock().await;
|
|
|
|
if let Some(instance) = inner.instances.remove(id) {
|
|
log::info!("Cleaning up Wayfern instance {}", instance.id);
|
|
if let Some(pid) = instance.process_id {
|
|
kill_browser_process(pid);
|
|
log::info!("Stopped Wayfern instance {id} (PID: {pid})");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Opens a URL in a new tab for an existing Wayfern instance.
|
|
pub async fn open_url_in_tab(
|
|
&self,
|
|
profile_path: &str,
|
|
url: &str,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let inner = self.inner.lock().await;
|
|
let target_path = std::path::Path::new(profile_path)
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
|
|
|
|
let port = inner
|
|
.instances
|
|
.values()
|
|
.find(|i| {
|
|
i.profile_path
|
|
.as_deref()
|
|
.map(|p| {
|
|
std::path::Path::new(p)
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| std::path::Path::new(p).to_path_buf())
|
|
== target_path
|
|
})
|
|
.unwrap_or(false)
|
|
})
|
|
.and_then(|i| i.cdp_port)
|
|
.ok_or("Wayfern instance (with CDP port) not found for profile")?;
|
|
drop(inner);
|
|
|
|
// Open the URL in a new tab via the CDP HTTP convenience endpoint.
|
|
let new_tab_url = format!(
|
|
"http://127.0.0.1:{port}/json/new?{}",
|
|
urlencoding::encode(url)
|
|
);
|
|
let resp = self
|
|
.http_client
|
|
.put(&new_tab_url)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to open new tab: {e}"))?;
|
|
if !resp.status().is_success() {
|
|
return Err(format!("CDP /json/new returned HTTP {}", resp.status()).into());
|
|
}
|
|
|
|
log::info!("Opened URL in new tab via CDP");
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_cdp_port(&self, profile_path: &str) -> Option<u16> {
|
|
let inner = self.inner.lock().await;
|
|
let target_path = std::path::Path::new(profile_path)
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
|
|
|
|
for instance in inner.instances.values() {
|
|
if let Some(path) = &instance.profile_path {
|
|
let instance_path = std::path::Path::new(path)
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
|
|
if instance_path == target_path {
|
|
return instance.cdp_port;
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub async fn find_wayfern_by_profile(&self, profile_path: &str) -> Option<WayfernLaunchResult> {
|
|
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
|
|
|
let mut inner = self.inner.lock().await;
|
|
|
|
// Canonicalize the target path for comparison
|
|
let target_path = std::path::Path::new(profile_path)
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
|
|
|
|
// Find the instance with the matching profile path
|
|
let mut found_id: Option<String> = None;
|
|
for (id, instance) in &inner.instances {
|
|
if let Some(path) = &instance.profile_path {
|
|
let instance_path = std::path::Path::new(path)
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
|
|
if instance_path == target_path {
|
|
found_id = Some(id.clone());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we found an instance, verify the process is still running
|
|
if let Some(id) = found_id {
|
|
if let Some(instance) = inner.instances.get(&id) {
|
|
if let Some(pid) = instance.process_id {
|
|
let system = System::new_with_specifics(
|
|
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
|
);
|
|
let sysinfo_pid = sysinfo::Pid::from_u32(pid);
|
|
|
|
if system.process(sysinfo_pid).is_some() {
|
|
return Some(WayfernLaunchResult {
|
|
id: id.clone(),
|
|
processId: instance.process_id,
|
|
profilePath: instance.profile_path.clone(),
|
|
url: instance.url.clone(),
|
|
cdp_port: instance.cdp_port,
|
|
used_fingerprint: None,
|
|
used_identity_baseline: None,
|
|
});
|
|
} else {
|
|
log::info!(
|
|
"Wayfern process {} for profile {} is no longer running, cleaning up",
|
|
pid,
|
|
profile_path
|
|
);
|
|
inner.instances.remove(&id);
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If not found in in-memory instances, scan system processes.
|
|
// This handles the case where the GUI was restarted but Wayfern is still running.
|
|
if let Some((pid, found_profile_path, cdp_port)) =
|
|
Self::find_wayfern_process_by_profile(&target_path)
|
|
{
|
|
log::info!(
|
|
"Found running Wayfern process (PID: {}) for profile path via system scan",
|
|
pid
|
|
);
|
|
|
|
let instance_id = format!("recovered_{}", pid);
|
|
inner.instances.insert(
|
|
instance_id.clone(),
|
|
WayfernInstance {
|
|
id: instance_id.clone(),
|
|
process_id: Some(pid),
|
|
profile_path: Some(found_profile_path.clone()),
|
|
url: None,
|
|
cdp_port,
|
|
},
|
|
);
|
|
|
|
return Some(WayfernLaunchResult {
|
|
id: instance_id,
|
|
processId: Some(pid),
|
|
profilePath: Some(found_profile_path),
|
|
url: None,
|
|
cdp_port,
|
|
used_fingerprint: None,
|
|
used_identity_baseline: None,
|
|
});
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Scan system processes to find a Wayfern/Chromium process using a specific profile path
|
|
fn find_wayfern_process_by_profile(
|
|
target_path: &std::path::Path,
|
|
) -> Option<(u32, String, Option<u16>)> {
|
|
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
|
|
|
let system = System::new_with_specifics(
|
|
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
|
);
|
|
|
|
let target_path_str = target_path.to_string_lossy();
|
|
|
|
for (pid, process) in system.processes() {
|
|
let cmd = process.cmd();
|
|
if cmd.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let exe_name = process.name().to_string_lossy().to_lowercase();
|
|
let is_chromium_like = exe_name.contains("wayfern")
|
|
|| exe_name.contains("chromium")
|
|
|| exe_name.contains("chrome");
|
|
|
|
if !is_chromium_like {
|
|
continue;
|
|
}
|
|
|
|
// Skip child processes (renderer, GPU, utility, zygote, etc.)
|
|
// Only the main browser process lacks a --type= argument
|
|
let is_child = cmd
|
|
.iter()
|
|
.any(|a| a.to_str().is_some_and(|s| s.starts_with("--type=")));
|
|
if is_child {
|
|
continue;
|
|
}
|
|
|
|
let mut matched = false;
|
|
let mut cdp_port: Option<u16> = None;
|
|
|
|
for arg in cmd.iter() {
|
|
if let Some(arg_str) = arg.to_str() {
|
|
if let Some(dir_val) = arg_str.strip_prefix("--user-data-dir=") {
|
|
let cmd_path = std::path::Path::new(dir_val)
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| std::path::Path::new(dir_val).to_path_buf());
|
|
if cmd_path == target_path {
|
|
matched = true;
|
|
}
|
|
}
|
|
|
|
if let Some(port_val) = arg_str.strip_prefix("--remote-debugging-port=") {
|
|
cdp_port = port_val.parse().ok();
|
|
}
|
|
}
|
|
}
|
|
|
|
if matched {
|
|
return Some((pid.as_u32(), target_path_str.to_string(), cdp_port));
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub async fn launch_wayfern_profile(
|
|
&self,
|
|
app_handle: &AppHandle,
|
|
profile: &BrowserProfile,
|
|
config: &WayfernConfig,
|
|
url: Option<&str>,
|
|
proxy_url: Option<&str>,
|
|
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
let profiles_dir = self.get_profiles_dir();
|
|
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
|
let profile_path_str = profile_path.to_string_lossy().to_string();
|
|
|
|
std::fs::create_dir_all(&profile_path)?;
|
|
|
|
if let Some(existing) = self.find_wayfern_by_profile(&profile_path_str).await {
|
|
log::info!("Stopping existing Wayfern instance for profile");
|
|
self.stop_wayfern(&existing.id).await?;
|
|
}
|
|
|
|
self
|
|
.launch_wayfern(
|
|
app_handle,
|
|
profile,
|
|
&profile_path_str,
|
|
config,
|
|
url,
|
|
proxy_url,
|
|
profile.ephemeral,
|
|
&[],
|
|
None,
|
|
false,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub async fn cleanup_dead_instances(&self) {
|
|
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
|
|
|
let mut inner = self.inner.lock().await;
|
|
let mut dead_ids = Vec::new();
|
|
|
|
let system = System::new_with_specifics(
|
|
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
|
);
|
|
|
|
for (id, instance) in &inner.instances {
|
|
if let Some(pid) = instance.process_id {
|
|
let pid = sysinfo::Pid::from_u32(pid);
|
|
if !system.processes().contains_key(&pid) {
|
|
dead_ids.push(id.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
for id in dead_ids {
|
|
log::info!("Cleaning up dead Wayfern instance: {id}");
|
|
inner.instances.remove(&id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Terminate a browser process by pid.
|
|
///
|
|
/// Shared with the launch path, which cannot go through `stop_wayfern`: an
|
|
/// instance is only registered in `inner.instances` at the very end of
|
|
/// `launch_wayfern`, so a launch that aborts part-way has a live process and no
|
|
/// id to stop it by.
|
|
fn kill_browser_process(pid: u32) {
|
|
#[cfg(unix)]
|
|
{
|
|
use nix::sys::signal::{kill, Signal};
|
|
use nix::unistd::Pid;
|
|
let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM);
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt;
|
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
let _ = std::process::Command::new("taskkill")
|
|
.args(["/PID", &pid.to_string(), "/F"])
|
|
.creation_flags(CREATE_NO_WINDOW)
|
|
.output();
|
|
}
|
|
}
|
|
|
|
lazy_static::lazy_static! {
|
|
static ref WAYFERN_MANAGER: WayfernManager = WayfernManager::new();
|
|
}
|
|
|
|
/// Deterministically derive a pleasant, distinct window frame color from a
|
|
/// profile id so concurrent profile windows are visually distinguishable even
|
|
/// when the user has not picked a custom color. Stable per profile (same id
|
|
/// always yields the same color). Returns "#RRGGBB".
|
|
pub fn derive_profile_color(id: &uuid::Uuid) -> String {
|
|
// FNV-1a over the 16 id bytes -> hue in [0,360). The hue varies per profile
|
|
// while saturation/lightness are fixed to a pastel band (see below).
|
|
let mut h: u32 = 2166136261;
|
|
for &b in id.as_bytes() {
|
|
h = (h ^ u32::from(b)).wrapping_mul(16777619);
|
|
}
|
|
let hue = f64::from(h % 360);
|
|
// Pastel: high lightness + soft saturation so windows stay easy to tell apart
|
|
// without a garish frame.
|
|
let (r, g, b) = hsl_to_rgb(hue, 0.6, 0.8);
|
|
format!("#{r:02x}{g:02x}{b:02x}")
|
|
}
|
|
|
|
/// Convert HSL (h in [0,360), s/l in [0,1]) to 8-bit RGB.
|
|
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
|
|
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
|
let hp = h / 60.0;
|
|
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
|
|
let (r1, g1, b1) = match hp as i32 {
|
|
0 => (c, x, 0.0),
|
|
1 => (x, c, 0.0),
|
|
2 => (0.0, c, x),
|
|
3 => (0.0, x, c),
|
|
4 => (x, 0.0, c),
|
|
_ => (c, 0.0, x),
|
|
};
|
|
let m = l - c / 2.0;
|
|
let to_u8 = |v: f64| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
|
|
(to_u8(r1), to_u8(g1), to_u8(b1))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn remote_socks_url_detection() {
|
|
// Remote socks upstreams (the hyper-util-affected case) are detected...
|
|
assert!(WayfernManager::is_remote_socks_url(
|
|
"socks5://user:pass@gw.dataimpulse.com:10000"
|
|
));
|
|
assert!(WayfernManager::is_remote_socks_url("socks5://1.2.3.4:1080"));
|
|
assert!(WayfernManager::is_remote_socks_url("socks4://1.2.3.4:1080"));
|
|
|
|
// ...but the app's own loopback workers are not. socks is a non-special
|
|
// URL scheme, so the IP literal parses as Host::Domain — the launch-time
|
|
// randomize path depends on this returning false.
|
|
assert!(!WayfernManager::is_remote_socks_url(
|
|
"socks5://127.0.0.1:24001"
|
|
));
|
|
assert!(!WayfernManager::is_remote_socks_url("socks5://[::1]:24001"));
|
|
assert!(!WayfernManager::is_remote_socks_url(
|
|
"socks5://localhost:24001"
|
|
));
|
|
|
|
// Non-socks schemes and unparsable URLs never need the workaround.
|
|
assert!(!WayfernManager::is_remote_socks_url(
|
|
"http://gw.dataimpulse.com:10000"
|
|
));
|
|
assert!(!WayfernManager::is_remote_socks_url(
|
|
"https://gw.dataimpulse.com:10000"
|
|
));
|
|
assert!(!WayfernManager::is_remote_socks_url("socks5://"));
|
|
assert!(!WayfernManager::is_remote_socks_url("not a url"));
|
|
}
|
|
|
|
fn obj(json: &str) -> serde_json::Map<String, serde_json::Value> {
|
|
serde_json::from_str(json).expect("test fixture must be an object")
|
|
}
|
|
|
|
#[test]
|
|
fn identity_api_switch_follows_the_chromium_major() {
|
|
// The profile version is the full Chromium version string.
|
|
assert!(!supports_identity_api("150.0.7801.12"));
|
|
assert!(supports_identity_api("151.0.7922.71"));
|
|
assert!(supports_identity_api("152.0.1.0"));
|
|
// A version we cannot parse must not be assumed to have the identity API:
|
|
// calling createIdentity on a 150 binary is an unknown-command error,
|
|
// whereas the legacy pair still exists on every version that ever shipped.
|
|
assert!(!supports_identity_api(""));
|
|
assert!(!supports_identity_api("not a version"));
|
|
}
|
|
|
|
#[test]
|
|
fn overrides_are_only_what_differs_from_the_derived_view() {
|
|
let baseline = obj(r#"{"hardwareConcurrency": 8, "deviceMemory": 8, "platform": "Win32"}"#);
|
|
let current = obj(r#"{"hardwareConcurrency": 16, "deviceMemory": 8, "platform": "Win32"}"#);
|
|
|
|
let overrides = WayfernManager::identity_overrides(¤t, &baseline);
|
|
assert_eq!(overrides.len(), 1);
|
|
assert_eq!(overrides.get("hardwareConcurrency"), Some(&json!(16)));
|
|
}
|
|
|
|
#[test]
|
|
fn overrides_exclude_geolocation_and_include_added_keys() {
|
|
let baseline = obj(r#"{"timezone": "America/New_York", "platform": "Win32"}"#);
|
|
// Location is rewritten by donut on every geolocation refresh, and
|
|
// setIdentity takes it as its own parameter, so it must never be sent twice.
|
|
let current = obj(
|
|
r#"{"timezone": "Europe/Berlin", "language": "de-DE", "platform": "Win32", "doNotTrack": "1"}"#,
|
|
);
|
|
|
|
let overrides = WayfernManager::identity_overrides(¤t, &baseline);
|
|
assert_eq!(overrides.len(), 1);
|
|
assert_eq!(overrides.get("doNotTrack"), Some(&json!("1")));
|
|
|
|
let geo = WayfernManager::geo_params(¤t);
|
|
assert_eq!(geo.get("timezone"), Some(&json!("Europe/Berlin")));
|
|
assert_eq!(geo.get("language"), Some(&json!("de-DE")));
|
|
assert!(geo.get("platform").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn baseline_adopts_rederived_values_but_keeps_overridden_ones() {
|
|
// The same identity on a newer browser derives 12 cores where it used to
|
|
// derive 8, while the user has pinned deviceMemory to 32.
|
|
let previous = obj(r#"{"hardwareConcurrency": 8, "deviceMemory": 8}"#);
|
|
let overrides = obj(r#"{"deviceMemory": 32}"#);
|
|
let applied = obj(r#"{"hardwareConcurrency": 12, "deviceMemory": 32}"#);
|
|
|
|
let refreshed = WayfernManager::refreshed_identity_baseline(&applied, &previous, &overrides);
|
|
|
|
// Re-derived: adopted, so the next launch does not mistake it for an edit.
|
|
assert_eq!(refreshed.get("hardwareConcurrency"), Some(&json!(12)));
|
|
// Overridden: the applied view holds the override, so the derived value is
|
|
// kept as the diff reference and the override survives.
|
|
assert_eq!(refreshed.get("deviceMemory"), Some(&json!(8)));
|
|
|
|
let next_overrides = WayfernManager::identity_overrides(&applied, &refreshed);
|
|
assert_eq!(next_overrides.len(), 1);
|
|
assert_eq!(next_overrides.get("deviceMemory"), Some(&json!(32)));
|
|
}
|
|
|
|
#[test]
|
|
fn baseline_keeps_a_user_added_key_out_so_the_override_survives() {
|
|
// The user set a field the derivation never produces. There is no derived
|
|
// value to fall back to, so the key must stay absent from the baseline.
|
|
let previous = obj(r#"{"platform": "Win32"}"#);
|
|
let overrides = obj(r#"{"doNotTrack": "1"}"#);
|
|
let applied = obj(r#"{"platform": "Win32", "doNotTrack": "1"}"#);
|
|
|
|
let refreshed = WayfernManager::refreshed_identity_baseline(&applied, &previous, &overrides);
|
|
assert!(refreshed.get("doNotTrack").is_none());
|
|
|
|
let next_overrides = WayfernManager::identity_overrides(&applied, &refreshed);
|
|
assert_eq!(next_overrides.get("doNotTrack"), Some(&json!("1")));
|
|
}
|
|
|
|
#[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"]});
|
|
|
|
WayfernManager::carry_over_locale(&stored, &mut applied);
|
|
let applied = applied.as_object().unwrap();
|
|
|
|
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.
|
|
assert_eq!(
|
|
applied.get("languages"),
|
|
Some(&json!(["de-DE", "de", "en-US", "en"]))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn overrides_drop_the_geo_fields_donut_synthesised_itself() {
|
|
// The baseline is snapshotted BEFORE geolocation runs, so it never carries
|
|
// these two. Without the filter they diff into the override set on the
|
|
// first launch and stay pinned there for the life of the profile, which
|
|
// permanently replaces the browser's realistic ladder with donut's.
|
|
let baseline = obj(
|
|
r#"{"platform": "Win32", "timezoneOffset": 0,
|
|
"languages": ["de-DE", "de", "en-US", "en"]}"#,
|
|
);
|
|
// Built through the same helper the writer uses so the fixture cannot go
|
|
// stale when Berlin changes its DST offset.
|
|
let offset = WayfernManager::timezone_offset_minutes("Europe/Berlin").expect("known zone");
|
|
let mut current = obj(
|
|
r#"{"platform": "Win32", "timezone": "Europe/Berlin",
|
|
"language": "de-DE", "languages": ["de-DE", "de"]}"#,
|
|
);
|
|
current.insert("timezoneOffset".to_string(), json!(offset));
|
|
|
|
let overrides = WayfernManager::identity_overrides(¤t, &baseline);
|
|
assert!(overrides.is_empty(), "unexpected overrides: {overrides:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_user_edited_language_ladder_still_travels_as_an_override() {
|
|
// Anything that is NOT what donut would have written is a real edit, so no
|
|
// editing capability is lost by the filter above.
|
|
let baseline = obj(r#"{"platform": "Win32", "languages": ["de-DE", "de"]}"#);
|
|
let current = obj(
|
|
r#"{"platform": "Win32", "timezone": "Europe/Berlin", "language": "de-DE",
|
|
"languages": ["de-DE", "de", "en-US", "en"]}"#,
|
|
);
|
|
|
|
let overrides = WayfernManager::identity_overrides(¤t, &baseline);
|
|
assert_eq!(
|
|
overrides.get("languages"),
|
|
Some(&json!(["de-DE", "de", "en-US", "en"]))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn derived_provenance_never_travels_as_an_override() {
|
|
// setIdentity rejects the whole call when one of these appears, so a stored
|
|
// payload carrying them would fail on every launch rather than once.
|
|
let baseline = obj(r#"{"platform": "Win32"}"#);
|
|
let current = obj(
|
|
r#"{"platform": "Win32", "webglProfileId": "webgl-abc",
|
|
"mediaProfile": "media-abc", "deviceProfileApplied": true,
|
|
"doNotTrack": "1"}"#,
|
|
);
|
|
|
|
let overrides = WayfernManager::identity_overrides(¤t, &baseline);
|
|
assert_eq!(overrides.len(), 1);
|
|
assert_eq!(overrides.get("doNotTrack"), Some(&json!("1")));
|
|
}
|
|
|
|
#[test]
|
|
fn the_claimed_os_comes_from_the_config_then_the_stored_platform() {
|
|
let stored = obj(r#"{"platform": "MacIntel"}"#);
|
|
|
|
// An explicit claim wins.
|
|
let config = WayfernConfig {
|
|
os: Some("android".to_string()),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
WayfernManager::claimed_operating_system(&config, Some(&stored)),
|
|
Some("android")
|
|
);
|
|
|
|
// Profiles minted before `os` existed fall back to the stored device.
|
|
let config = WayfernConfig::default();
|
|
assert_eq!(
|
|
WayfernManager::claimed_operating_system(&config, Some(&stored)),
|
|
Some("macos")
|
|
);
|
|
|
|
// Nothing to read, and an unrecognised claim, both stay unknown so the
|
|
// launch is never refused on a guess.
|
|
assert_eq!(
|
|
WayfernManager::claimed_operating_system(&WayfernConfig::default(), None),
|
|
None
|
|
);
|
|
let config = WayfernConfig {
|
|
os: Some("freebsd".to_string()),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
WayfernManager::claimed_operating_system(&config, None),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn platform_strings_map_the_way_the_browser_maps_them() {
|
|
// Mirrors WayfernHandler::IsCrossOSFromPlatform, including armv8l/aarch64
|
|
// reading as android rather than linux.
|
|
assert_eq!(WayfernManager::os_from_platform("Win32"), Some("windows"));
|
|
assert_eq!(WayfernManager::os_from_platform("MacIntel"), Some("macos"));
|
|
assert_eq!(WayfernManager::os_from_platform("iPhone"), Some("ios"));
|
|
assert_eq!(
|
|
WayfernManager::os_from_platform("Linux armv8l"),
|
|
Some("android")
|
|
);
|
|
assert_eq!(
|
|
WayfernManager::os_from_platform("Linux aarch64"),
|
|
Some("android")
|
|
);
|
|
assert_eq!(
|
|
WayfernManager::os_from_platform("Linux x86_64"),
|
|
Some("linux")
|
|
);
|
|
assert_eq!(WayfernManager::os_from_platform("Nintendo"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_refused_apply_is_translated_to_a_code_the_frontend_knows() {
|
|
// The exact literals WayfernHandler emits.
|
|
let cross_os = WayfernManager::apply_failure_error(
|
|
"CDP error: Cross-OS fingerprinting requires a paid plan. Provide a wayfernToken parameter.",
|
|
Some("macos"),
|
|
);
|
|
assert!(cross_os.contains("WAYFERN_CROSS_OS_REQUIRES_PLAN"));
|
|
assert!(cross_os.contains("macos"));
|
|
|
|
let quota = WayfernManager::apply_failure_error(
|
|
"CDP error: Fingerprint generation limit reached for this account.",
|
|
None,
|
|
);
|
|
assert!(quota.contains("WAYFERN_GENERATION_LIMIT_REACHED"));
|
|
|
|
// Anything else keeps the raw text for support, under the generic code.
|
|
let other = WayfernManager::apply_failure_error("CDP error: No response received", None);
|
|
assert!(other.contains("WAYFERN_FINGERPRINT_APPLY_FAILED"));
|
|
assert!(other.contains("No response received"));
|
|
}
|
|
|
|
#[test]
|
|
fn window_size_prefers_outer_window_dimensions() {
|
|
// Field names + values mirror a real Wayfern fingerprint (camelCase).
|
|
let fp = r#"{"windowOuterWidth": 1268, "windowOuterHeight": 764,
|
|
"windowInnerWidth": 1253, "windowInnerHeight": 630,
|
|
"screenAvailWidth": 1280, "screenAvailHeight": 775,
|
|
"screenWidth": 1280, "screenHeight": 800}"#;
|
|
assert_eq!(
|
|
WayfernManager::window_size_from_fingerprint(fp),
|
|
Some((1268, 764))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn window_size_falls_back_to_avail_then_full_screen() {
|
|
let avail = r#"{"screenAvailWidth": 1280, "screenAvailHeight": 775,
|
|
"screenWidth": 1280, "screenHeight": 800}"#;
|
|
assert_eq!(
|
|
WayfernManager::window_size_from_fingerprint(avail),
|
|
Some((1280, 775))
|
|
);
|
|
|
|
let full = r#"{"screenWidth": 2560, "screenHeight": 1440}"#;
|
|
assert_eq!(
|
|
WayfernManager::window_size_from_fingerprint(full),
|
|
Some((2560, 1440))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn window_size_handles_wrapper_and_stringified_numbers() {
|
|
let wrapped = r#"{"fingerprint": {"windowOuterWidth": "1366", "windowOuterHeight": "768"}}"#;
|
|
assert_eq!(
|
|
WayfernManager::window_size_from_fingerprint(wrapped),
|
|
Some((1366, 768))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn window_size_none_when_missing_or_invalid() {
|
|
// No dimensions at all.
|
|
assert_eq!(
|
|
WayfernManager::window_size_from_fingerprint(r#"{"userAgent": "x"}"#),
|
|
None
|
|
);
|
|
// A width with no matching height is not a usable pair.
|
|
assert_eq!(
|
|
WayfernManager::window_size_from_fingerprint(r#"{"windowOuterWidth": 1268}"#),
|
|
None
|
|
);
|
|
// Zero is rejected as a degenerate size.
|
|
assert_eq!(
|
|
WayfernManager::window_size_from_fingerprint(
|
|
r#"{"windowOuterWidth": 0, "windowOuterHeight": 0}"#
|
|
),
|
|
None
|
|
);
|
|
// Not valid JSON.
|
|
assert_eq!(
|
|
WayfernManager::window_size_from_fingerprint("not json"),
|
|
None
|
|
);
|
|
}
|
|
}
|