use crate::browser_runner::BrowserRunner; use crate::profile::BrowserProfile; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::{HashMap, VecDeque}; use std::path::{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, skip_serializing_if = "Option::is_none")] pub fingerprint: Option, #[serde(default)] pub randomize_fingerprint_on_launch: Option, #[serde(default)] pub os: Option, #[serde(default)] pub screen_max_width: Option, #[serde(default)] pub screen_max_height: Option, #[serde(default)] pub screen_min_width: Option, #[serde(default)] pub screen_min_height: Option, #[serde(default)] pub geoip: Option, // For compatibility with shared config form #[serde(default)] pub block_images: Option, // For compatibility with shared config form /// LEGACY on/off switch kept for stored configs; `webrtc_mode` wins when /// both are present, and `Some(true)` alone reads as `block`. #[serde(default)] pub block_webrtc: Option, /// How WebRTC may reach the network: `auto` (real STUN only where the UDP /// it needs is carried by the route, else the exit-IP posture), `tcp_only` /// (one server-reflexive candidate on the proxy's exit IP), or `block` (no /// ICE candidates at all). `None` is `auto`. #[serde(default)] pub webrtc_mode: Option, /// The user's edits to the profile's persona, as a JSON array of /// `{id,label,value}`. Everything not edited is derived from the profile's /// own seed, so this holds edits and nothing else. An empty value removes /// that row from what the browser offers. #[serde(default)] pub persona: Option, /// A still (.png) or clip (.y4m/.mjpeg) the claimed camera serves, as an /// absolute path. `None` means the camera serves dark frames; the browser /// never falls back to the host device. #[serde(default)] pub camera_file: Option, /// `x,y,width,height` in SOURCE pixels, cropped before the frame is scaled. #[serde(default)] pub camera_crop: Option, /// Whether an interactive launch reopens the windows and tabs of the last /// session. `None` is the default, which is on. Automation, headless, /// ephemeral and clear-on-close launches never restore, whatever this says, /// and neither does a browser that cannot take the identity at launch: a /// restored tab loads before any post-launch `setIdentity`, so it would /// carry the host device for its whole lifetime. #[serde(default)] pub restore_session: Option, #[serde(default)] pub block_webgl: Option, #[serde(default, skip_serializing)] pub proxy: Option, /// 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, #[serde(default)] pub identity_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub identity_baseline: Option, /// The user's own edits to an identity-backed device, as a JSON object of /// fingerprint fields. Sent verbatim as `setIdentity` overrides; everything /// not listed here comes from the identity. `None` means no edits. #[serde(default)] pub identity_overrides: Option, /// The location the profile's exit resolves to (timezone, timezoneOffset, /// language, languages, latitude, longitude, accuracy) as a JSON object. /// It depends on the proxy, not on the identity, which is why it is the one /// piece of device state an identity-backed profile persists. #[serde(default)] pub location: Option, } /// 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 that is ever needed; 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 } /// First Wayfern version that ships the 152 launch contract: the identity file /// (`--wayfern-identity-file`), the `--wayfern-token` switch, WebRTC mode and /// exit IP, persona/icon/camera/Widevine/entitlement-cache switches, the /// `Vellum` input domain and the perception/locator/extraction commands. /// Everything gated on it keeps its pre-152 path on any older browser. const WAYFERN_152_MIN_VERSION: &str = "152"; /// Whether `version` speaks the Wayfern 152 launch and automation contract. /// Same rule as [`supports_identity_api`]: pass `BrowserProfile::version`, read /// it at the point of use, and an unparsable version takes the older path. pub fn supports_wayfern_152(version: &str) -> bool { crate::api_client::compare_versions(version, WAYFERN_152_MIN_VERSION) != std::cmp::Ordering::Less } /// Where the launch-time identity document lives inside the profile directory. /// Rewritten before every launch; the sync manifest excludes it. pub const LAUNCH_IDENTITY_FILE: &str = "wayfern-identity.json"; /// What kind of launch this is, from the browser's point of view. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LaunchKind { /// A person opened the profile from the app: the window is theirs, and the /// session they left is the one they expect to find again. Interactive, /// REST, MCP or a batch run drives the browser: it starts clean on the URL /// the caller named and never reopens what a person left behind. Automation, } /// Whether this launch reopens the last session, or why it does not. /// /// `identity_at_launch` says the device is committed before the first /// navigation (the 152 identity file), or that there is no device to commit. /// Anything applied over CDP after the window opens loses the race with a /// restored tab, which then carries the host device for its whole lifetime, /// so such a launch starts on a fresh tab instead and the log says why. pub fn session_restore_verdict( config: &WayfernConfig, kind: LaunchKind, headless: bool, ephemeral: bool, clear_on_close: bool, identity_at_launch: bool, ) -> Result<(), &'static str> { if kind == LaunchKind::Automation { return Err("an automation run starts clean"); } if headless { return Err("a headless launch has no session to show"); } if ephemeral { return Err("an ephemeral profile keeps nothing between launches"); } if clear_on_close { return Err("the profile clears its data on close"); } if config.randomize_fingerprint_on_launch == Some(true) { return Err("a device randomized on every launch has no session to continue"); } if config.restore_session == Some(false) { return Err("the profile has session restore switched off"); } if !identity_at_launch { return Err( "this browser applies the identity after the window opens, so a restored tab would load on the host device", ); } Ok(()) } /// The switches that shape the first window: session restore and the launch /// identity. Kept apart from the rest of the command line so a test can pin /// them per launch kind. pub fn session_switches(restore_session: bool, identity_file: Option<&Path>) -> Vec { let mut switches = Vec::new(); if let Some(path) = identity_file { switches.push(format!("--wayfern-identity-file={}", path.display())); } if restore_session { switches.push("--restore-last-session".to_string()); } switches } /// The WebRTC posture a 152 browser is launched with. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WebRtcMode { Auto, TcpOnly, Block, } impl WebRtcMode { pub fn parse(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "auto" => Some(Self::Auto), "tcp_only" | "tcp-only" | "tcponly" => Some(Self::TcpOnly), "block" | "blocked" | "off" => Some(Self::Block), _ => None, } } /// The mode a stored config asks for. `webrtc_mode` wins; the legacy /// `block_webrtc: true` reads as `block`; anything else is `auto`. An /// unknown string is `auto` too, logged by the caller, never a launch error. pub fn from_config(config: &WayfernConfig) -> Self { if let Some(mode) = config.webrtc_mode.as_deref().and_then(Self::parse) { return mode; } if config.block_webrtc == Some(true) { return Self::Block; } Self::Auto } pub fn switch_value(self) -> &'static str { match self { Self::Auto => "auto", Self::TcpOnly => "tcp_only", Self::Block => "block", } } } /// The WebRTC switches for a launch: the mode always, and the exit IP whenever /// one is known and the mode can use it. A value the browser will not accept /// costs only the synthetic server-reflexive candidate that makes a TCP-only /// route look natural, never a leak. Pre-152 browsers do not read these, and /// get nothing. pub fn webrtc_switches(version: &str, mode: WebRtcMode, exit_ip: Option<&str>) -> Vec { if !supports_wayfern_152(version) { return Vec::new(); } let mut switches = vec![format!("--wayfern-webrtc-mode={}", mode.switch_value())]; if mode != WebRtcMode::Block { if let Some(ip) = exit_ip.map(str::trim).filter(|ip| !ip.is_empty()) { if let Ok(address) = ip.parse::() { switches.push(format!("--wayfern-webrtc-exit-ip={address}")); } } } switches } /// Say so when a device claims a screen the host cannot show. /// /// One function for both launch paths: an identity-backed profile learns its /// screen from the running browser, a legacy one carries it on disk, and the /// sentence is the same either way. impl WayfernManager { fn warn_on_screen_over_host(device_json: &str, profile: &BrowserProfile, app_handle: &AppHandle) { if let Some((claimed_w, claimed_h, host_w, host_h)) = screen_claim_over_host(Some(device_json), host_screen_size(app_handle)) { log::warn!( "Profile {} claims a {claimed_w}x{claimed_h} screen on a {host_w}x{host_h} display: its window can never fill the screen it reports, which a page can measure", profile.name ); } } } /// The claimed screen a stored device presents, in CSS pixels. fn claimed_screen(fingerprint_json: &str) -> Option<(u32, u32)> { let device = WayfernManager::fingerprint_object(fingerprint_json)?; let read = |key: &str| device.get(key).and_then(|v| v.as_u64()).map(|v| v as u32); Some((read("screenWidth")?, read("screenHeight")?)) } /// How much bigger the claimed screen is than the display the browser will /// actually open on, when it is bigger at all. /// /// A device claiming a screen the host cannot show is visible from the page: /// the window can never grow to the claimed size, so `outerWidth` stays below /// `screen.width` however the user maximises it. Donut cannot fix the device /// at launch without silently changing it, so it says so instead, on the /// launch report and in the log. pub fn screen_claim_over_host( fingerprint_json: Option<&str>, host: Option<(u32, u32)>, ) -> Option<(u32, u32, u32, u32)> { let (claimed_width, claimed_height) = claimed_screen(fingerprint_json?)?; let (host_width, host_height) = host?; if host_width == 0 || host_height == 0 { return None; } (claimed_width > host_width || claimed_height > host_height).then_some(( claimed_width, claimed_height, host_width, host_height, )) } /// The primary display's size in CSS pixels, which is what a page reads. pub fn host_screen_size(app_handle: &AppHandle) -> Option<(u32, u32)> { let monitor = app_handle.primary_monitor().ok().flatten()?; let scale = monitor.scale_factor(); let size = monitor.size().to_logical::(scale); if size.width < 1.0 || size.height < 1.0 { return None; } Some((size.width as u32, size.height as u32)) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct WorkArea { pub origin: (i32, i32), pub size: (u32, u32), } pub fn host_work_area(app_handle: &AppHandle) -> Option { let monitor = app_handle.primary_monitor().ok().flatten()?; let scale = monitor.scale_factor(); let area = monitor.work_area(); let size = area.size.to_logical::(scale); let origin = area.position.to_logical::(scale); if size.width < 1.0 || size.height < 1.0 { return None; } Some(WorkArea { origin: (origin.x as i32, origin.y as i32), size: (size.width as u32, size.height as u32), }) } pub fn fit_window_to_work_area(requested: (u32, u32), work_area: Option<(u32, u32)>) -> (u32, u32) { let (width, height) = requested; match work_area { Some((max_width, max_height)) if max_width > 0 && max_height > 0 => { (width.min(max_width), height.min(max_height)) } _ => (width, height), } } /// Where a 152 browser keeps its entitlement cache: inside donut's own cache /// root rather than the OS default, so it is removed with the app's data and /// never shared between an e2e session and the real installation. pub fn entitlement_cache_switch(version: &str, cache_root: &Path) -> Option { if !supports_wayfern_152(version) { return None; } let dir = cache_root.join("wayfern-entitlements"); if let Err(e) = std::fs::create_dir_all(&dir) { log::warn!( "Could not create the Wayfern entitlement cache at {}: {e}; the browser keeps its default", dir.display() ); return None; } Some(format!("--wayfern-entitlement-cache-dir={}", dir.display())) } pub fn cdp_error_message(detail: &str) -> String { if let Some((_, rest)) = detail.split_once("CDP error: ") { if let Ok(error) = serde_json::from_str::(rest.trim()) { if let Some(message) = error.get("message").and_then(|m| m.as_str()) { return message.to_string(); } } } detail.trim().to_string() } pub fn cdp_error_is_invalid_params(detail: &str) -> bool { detail .split_once("CDP error: ") .and_then(|(_, rest)| serde_json::from_str::(rest.trim()).ok()) .and_then(|error| error.get("code").and_then(|code| code.as_i64())) == Some(-32602) } pub fn known_wayfern_failure(detail: &str, claimed_os: Option<&str>) -> Option { let message = cdp_error_message(detail); let says = |needle: &str| detail.contains(needle) || message.contains(needle); if says("Wayfern instance limit reached") { return Some(crate::backend_error("WAYFERN_INSTANCE_LIMIT_REACHED")); } if says("Before using Wayfern, you must read and agree") { return Some(crate::backend_error("WAYFERN_TERMS_REQUIRED")); } if says("temporarily unavailable") { return Some(crate::backend_error("WAYFERN_PLAN_CHECK_UNAVAILABLE")); } if says("Cross-OS fingerprinting requires") || says("Cross-OS fingerprinting authorization failed") || says("a cross-OS claim requires") { return Some(crate::backend_error_with_detail( "WAYFERN_CROSS_OS_REQUIRES_PLAN", claimed_os.unwrap_or("another operating system"), )); } if says("Custom fingerprints require") { return Some(crate::backend_error( "WAYFERN_CUSTOM_FINGERPRINT_REQUIRES_PLAN", )); } if says("generation limit reached") || says("Too many profiles") { return Some(crate::backend_error("WAYFERN_GENERATION_LIMIT_REACHED")); } if says("Fingerprint generation state is unavailable") { return Some(crate::backend_error("WAYFERN_GENERATION_UNAVAILABLE")); } if says("identity change is in progress") || says("Target closed during fingerprint identity change") || says("another identity change already owns this profile") || says("the identity could not be derived") || says("the derived identity was not committed") { return Some(crate::backend_error("WAYFERN_BROWSER_BUSY")); } None } pub fn wayfern_failure(detail: &str, fallback_code: &str, claimed_os: Option<&str>) -> String { if detail.trim_start().starts_with('{') { return detail.to_string(); } known_wayfern_failure(detail, claimed_os) .unwrap_or_else(|| crate::backend_error_with_detail(fallback_code, cdp_error_message(detail))) } fn early_exit_error(said: &str, status: &std::process::ExitStatus) -> String { known_wayfern_failure(said, None).unwrap_or_else(|| { crate::backend_error_with_detail("WAYFERN_BROWSER_EXITED", status.to_string()) }) } pub fn is_temporary_wayfern_failure(coded: &str) -> bool { [ "WAYFERN_INSTANCE_LIMIT_REACHED", "WAYFERN_PLAN_CHECK_UNAVAILABLE", "WAYFERN_GENERATION_UNAVAILABLE", "WAYFERN_BROWSER_BUSY", "WAYFERN_GENERATION_LIMIT_REACHED", ] .iter() .any(|code| coded.contains(&format!("\"{code}\""))) } /// Write the profile's persona beside its data directory and return the switch /// that hands it to a 152 browser. The document is DERIVED from `seed` with /// the user's edits applied, so it is stable per profile and unique to it. pub fn persona_switch( version: &str, profile_path: &str, seed: &str, edits: Option<&str>, ) -> Option { if !supports_wayfern_152(version) { return None; } let edits: Vec = edits .map(str::trim) .filter(|edits| !edits.is_empty()) .and_then(|edits| serde_json::from_str(edits).ok()) .unwrap_or_default(); let fields = crate::wayfern_persona::with_edits(seed, &edits); if fields.is_empty() { return None; } let path = Path::new(profile_path).join("wayfern-persona.json"); let body = serde_json::to_vec(&crate::wayfern_persona::document(&fields)).ok()?; if let Err(e) = std::fs::write(&path, body) { log::warn!( "Could not write the persona at {}: {e}; the browser offers no fill entries", path.display() ); return None; } crate::app_dirs::restrict_to_owner(&path); let path = path.canonicalize().unwrap_or(path); Some(format!("--wayfern-profile-persona={}", path.display())) } /// The camera switches for a launch. A file that is not there is not passed: /// the browser would report it and serve dark frames anyway, and the log line /// here names the profile, which its own does not. pub fn camera_switches(version: &str, config: &WayfernConfig) -> Vec { if !supports_wayfern_152(version) { return Vec::new(); } let Some(file) = config .camera_file .as_deref() .map(str::trim) .filter(|file| !file.is_empty()) else { return Vec::new(); }; if !Path::new(file).is_file() { log::warn!("Camera source {file} is missing; the claimed camera serves dark frames"); return Vec::new(); } let mut switches = vec![format!("--wayfern-camera-file={file}")]; if let Some(crop) = config .camera_crop .as_deref() .map(str::trim) .filter(|crop| !crop.is_empty()) { if valid_camera_crop(crop) { switches.push(format!("--wayfern-camera-crop={crop}")); } else { log::warn!( "Camera crop {crop:?} is not x,y,width,height in source pixels; using the whole frame" ); } } switches } /// `x,y,width,height`, all non-negative integers, width and height non-zero. fn valid_camera_crop(crop: &str) -> bool { let parts: Vec<&str> = crop.split(',').map(str::trim).collect(); if parts.len() != 4 { return false; } let Ok(values) = parts .iter() .map(|part| part.parse::()) .collect::, _>>() else { return false; }; values[2] > 0 && values[3] > 0 } /// The platform directory a component-updater CDM install uses, and the /// library name inside it. `None` on a platform Widevine does not ship for. fn widevine_platform() -> Option<(&'static str, &'static str)> { let arch = match std::env::consts::ARCH { "x86_64" => "x64", "aarch64" => "arm64", _ => return None, }; let (os, library) = match std::env::consts::OS { "macos" => ("mac", "libwidevinecdm.dylib"), "windows" => ("win", "widevinecdm.dll"), "linux" => ("linux", "libwidevinecdm.so"), _ => return None, }; Some((Box::leak(format!("{os}_{arch}").into_boxed_str()), library)) } /// The Widevine switch for a launch, when a CDM has been provisioned into /// `/WidevineCdm` in the component-updater layout. /// /// Donut does not download the CDM: its distribution is a licensing decision. /// What this does is use one that is present, and say so when one is present /// but unusable — the browser registers nothing from a broken directory, and a /// silent fallback to the bundled path would hide that. pub fn widevine_switch(version: &str, data_root: &Path) -> Option { if !supports_wayfern_152(version) { return None; } let dir = data_root.join("WidevineCdm"); if !dir.join("manifest.json").is_file() { return None; } match widevine_platform() { Some((platform, library)) => { let payload = dir.join("_platform_specific").join(platform).join(library); if !payload.is_file() { log::warn!( "Widevine is provisioned at {} but {} is missing; the browser will register no CDM", dir.display(), payload.display() ); } } None => log::warn!( "Widevine does not ship for this platform; the CDM directory is passed as provisioned" ), } Some(format!("--wayfern-widevine-cdm-dir={}", dir.display())) } /// The last lines the browser wrote about Wayfern itself. /// /// A refusal of the launch identity is logged by the browser and never fatal /// to it (it keeps the device it would have used anyway), so the launcher has /// to read the verdict off stderr to turn it into an error the user sees. #[derive(Clone, Default)] pub struct BrowserLogTap(Arc>>); impl BrowserLogTap { const CAPACITY: usize = 32; pub fn push(&self, line: String) { let mut lines = self.0.lock().unwrap_or_else(|e| e.into_inner()); if lines.len() >= Self::CAPACITY { lines.pop_front(); } lines.push_back(line); } pub fn lines(&self) -> Vec { self .0 .lock() .unwrap_or_else(|e| e.into_inner()) .iter() .cloned() .collect() } /// The reason of the most recent launch-identity refusal, if any. pub fn identity_refusal(&self) -> Option { self.lines().iter().rev().find_map(|line| { line .split_once("Wayfern launch identity refused: ") .map(|(_, reason)| reason.trim().to_string()) }) } pub fn dropped_overrides(&self) -> bool { self .lines() .iter() .any(|line| line.contains("Wayfern launch identity: dropping")) } /// Whether the browser reported the launch identity as applied. pub fn identity_applied(&self) -> bool { self .lines() .iter() .any(|line| line.contains("Wayfern launch identity applied")) } pub fn has_identity_verdict(&self) -> bool { self.identity_applied() || self.identity_refusal().is_some() } } /// Keep reading the browser's stderr for its lifetime, keeping only what it /// says about Wayfern. Reading it all is what keeps the pipe from filling. fn tap_browser_stderr( stderr: tokio::process::ChildStderr, tap: BrowserLogTap, profile_name: String, ) { tauri::async_runtime::spawn(async move { use tokio::io::{AsyncBufReadExt, BufReader}; let mut lines = BufReader::new(stderr).lines(); while let Ok(Some(line)) = lines.next_line().await { if line.contains("Wayfern") || line.contains("wayfern_") { log::info!( "[browser {profile_name}] {}", crate::log_redaction::text(&line) ); tap.push(line); } } }); } pub(crate) struct HeadlessWayfern { child: tokio::process::Child, user_data_dir: PathBuf, page_ws_url: String, } impl HeadlessWayfern { pub(crate) async fn start( manager: &WayfernManager, profile: &BrowserProfile, token: Option<&str>, label: &str, ) -> Result> { let executable_path = BrowserRunner::instance() .get_browser_executable_path(profile) .map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?; let port = WayfernManager::find_free_port().await?; log::info!("Launching headless Wayfern on port {port} for {label}"); let user_data_dir = std::env::temp_dir().join(format!("wayfern_fingerprint_{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&user_data_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={}", user_data_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") .arg("--enable-logging=stderr") .arg("--log-level=0"); if let Some(switch) = entitlement_cache_switch(&profile.version, &crate::app_dirs::cache_dir()) { cmd.arg(switch); } #[cfg(target_os = "linux")] cmd .arg("--no-sandbox") .arg("--disable-setuid-sandbox") .arg("--disable-dev-shm-usage"); cmd .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()); if let Some(token) = token { cmd.env("WAYFERN_TOKEN", token); } let mut child = match cmd.spawn() { Ok(child) => child, Err(e) => { let _ = std::fs::remove_dir_all(&user_data_dir); 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 { "" }; return Err(format!("Failed to spawn headless Wayfern: {e}{hint}").into()); } }; let log = BrowserLogTap::default(); if let Some(stderr) = child.stderr.take() { tap_browser_stderr(stderr, log.clone(), label.to_string()); } let mut session = Self { child, user_data_dir, page_ws_url: String::new(), }; if let Err(e) = manager .wait_for_cdp_ready_while_running(port, &mut session.child, &log) .await { session.stop().await; return Err(e); } // The debugging endpoint answers as soon as the process is up, which is // BEFORE the first window exists, so the target list is legitimately empty // for a moment and a single read races it. Poll until a page target // appears, and stop early if the process dies so a refused start is // reported as its own reason rather than as a timeout. let deadline = std::time::Instant::now() + Duration::from_secs(30); loop { if let Ok(Some(status)) = session.child.try_wait() { tokio::time::sleep(Duration::from_millis(300)).await; let said = log.lines().join("\n"); session.stop().await; return Err(early_exit_error(&said, &status).into()); } let detail = match manager.get_cdp_targets(port).await { Ok(targets) => { if let Some(url) = targets .into_iter() .find(|t| t.target_type == "page") .and_then(|t| t.websocket_debugger_url) { session.page_ws_url = url; return Ok(session); } "the browser reported no page target".to_string() } Err(e) => e.to_string(), }; if std::time::Instant::now() >= deadline { session.stop().await; return Err(format!("No page target found for CDP: {detail}").into()); } tokio::time::sleep(Duration::from_millis(100)).await; } } pub(crate) fn page_ws_url(&self) -> &str { &self.page_ws_url } pub(crate) async fn call( &self, manager: &WayfernManager, method: &str, params: serde_json::Value, ) -> Result> { manager .send_cdp_command(&self.page_ws_url, method, params) .await } pub(crate) async fn stop(mut self) { if let Some(pid) = self.child.id() { kill_browser_process(pid); if tokio::time::timeout(Duration::from_secs(5), self.child.wait()) .await .is_err() { let _ = self.child.start_kill(); let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await; } } let _ = std::fs::remove_dir_all(&self.user_data_dir); } } /// How a browser process ended up stopping. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StopOutcome { /// It shut itself down after `Browser.close`, so its session files are /// complete and the next launch can restore them. Closed, /// It exited on a termination request. Terminated, /// It had to be killed outright. Killed, /// Nothing this function did stopped it. StillRunning, } impl std::fmt::Display for StopOutcome { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Self::Closed => "closed cleanly", Self::Terminated => "terminated", Self::Killed => "killed", Self::StillRunning => "still running", }) } } /// Ask a process to exit: SIGTERM, which Chromium handles as a normal /// shutdown, or `taskkill` without `/F`, which only reaches a process with a /// window. The caller escalates when this is not enough. fn terminate_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()]) .creation_flags(CREATE_NO_WINDOW) .output(); } } /// End a process without asking. fn force_kill_process(pid: u32) { #[cfg(unix)] { use nix::sys::signal::{kill, Signal}; use nix::unistd::Pid; let _ = kill(Pid::from_raw(pid as i32), Signal::SIGKILL); } #[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(); } } /// Wait up to `limit` for `pid` to leave the process table. async fn wait_for_exit(pid: u32, limit: Duration) -> bool { let started = std::time::Instant::now(); loop { if !crate::proxy_storage::is_process_running(pid) { return true; } if started.elapsed() >= limit { return false; } tokio::time::sleep(Duration::from_millis(100)).await; } } /// 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", ]; /// How the geolocation probe reaches this profile's exit. /// /// Every variant either genuinely carries the traffic or refuses. There is no /// "try it and see" arm on purpose: a probe that does not cross the profile's /// upstream resolves THIS MACHINE'S address, and its location was then written /// into the fingerprint as the exit's. #[derive(Debug, Clone, PartialEq, Eq)] enum ProbeRoute { /// `reqwest` proxies this scheme itself. Carries the rewritten URL, so /// `httpstls` is already the `https` reqwest understands. Reqwest(String), /// A temporary local `donut-proxy` worker dials this upstream. Carries the /// URL the WORKER should dial, which is not always the stored one. Worker(String), /// The upstream is VLESS, which no `donut-proxy` worker can speak. An /// Xray-core sidecar carries the VLESS hop and a `donut-proxy` worker fronts /// its loopback SOCKS5 endpoint, the same two-stage path `browser_runner` /// builds for a VLESS launch. Carries the VLESS URI. Xray(String), /// Nothing available here carries this upstream. The probe is skipped and /// the fingerprint keeps no location at all, which is the only honest /// outcome: a location that is neither the user's nor the exit's is worse /// than none. Unroutable, } /// A probe transport built for one fingerprint generation, plus the temporary /// workers that have to be stopped once the probe is done. #[derive(Default)] struct ProbeTransport { /// The proxy URL to hand `reqwest`, or `None` when nothing could carry the /// probe and it must be skipped rather than sent unproxied. proxy: Option, donut_worker_id: Option, xray_worker_id: Option, } impl ProbeTransport { /// Stop everything this transport started. The `donut-proxy` worker goes /// first because it is the one holding connections open through the Xray /// sidecar behind it. async fn shutdown(self) { if let Some(id) = self.donut_worker_id { let _ = crate::proxy_runner::stop_proxy_process(&id).await; } if let Some(id) = self.xray_worker_id { let _ = crate::xray_worker_runner::stop_xray_worker(&id).await; } } } /// A freshly generated device, plus its identity handle when the browser /// supports identities. pub struct GeneratedFingerprint { /// The device the browser produced, as a flat camelCase JSON object. For a /// LEGACY browser this is what `WayfernConfig::fingerprint` stores. For an /// identity-backed profile it is a VIEW for the caller to show once and /// discard: only `identity_id` and `location` are persisted. pub fingerprint: String, pub identity_id: Option, /// `WayfernConfig::location` for the exit this device was generated /// against, or `None` when no location field was resolved. pub location: Option, /// 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, #[serde(alias = "profile_path")] pub profilePath: Option, pub url: Option, pub cdp_port: Option, } struct WayfernInstance { id: String, process_id: Option, profile_path: Option, url: Option, cdp_port: Option, /// What the browser said about Wayfern on stderr, for diagnostics. Read /// through `browser_log_lines`, which the WebRTC and persona launch checks /// consult; nothing else needs it yet. #[allow(dead_code)] log_tap: BrowserLogTap, } struct WayfernManagerInner { instances: HashMap, } pub struct WayfernManager { inner: Arc>, http_client: Client, } #[derive(Debug, Deserialize)] struct CdpTarget { #[serde(rename = "type")] target_type: String, #[serde(rename = "webSocketDebuggerUrl")] websocket_debugger_url: Option, } 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() } pub(crate) async fn find_free_port() -> Result> { 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 obj = Self::fingerprint_object(fingerprint_json)?; // 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 { let v = obj.get(key)?; v.as_u64() .or_else(|| v.as_str().and_then(|s| s.trim().parse::().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")) } /// The fingerprint value a stored `WayfernConfig::fingerprint` string holds: /// the object itself, or the one nested in the legacy /// `{ "fingerprint": {...} }` wrapper some old profiles carry. /// /// The single place that shape is resolved. Everything that reads a stored /// fingerprint goes through this or through [`Self::fingerprint_object`], so /// no two readers can end up disagreeing about which shapes count. fn unwrap_stored_fingerprint(stored: &serde_json::Value) -> &serde_json::Value { stored.get("fingerprint").unwrap_or(stored) } /// Parse a stored fingerprint JSON into its object, tolerating the legacy /// `{ "fingerprint": {...} }` wrapper some old profiles carry. /// /// Shared with `fingerprint_consistency`, which reads the timezone and /// language it compares against the measured exit through this exact /// accessor. Two readers with their own idea of the stored shape is how the /// gate came to report "this profile declares no timezone" for a wrapped /// fingerprint whose launch presented the timezone nested one level down. pub fn fingerprint_object( fingerprint_json: &str, ) -> Option> { let parsed: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?; Self::unwrap_stored_fingerprint(&parsed) .as_object() .cloned() } /// The device this launch hands the browser, derived from what the profile /// stores. Pure, and the only place that payload is built, so what the /// browser is actually given can be asserted without one running. /// /// It never invents a field. A stored fingerprint that declares no timezone /// produces a payload with no timezone, and the engine keeps whatever it /// reports natively. The launcher used to insert `America/New_York` and /// offset 300 here, which put a US clock behind whatever exit the profile /// routed through, while `fingerprint_consistency`, reading the same stored /// fingerprint, told the user its timezone had never been compared. That is /// the one combination that must never happen: the app cannot claim it /// compared nothing while shipping a location it made up. pub(crate) fn launch_fingerprint_payload( fingerprint_json: &str, ) -> Result { let stored: serde_json::Value = serde_json::from_str(fingerprint_json) .map_err(|e| format!("Failed to parse stored fingerprint JSON: {e}"))?; // Denormalize for Wayfern CDP (arrays/objects travel as JSON strings). let mut payload = Self::denormalize_fingerprint(Self::unwrap_stored_fingerprint(&stored).clone()); // Normalize languages: a comma-separated string becomes the array the // browser expects. if let Some(obj) = payload.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)); } } Ok(payload) } /// A stored JSON object field (`identity_overrides`, `location`), or an /// empty map when absent or unparsable. pub fn stored_object(json: Option<&str>) -> serde_json::Map { json.and_then(Self::fingerprint_object).unwrap_or_default() } /// The exit-derived location fields a device object carries, in the shape /// `WayfernConfig::location` stores; `None` when it carries none. pub fn location_of(device: &serde_json::Map) -> Option { let mut location = serde_json::Map::new(); for key in LOCALE_CARRY_OVER_KEYS { if let Some(value) = device.get(key) { if !value.is_null() { location.insert(key.to_string(), value.clone()); } } } if location.is_empty() { None } else { serde_json::to_string(&location).ok() } } /// Overrides from a WHOLE fingerprint an API or MCP caller supplied for an /// identity-backed profile: every field it names is taken as an explicit /// edit, except the provenance keys the browser refuses and the location /// keys, which travel through `location`. pub fn overrides_from_explicit_fingerprint( fingerprint: &serde_json::Map, ) -> serde_json::Map { let mut overrides = serde_json::Map::new(); for (key, value) in fingerprint { if DERIVED_PROVENANCE_KEYS.contains(&key.as_str()) || GEO_PARAM_KEYS.contains(&key.as_str()) || LOCALE_CARRY_OVER_KEYS.contains(&key.as_str()) || value.is_null() { continue; } overrides.insert(key.clone(), value.clone()); } overrides } /// 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, baseline: &serde_json::Map, ) -> serde_json::Map { 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, ) -> serde_json::Map { 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 { use chrono::Offset; let tz = timezone.parse::().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 `setIdentity` geolocation parameters carried by a stored fingerprint. fn geo_params( fingerprint: &serde_json::Map, ) -> serde_json::Map { 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 } /// 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. pub(crate) 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. /// /// Matches the browser's own platform-to-OS mapping, 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>, ) -> 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. fn apply_failure_error(detail: &str, claimed_os: Option<&str>) -> String { wayfern_failure(detail, "WAYFERN_FINGERPRINT_APPLY_FAILED", claimed_os) } /// The document a 152 browser takes through `--wayfern-identity-file`, or /// `None` when this profile cannot be described that way: a legacy device /// payload (only `setFingerprint` reproduces one), no identity, no claimed /// operating system, or no timezone. The browser requires the timezone /// because it does not resolve the exit itself; donut holds the proxy and /// resolved it when the location was written. pub fn launch_identity_document(config: &WayfernConfig) -> Option { if config.fingerprint.is_some() { return None; } let identity_id = config .identity_id .as_deref() .map(str::trim) .filter(|id| !id.is_empty())?; // The browser requires the operating system in the document. Over CDP an // omitted `operatingSystem` means the host, so the document says so // explicitly, and the profile's own claim wins when it has one. let host_os = crate::profile::types::get_host_os(); let os = Self::claimed_operating_system(config, None).unwrap_or(host_os.as_str()); let location = Self::stored_object(config.location.as_deref()); let geo = Self::geo_params(&location); let timezone = geo .get("timezone") .and_then(|v| v.as_str()) .filter(|tz| !tz.is_empty())?; let mut document = serde_json::Map::new(); document.insert("identityId".to_string(), json!(identity_id)); document.insert("operatingSystem".to_string(), json!(os)); document.insert("timezone".to_string(), json!(timezone)); if let Some(language) = geo .get("language") .and_then(|v| v.as_str()) .filter(|l| !l.is_empty()) { document.insert("language".to_string(), json!(language)); } if let (Some(latitude), Some(longitude)) = ( geo.get("latitude").and_then(|v| v.as_f64()), geo.get("longitude").and_then(|v| v.as_f64()), ) { document.insert("latitude".to_string(), json!(latitude)); document.insert("longitude".to_string(), json!(longitude)); } let overrides = Self::stored_object(config.identity_overrides.as_deref()); if !overrides.is_empty() { document.insert( "overrides".to_string(), serde_json::Value::Object(overrides), ); } Some(serde_json::Value::Object(document)) } /// Write the launch identity into the profile directory and return the /// absolute path the browser is given. Private to the user on Unix: the /// document names the identity and the user's overrides. fn write_launch_identity( profile_path: &str, document: &serde_json::Value, ) -> Result { let dir = PathBuf::from(profile_path); std::fs::create_dir_all(&dir) .map_err(|e| format!("could not create the profile directory for the identity file: {e}"))?; let path = dir.join(LAUNCH_IDENTITY_FILE); let body = serde_json::to_vec(document) .map_err(|e| format!("could not encode the identity document: {e}"))?; #[cfg(unix)] { use std::io::Write; use std::os::unix::fs::OpenOptionsExt; let mut file = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(&path) .map_err(|e| format!("could not write the identity file: {e}"))?; file .write_all(&body) .map_err(|e| format!("could not write the identity file: {e}"))?; } #[cfg(not(unix))] { std::fs::write(&path, &body) .map_err(|e| format!("could not write the identity file: {e}"))?; } let path = path.canonicalize().unwrap_or(path); Ok(path) } /// Whether the browser started on the identity the launcher handed it. /// /// The browser's own stderr verdict is authoritative when present: a /// refusal names its reason, an "applied" line settles it. Without one, the /// identity id the browser reports decides, and as a last resort the /// timezone, language and platform of the running device are compared with /// the document. fn launch_identity_verdict( document: &serde_json::Value, observed: Option<&serde_json::Value>, cdp_error: Option<&str>, tap: &BrowserLogTap, ) -> Result<&'static str, String> { if let Some(reason) = tap.identity_refusal() { return Err(reason); } let expected_id = document["identityId"].as_str().unwrap_or_default(); let observed_id = observed.and_then(|o| o["identityId"].as_str()); if !expected_id.is_empty() && observed_id == Some(expected_id) { return Ok("the browser reports the identity"); } if tap.identity_applied() { return Ok("the browser logged the identity as applied"); } let Some(observed) = observed else { return Err(match cdp_error { Some(error) => format!("Wayfern.getIdentity failed: {error}"), None => "the browser exposed no page target to verify the identity on".to_string(), }); }; let identity = &observed["identity"]; let same = |key: &str| identity[key].as_str() == document[key].as_str(); let platform = identity["platform"].as_str().unwrap_or_default(); let os_matches = Self::os_from_platform(platform) == document["operatingSystem"].as_str(); if same("timezone") && (document.get("language").is_none() || same("language")) && os_matches { return Ok("the running device matches the document's timezone, language and platform"); } Err(format!( "the browser reports identity {} (timezone {}, language {}, platform {}) instead of {expected_id} ({}, {}, {})", observed_id.unwrap_or("none"), identity["timezone"].as_str().unwrap_or("unknown"), identity["language"].as_str().unwrap_or("unknown"), if platform.is_empty() { "unknown" } else { platform }, document["timezone"].as_str().unwrap_or("unknown"), document["language"].as_str().unwrap_or("any"), document["operatingSystem"].as_str().unwrap_or("unknown"), )) } async fn wait_for_cdp_ready_while_running( &self, port: u16, child: &mut tokio::process::Child, log: &BrowserLogTap, ) -> Result<(), Box> { let url = format!("http://127.0.0.1:{port}/json/version"); let max_attempts = 120; let delay = Duration::from_millis(500); let mut last_error: Option = None; for attempt in 0..max_attempts { if let Ok(Some(status)) = child.try_wait() { tokio::time::sleep(Duration::from_millis(300)).await; let said = log.lines().join("\n"); log::error!("Wayfern exited on port {port} before CDP was ready ({status})"); return Err(early_exit_error(&said, &status).into()); } 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::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, Box> { let url = format!("http://127.0.0.1:{port}/json"); let resp = self.http_client.get(&url).send().await?; let targets: Vec = resp.json().await?; Ok(targets) } pub(crate) async fn send_cdp_command( &self, ws_url: &str, method: &str, params: serde_json::Value, ) -> Result> { 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) => { // NOTHING is written here, deliberately. A failed probe used to fill in // America/New_York and offset 300, which made "we could not resolve the // exit" indistinguishable from "the exit is in New York": the profile // then presented a US location as its proxy's, in the one field the // consistency gate and the user both read as authoritative. A location // that is neither the user's nor the exit's is worse than no location, // so the fields are left ungenerated. // // Returning false is what makes that recoverable: the caller must not // stamp `geo_proxy_signature`, so the launch-time refresh sees a // signature mismatch and probes again through the local worker the // browser is about to use. log::warn!("Geolocation failed; leaving the fingerprint's location ungenerated: {e}"); 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 { 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. /// Upstreams the geolocation probe must reach through a local donut-proxy /// worker instead of handing to `reqwest`. /// /// Two groups. Remote SOCKS, which reqwest could proxy but which this code has /// always routed through a worker. And EVERY scheme reqwest cannot proxy, /// whatever its host, that group is the dangerous one: `Proxy::all` ACCEPTS /// such a URL, then matches nothing, so the probe went out from the user's /// REAL address and its geolocation was written into the profile fingerprint. /// Nothing failed and nothing was logged: exactly the exit-vs-fingerprint /// mismatch `fingerprint_consistency.rs` exists to catch, manufactured by the /// fingerprint generator itself. And `probe_url` skips `ss`/`vless` entirely, /// so the launch-time gate cannot catch it either. /// /// The loopback exemption applies ONLY to schemes reqwest can proxy. A /// loopback SOCKS upstream IS the local worker, so it needs no second one; a /// loopback `ss://127.0.0.1:8388` is still a scheme reqwest discards, and /// exempting it re-opened the whole leak. /// /// True here means only "reqwest must not be handed this". It does NOT mean a /// `donut-proxy` worker can carry it, reading it that way is what sent /// `vless://` to a worker that cannot speak VLESS, so the probe could never /// succeed. `worker_upstream_url` answers what a worker can actually dial, /// and `probe_route` puts the two together. fn needs_local_worker_for_probe(url: &str) -> bool { // Measured on the REWRITTEN url, because `httpstls://` becomes `https://` // before reqwest ever sees it and is proxyable from that point on. let rewritten = crate::proxy_storage::reqwest_upstream_url(url); let scheme = rewritten .split("://") .next() .unwrap_or_default() .to_ascii_lowercase(); if !crate::proxy_storage::reqwest_can_proxy(&rewritten) { // No host exemption here: reqwest discards it wherever it points. return true; } scheme.starts_with("socks") && url::Url::parse(&rewritten) .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::() .map(|ip| !ip.is_loopback()) .unwrap_or(true), ), None => None, }) .unwrap_or(false) } /// The URL a `donut-proxy` worker can actually dial for this upstream, or /// `None` when no worker can carry it at all. /// /// The allow-list is the exact set `proxy_server::dial_upstream` matches on. /// Everything outside it reaches that function's `_` arm, so every request /// through the worker dies as "Unsupported upstream proxy scheme", a worker /// started on such a URL is not a fallback, it is a guaranteed failure. That /// is how `vless://` came to be routed here: `needs_local_worker_for_probe` /// answers "reqwest cannot proxy this", which was read as "a worker can", and /// the probe could then never succeed. VLESS is carried by an Xray-core /// sidecar instead (see `probe_route`), and anything else honestly has no /// transport here. /// /// `socks5h`/`socks4a` are the "resolve at the exit" spellings of `socks5`/ /// `socks4`. The worker hands the target hostname to the SOCKS server rather /// than resolving it locally, which is exactly what the `h` asks for, so they /// are normalized to the spelling the worker matches instead of rejected. fn worker_upstream_url(url: &str) -> Option { let (scheme, rest) = url.split_once("://")?; let scheme = scheme.to_ascii_lowercase(); match scheme.as_str() { "http" | "https" | "httpstls" | "socks4" | "socks5" | "ss" | "shadowsocks" => { Some(format!("{scheme}://{rest}")) } "socks5h" => Some(format!("socks5://{rest}")), "socks4a" => Some(format!("socks4://{rest}")), _ => None, } } /// Decide how the geolocation probe for `url` reaches the exit. /// /// Preference order, and the reason for it: probe through something that /// genuinely carries the traffic, or do not probe at all. Every arm that /// cannot carry it resolves to `Unroutable`, which the caller turns into a /// skipped probe and an ungenerated location, never into a probe sent from /// this machine's own address, and never into a default location presented as /// the exit's. fn probe_route(url: &str) -> ProbeRoute { if !Self::needs_local_worker_for_probe(url) { let rewritten = crate::proxy_storage::reqwest_upstream_url(url); // Checked rather than assumed. `needs_local_worker_for_probe` already // returns true for everything reqwest cannot proxy, but handing reqwest a // URL it cannot match makes it send the request DIRECT with no error and // no log line, so the invariant is re-asserted where it matters. return if crate::proxy_storage::reqwest_can_proxy(&rewritten) { ProbeRoute::Reqwest(rewritten) } else { ProbeRoute::Unroutable }; } // The worker gets the STORED url, never the reqwest rewrite: to // `donut-proxy`, `httpstls` means "TLS to the proxy, then CONNECT" while // `https` means a plaintext CONNECT, so handing it the reqwest spelling // would silently downgrade that hop. if let Some(upstream) = Self::worker_upstream_url(url) { return ProbeRoute::Worker(upstream); } if url .split_once("://") .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("vless")) { return match crate::xray::parse_vless_uri(url) { Ok(_) => ProbeRoute::Xray(url.to_string()), // A `vless://host:port` with no id, flow or security parameters is what // a stored VLESS proxy collapses to when it is rendered as // `type://host:port`. Xray cannot dial that, so there is nothing to // probe through and the launch-time refresh does the location instead - // by then the upstream is the loopback SOCKS5 endpoint of a real Xray // worker, which any transport here can carry. Err(_) => ProbeRoute::Unroutable, }; } ProbeRoute::Unroutable } /// Whether the probe has to be skipped outright. /// /// True when the profile routes its traffic somewhere, the probe would /// actually leave this machine, and no transport was built to carry it. /// Probing anyway resolves this machine's own address and writes its location /// into the fingerprint as the exit's. /// /// `probe_leaves_this_machine` is false when the location comes from a pinned /// geoip IP or geolocation is switched off. `apply_geolocation` never touches /// the network through the proxy in either case, so a routed profile with a /// pinned IP must still get its location. fn must_skip_probe( routes_traffic: bool, probe_leaves_this_machine: bool, have_probe_proxy: bool, ) -> bool { routes_traffic && probe_leaves_this_machine && !have_probe_proxy } /// Build the transport the geolocation probe for `url` will use. Callers must /// `shutdown()` the result once the probe is done, on every path. async fn build_probe_transport(url: &str) -> ProbeTransport { match Self::probe_route(url) { ProbeRoute::Reqwest(proxy) => ProbeTransport { proxy: Some(proxy), ..Default::default() }, ProbeRoute::Worker(upstream) => Self::front_with_local_worker(upstream, None).await, ProbeRoute::Xray(uri) => { // The error is flattened to a String on the spot. `start_xray_worker` // reports a bare `Box`, which is not `Send`, and holding one // across the `front_with_local_worker` await below would make this // whole future non-`Send`, and with it every Tauri command and spawned // task that reaches fingerprint generation. let started = crate::xray_worker_runner::start_xray_worker(None, &uri) .await .map_err(|error| error.to_string()); match started { Ok(worker) => { let upstream = crate::proxy_manager::ProxyManager::build_proxy_url(&worker.local_proxy_settings()); Self::front_with_local_worker(upstream, Some(worker.id)).await } Err(e) => { log::warn!( "Could not start an Xray-core worker to carry the VLESS geolocation probe ({e}); skipping the probe rather than sending it unproxied" ); ProbeTransport::default() } } } ProbeRoute::Unroutable => { log::warn!( "No transport here can carry the geolocation probe through this profile's upstream; skipping the probe rather than resolving this machine's own address" ); ProbeTransport::default() } } } /// Put a temporary local `donut-proxy` worker in front of `upstream`, the /// same path the browser itself uses. `xray_worker_id` is threaded through so /// a sidecar started for a VLESS upstream is still stopped when the worker in /// front of it fails to start. async fn front_with_local_worker( upstream: String, xray_worker_id: Option, ) -> ProbeTransport { match crate::proxy_runner::start_proxy_process(Some(upstream), None).await { Ok(worker) => ProbeTransport { proxy: Some(format!( "http://127.0.0.1:{}", worker.local_port.unwrap_or(0) )), donut_worker_id: Some(worker.id), xray_worker_id, }, Err(e) => { // NOT the raw upstream. reqwest silently ignores a proxy URL it cannot // match, so handing it back here sent the probe from this machine's // real address. `None` means "no proxied probe available", and the // caller skips the probe entirely rather than making it unproxied. log::warn!( "Could not start local proxy worker for geolocation ({e}); skipping the probe rather than sending it unproxied" ); ProbeTransport { proxy: None, donut_worker_id: None, xray_worker_id, } } } } /// 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> { let session = HeadlessWayfern::start( self, profile, None, &format!("generation for {}", profile.name), ) .await?; let ws_url = session.page_ws_url().to_string(); 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_wayfern_152(&profile.version); // No geolocation override is passed here. Donut resolves the exit's // location itself, below, through the profile's own proxy, because the // browser cannot resolve it through an authenticated upstream. 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, 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); // Build a transport that genuinely carries the probe through this // profile's upstream, or none at all. `probe_route` decides which: // reqwest where it can proxy the scheme itself, a temporary local // donut-proxy worker for the schemes that worker speaks (including // every remote SOCKS one, because reqwest's SOCKS connector corrupts // its parse buffer when a proxy splits a handshake reply across TCP // segments), an Xray-core sidecar behind such a worker for VLESS, and // nothing for an upstream none of them can speak. // // No transport is built when the probe would not leave this machine // anyway: `apply_geolocation` ignores `proxy` entirely when geolocation // is off or the location comes from a pinned geoip IP. let probe_leaves_this_machine = !matches!( config.geoip.as_ref(), Some(serde_json::Value::Bool(false)) | Some(serde_json::Value::String(_)) ); let transport = match config.proxy.as_deref() { Some(url) if probe_leaves_this_machine => Self::build_probe_transport(url).await, _ => ProbeTransport::default(), }; // Apply timezone/geolocation for the proxy this fingerprint is being // generated against. Shared with the launch-time location refresh. // A profile that ROUTES ITS TRAFFIC must never probe from the real // address: `apply_geolocation` treats `None` as "no proxy configured", // which is right for a direct profile and an IP leak for a routed one. // // `config.proxy.is_some()` alone was not that predicate. A WireGuard // profile carries its route in `vpn_id` and reaches here with // `config.proxy == None`, so the guard never fired and the host's own // timezone, latitude/longitude and language were written into the // fingerprint as authoritative. let routes_traffic = config.proxy.is_some() || profile.vpn_id.is_some(); let geolocation_applied = if Self::must_skip_probe( routes_traffic, probe_leaves_this_machine, transport.proxy.is_some(), ) { log::warn!( "Skipping the geolocation probe: this profile has an upstream but no proxied probe could be built, and probing directly would write this machine's own location into the fingerprint" ); false } else { Self::apply_geolocation( &mut normalized, transport.proxy.as_deref(), config.geoip.as_ref(), ) .await }; transport.shutdown().await; (normalized, identity_id, geolocation_applied) } Err(e) => { session.stop().await; let what = if use_identity_api { "create identity" } else { "get fingerprint" }; let detail = e.to_string(); let coded = wayfern_failure( &detail, "WAYFERN_FINGERPRINT_GENERATION_FAILED", config.os.as_deref(), ); log::error!( "Wayfern refused to {what} for profile {}: {} (browser said: {})", crate::log_redaction::Plain(&profile.name), coded, cdp_error_message(&detail) ); return Err(coded.into()); } }; session.stop().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::>()) ); // 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 { location: fingerprint.as_object().and_then(Self::location_of), fingerprint: fingerprint_json, identity_id, 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, headless: bool, kind: LaunchKind, ) -> Result> { 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 = 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(), "--hide-crash-restore-bubble".to_string(), // Release builds log nothing unless asked. Wayfern reports what it did // with the launch identity, WebRTC and the rest on stderr, and that is // the only channel that carries a refusal's reason. "--enable-logging=stderr".to_string(), "--log-level=0".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) { let work_area = host_work_area(_app_handle); let (fit_w, fit_h) = fit_window_to_work_area((w, h), work_area.map(|area| area.size)); let (x, y) = work_area.map_or((0, 0), |area| area.origin); if (fit_w, fit_h) == (w, h) { log::info!("Sizing Wayfern window to fingerprint dimensions: {w}x{h}"); } else { log::info!( "Sizing Wayfern window to {fit_w}x{fit_h}: the profile describes a {w}x{h} window, which does not fit this display's usable area" ); } args.push(format!("--window-size={fit_w},{fit_h}")); args.push(format!("--window-position={x},{y}")); } #[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. The browser reads these switches and uses // them for the window title (label) and the frame colour. 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, 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 against the host OS, so `android` // and `ios` count on every desktop. Every browser version gates it the same // way; 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 wayfern_token.is_none() && !Self::stored_object(config.identity_overrides.as_deref()).is_empty() && crate::cloud_auth::CLOUD_AUTH .is_entitled_to_wayfern_token() .await { log::error!( "Refusing to launch profile {}: it stores fingerprint edits and no Wayfern token is available yet", profile.name ); return Err(crate::backend_error("WAYFERN_PLAN_CHECK_UNAVAILABLE").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()); } // A 152 browser takes the identity on its command line and commits it // before the first navigation, which is what lets a restored tab load on // the profile's device rather than the host's. Older browsers, legacy // device payloads and a profile whose location carries no timezone keep // the post-launch CDP apply below. let launch_identity = if supports_wayfern_152(&profile.version) { Self::launch_identity_document(config) } else { None }; if launch_identity.is_none() && supports_wayfern_152(&profile.version) && config.identity_id.is_some() && config.fingerprint.is_none() { log::warn!( "Profile {} has an identity but no resolved timezone (or no claimed operating system); applying it over CDP after launch instead of at startup", profile.name ); } let identity_file = match &launch_identity { Some(document) => Some( Self::write_launch_identity(profile_path, document) .map_err(|e| crate::backend_error_with_detail("WAYFERN_IDENTITY_REFUSED", e))?, ), None => None, }; let identity_at_launch = identity_file.is_some() || (config.identity_id.is_none() && config.fingerprint.is_none()); let restore_session = match session_restore_verdict( config, kind, headless, ephemeral, profile.clear_on_close, identity_at_launch, ) { Ok(()) => true, Err(reason) => { log::info!( "Session restore is off for profile {}: {reason}", profile.name ); false } }; args.extend(session_switches(restore_session, identity_file.as_deref())); // WebRTC posture, plus the exit the launch gate measured for this route // (cache only: an automation launch never probes). A direct connection // has nothing cached and needs nothing: its real egress is already what // every HTTP request shows. let webrtc_mode = WebRtcMode::from_config(config); if let Some(unknown) = config .webrtc_mode .as_deref() .filter(|value| WebRtcMode::parse(value).is_none()) { log::warn!( "Profile {} names an unknown WebRTC mode {unknown:?}; launching with auto", profile.name ); } let exit_ip = crate::fingerprint_consistency::cached_exit_ip(profile); let webrtc = webrtc_switches(&profile.version, webrtc_mode, exit_ip.as_deref()); if !webrtc.is_empty() { log::info!( "WebRTC for profile {}: mode {}, exit IP {}", profile.name, webrtc_mode.switch_value(), exit_ip.as_deref().unwrap_or("unknown") ); } args.extend(webrtc); args.extend(entitlement_cache_switch( &profile.version, &crate::app_dirs::cache_dir(), )); // The persona is a property of the profile, so an identity-backed profile // seeds it from the identity and a legacy one from its id: either way the // same profile presents the same person on every launch. let persona_seed = config .identity_id .as_deref() .map(str::trim) .filter(|id| !id.is_empty()) .map(str::to_string) .unwrap_or_else(|| profile.id.to_string()); args.extend(persona_switch( &profile.version, profile_path, &persona_seed, config.persona.as_deref(), )); args.extend(camera_switches(&profile.version, config)); args.extend(widevine_switch( &profile.version, &crate::app_dirs::data_dir(), )); if let Some(path) = &identity_file { log::info!( "Launch identity for profile {} written to {}", profile.name, path.display() ); } let mut command = TokioCommand::new(&executable_path); command .args(&args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()); if let Some(ref token) = wayfern_token { command.env("WAYFERN_TOKEN", token); log::info!("Wayfern authorization configured for browser process"); } let mut child = command .spawn() .map_err(|e| -> Box { 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(); let log_tap = BrowserLogTap::default(); if let Some(stderr) = child.stderr.take() { tap_browser_stderr(stderr, log_tap.clone(), profile.name.clone()); } self .wait_for_cdp_ready_while_running(port, &mut child, &log_tap) .await?; drop(child); 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()); // 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. let identity_only = supports_identity_api(&profile.version) && config.identity_id.is_some() && config.fingerprint.is_none(); let identity_with_view = supports_identity_api(&profile.version) && !supports_wayfern_152(&profile.version) && config.identity_id.is_some() && config.fingerprint.is_some(); if let Some(document) = launch_identity.as_ref().filter(|_| identity_file.is_some()) { // The identity travelled on the command line. The browser never fails // its own launch over it (a refusal only logs), so the launcher checks, // and a browser running on the wrong device is closed rather than // handed to the user with the app still showing the profile's device. let mut observed: Option = None; let mut last_error: Option = None; for target in &page_targets { if let Some(ws_url) = &target.websocket_debugger_url { match self .send_cdp_command(ws_url, "Wayfern.getIdentity", json!({})) .await { Ok(result) => { observed = Some(result); break; } Err(e) => last_error = Some(e.to_string()), } } } // The browser wrote its verdict to stderr before it opened the // debugging port; the tap may still be a line behind the socket. if !log_tap.has_identity_verdict() { tokio::time::sleep(Duration::from_millis(300)).await; } match Self::launch_identity_verdict( document, observed.as_ref(), last_error.as_deref(), &log_tap, ) { Ok(confirmation) => { if log_tap.dropped_overrides() && crate::cloud_auth::CLOUD_AUTH .is_entitled_to_wayfern_token() .await { log::error!( "Killing Wayfern (pid {process_id:?}) for profile {}: the browser dropped this profile's fingerprint edits", profile.name ); if let Some(pid) = process_id { kill_browser_process(pid); } return Err(crate::backend_error("WAYFERN_PLAN_CHECK_UNAVAILABLE").into()); } log::info!( "Launch identity confirmed for profile {}: {confirmation}", profile.name ); // The only place an identity-backed profile's screen is known: the // device is derived by the browser, so nothing on disk carries it. if let Some(device) = observed .as_ref() .and_then(|value| value.get("identity")) .map(ToString::to_string) { Self::warn_on_screen_over_host(&device, profile, _app_handle); } } Err(reason) => { log::error!( "Killing Wayfern (pid {process_id:?}) for profile {}: the launch identity was not applied: {reason}", profile.name ); if let Some(pid) = process_id { kill_browser_process(pid); } return Err( wayfern_failure( &reason, "WAYFERN_IDENTITY_REFUSED", document["operatingSystem"].as_str(), ) .into(), ); } } } else if identity_only || identity_with_view { let identity_id = config.identity_id.clone().unwrap_or_default(); let (overrides, location) = if identity_with_view { let view = config .fingerprint .as_deref() .and_then(Self::fingerprint_object) .unwrap_or_default(); let baseline = Self::stored_object(config.identity_baseline.as_deref()); let mut overrides = Self::identity_overrides(&view, &baseline); overrides.extend(Self::stored_object(config.identity_overrides.as_deref())); (overrides, view) } else { ( Self::stored_object(config.identity_overrides.as_deref()), Self::stored_object(config.location.as_deref()), ) }; let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; let mut params = serde_json::Map::new(); params.insert("identityId".to_string(), json!(identity_id)); // The claimed OS travels explicitly as well as inside the id, because an // older browser cannot read an id minted by a newer one and would rebuild // the HOST OS instead. Every release lets the explicit parameter win, so // this keeps one stored profile portable across them. if let Some(os) = config .os .as_deref() .filter(|os| !os.is_empty() && !identity_with_view) { params.insert("operatingSystem".to_string(), json!(os)); } 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(&location)); 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::>() ); let mut applied_ok = false; let mut last_apply_error: Option = None; for target in &page_targets { if let Some(ws_url) = &target.websocket_debugger_url { match self .send_cdp_command( ws_url, "Wayfern.setIdentity", serde_json::Value::Object(params.clone()), ) .await { Ok(_) => { applied_ok = true; log::info!("Successfully applied identity to page target"); } Err(e) => { log::error!("Failed to apply identity to target: {e}"); last_apply_error = Some(e.to_string()); } } } } if !applied_ok { 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 identity 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, None)).into(), ); } } else if let Some(fingerprint_json) = &config.fingerprint { log::info!( "Applying fingerprint to Wayfern browser, fingerprint length: {} chars", fingerprint_json.len() ); Self::warn_on_screen_over_host(fingerprint_json, profile, _app_handle); // Both stored shapes, the bare object and the legacy // `{"fingerprint": {...}}` wrapper, are resolved by the same accessor the // consistency gate reads, and nothing is defaulted in. A profile that // declares no timezone is launched with none, which is exactly what the // gate reports to the user. let fingerprint_for_cdp = Self::launch_fingerprint_payload(fingerprint_json)?; log::info!( "Fingerprint prepared for CDP command, fields: {:?}", fingerprint_for_cdp .as_object() .map(|o| o.keys().collect::>()) ); // 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, for the diagnostic below. let stored = fingerprint_for_cdp.as_object().cloned().unwrap_or_default(); 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 // 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 = 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, "Wayfern.setFingerprint", apply_params.clone()) .await { Ok(_) => { applied_ok = true; log::info!("Successfully applied fingerprint to page target"); } 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 { if restore_session { // The tabs the browser reopened are the user's; the URL gets a tab of // its own instead of replacing whichever one came first. log::info!("Opening the launch URL in a new tab beside the restored session"); if let Err(e) = self.open_url_on_port(port, url).await { log::error!("Failed to open the launch URL in a new tab: {e}"); } } else { 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), log_tap, }; 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), }) } pub async fn stop_wayfern( &self, id: &str, ) -> Result<(), Box> { // Taken out of the map first, and the lock dropped: a clean shutdown can // take seconds, and nothing else should wait on it. let instance = { let mut inner = self.inner.lock().await; inner.instances.remove(id) }; if let Some(instance) = instance { log::info!("Cleaning up Wayfern instance {}", instance.id); if let Some(pid) = instance.process_id { let outcome = self.stop_browser_process(pid, instance.cdp_port).await; log::info!("Stopped Wayfern instance {id} (PID: {pid}): {outcome}"); } } Ok(()) } /// Stop a browser the way its session files need: ask it to close over /// CDP so Chromium runs its own shutdown (that is what writes the session /// and commits the cookie jar), then terminate, then kill. Each step gets a /// bounded wait, so a wedged browser still ends within seconds. pub async fn stop_browser_process(&self, pid: u32, cdp_port: Option) -> StopOutcome { if !crate::proxy_storage::is_process_running(pid) { return StopOutcome::Closed; } if let Some(port) = cdp_port { match tokio::time::timeout(Duration::from_secs(3), self.browser_close(port)).await { Ok(Ok(())) => {} Ok(Err(e)) => log::warn!("Browser.close was not accepted on port {port}: {e}"), Err(_) => log::warn!("Browser.close timed out on port {port}"), } if wait_for_exit(pid, Duration::from_secs(5)).await { return StopOutcome::Closed; } log::warn!("Wayfern (PID {pid}) did not exit after Browser.close; terminating it"); } terminate_process(pid); if wait_for_exit(pid, Duration::from_secs(5)).await { return StopOutcome::Terminated; } log::warn!("Wayfern (PID {pid}) ignored the termination request; killing it"); force_kill_process(pid); if wait_for_exit(pid, Duration::from_secs(2)).await { return StopOutcome::Killed; } StopOutcome::StillRunning } /// Send `Browser.close` on the browser endpoint. The browser answers with an /// empty result, or simply drops the socket on its way out; both mean it /// agreed, and the caller watches the process rather than the reply. async fn browser_close(&self, port: u16) -> Result<(), Box> { let version: serde_json::Value = self .http_client .get(format!("http://127.0.0.1:{port}/json/version")) .send() .await? .json() .await?; let ws_url = version["webSocketDebuggerUrl"] .as_str() .ok_or("the browser reported no webSocketDebuggerUrl")?; match self .send_cdp_command(ws_url, "Browser.close", json!({})) .await { Ok(_) => Ok(()), Err(e) if e.to_string().contains("No response received") => Ok(()), Err(e) => Err(e), } } /// 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> { 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); self.open_url_on_port(port, url).await } /// Open `url` in a new tab of the browser on `port`, through the CDP HTTP /// convenience endpoint, which needs no page target to exist yet. async fn open_url_on_port( &self, port: u16, url: &str, ) -> Result<(), Box> { 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(()) } /// What the running browser for `profile_path` has said about Wayfern on /// stderr so far (launch identity, refusals), newest last. #[allow(dead_code)] pub async fn browser_log_lines(&self, profile_path: &str) -> Vec { 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()); inner .instances .values() .find(|instance| { instance.profile_path.as_deref().is_some_and(|path| { std::path::Path::new(path) .canonicalize() .unwrap_or_else(|_| std::path::Path::new(path).to_path_buf()) == target_path }) }) .map(|instance| instance.log_tap.lines()) .unwrap_or_default() } pub async fn get_cdp_port(&self, profile_path: &str) -> Option { 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 { 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 = 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, }); } 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, log_tap: BrowserLogTap::default(), }, ); return Some(WayfernLaunchResult { id: instance_id, processId: Some(pid), profilePath: Some(found_profile_path), url: None, cdp_port, }); } 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)> { 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 = 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> { 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, LaunchKind::Interactive, ) .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)] terminate_process(pid); #[cfg(windows)] force_kill_process(pid); } 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::needs_local_worker_for_probe( "socks5://user:pass@gw.dataimpulse.com:10000" )); assert!(WayfernManager::needs_local_worker_for_probe( "socks5://1.2.3.4:1080" )); assert!(WayfernManager::needs_local_worker_for_probe( "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::needs_local_worker_for_probe( "socks5://127.0.0.1:24001" )); assert!(!WayfernManager::needs_local_worker_for_probe( "socks5://[::1]:24001" )); assert!(!WayfernManager::needs_local_worker_for_probe( "socks5://localhost:24001" )); // http/https reqwest proxies natively, so they stay on the direct path. assert!(!WayfernManager::needs_local_worker_for_probe( "http://gw.dataimpulse.com:10000" )); assert!(!WayfernManager::needs_local_worker_for_probe( "https://gw.dataimpulse.com:10000" )); // A hostless socks URL has nothing to route to; reqwest cannot use it, and // the worker path ends in a skipped probe rather than an unproxied one. assert!(!WayfernManager::needs_local_worker_for_probe("socks5://")); // An unparsable upstream asks for a worker ON PURPOSE. It used to answer // "no worker needed", which sent it down the arm that hands reqwest the raw // string, and reqwest answers garbage by silently going DIRECT. The worker // will fail to start for a malformed URL, and that failure now skips the // probe entirely, which is the outcome we want: no geolocation beats // geolocation taken from the user's own address. assert!(WayfernManager::needs_local_worker_for_probe("not a url")); // The schemes reqwest ACCEPTS and then silently does not proxy. Before // these were routed through a local worker, the fingerprint's geolocation // probe went out from the user's real address with nothing logged. // `httpstls` is NOT in this group: `reqwest_upstream_url` rewrites it to // `https://`, which reqwest proxies natively and over TLS, so the probe // genuinely goes through the proxy without spawning a worker. The rewrite // is what makes it safe, which is why the filter measures the REWRITTEN url // rather than the stored one. assert!(!WayfernManager::needs_local_worker_for_probe( "httpstls://user:pass@proxy.example.com:443" )); assert!(WayfernManager::needs_local_worker_for_probe( "ss://method:pass@1.2.3.4:8388" )); assert!(WayfernManager::needs_local_worker_for_probe( "vless://uuid@1.2.3.4:443" )); // Loopback is exempt ONLY for schemes reqwest can proxy. `httpstls` is // rewritten to `https`, so a loopback one genuinely needs no worker. assert!(!WayfernManager::needs_local_worker_for_probe( "httpstls://127.0.0.1:8443" )); // But a LOOPBACK ss/vless is still a scheme reqwest discards. Exempting // these re-opened the entire leak for anyone pointing at a locally // forwarded Shadowsocks or VLESS endpoint. for loopback in [ "ss://cipher:pw@127.0.0.1:8388", "ss://cipher:pw@localhost:8388", "ss://cipher:pw@[::1]:8388", "shadowsocks://cipher:pw@127.0.0.1:8388", "vless://uuid@127.0.0.1:443", ] { assert!( WayfernManager::needs_local_worker_for_probe(loopback), "{loopback} must still go through a worker" ); } // An ALLOW-list, so a scheme nobody anticipated cannot fall through. // `proxy_type` is free text via POST /v1/proxies, import_proxies_json and // the MCP tool, so this is reachable without a code change. Note this only // says "reqwest must not be handed it", whether anything here can carry it // is `probe_route`'s answer, tested below. for unknown in [ "trojan://gw.example.com:443", "hysteria2://gw.example.com:443", "wireguard://gw.example.com:51820", "SS://cipher:pw@1.2.3.4:8388", ] { assert!( WayfernManager::needs_local_worker_for_probe(unknown), "{unknown} is not proxyable by reqwest and must not be handed to it" ); } // socks5h is proxyable and remote, so it keeps the socks rule. assert!(WayfernManager::needs_local_worker_for_probe( "socks5h://1.2.3.4:1080" )); // http/https reqwest proxies natively, so they stay on the direct path. assert!(!WayfernManager::needs_local_worker_for_probe( "https://user:pass@proxy.example.com:8443" )); } /// A complete VLESS + XTLS Vision + REALITY URI, the only shape Donut takes. fn valid_vless_uri() -> String { "vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443\ ?security=reality&flow=xtls-rprx-vision&encryption=none&type=tcp&sni=a.com\ &pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome" .to_string() } #[test] fn worker_upstream_url_mirrors_what_donut_proxy_can_actually_dial() { // This list is `proxy_server::dial_upstream`'s match arms. Anything outside // it reaches that function's `_` arm, and every request through the worker // fails as "Unsupported upstream proxy scheme". for carried in [ "http://gw.example.com:8080", "https://gw.example.com:8080", "httpstls://user:pass@gw.example.com:443", "socks4://1.2.3.4:1080", "socks5://user:pass@gw.example.com:1080", "ss://aes-256-gcm:pw@1.2.3.4:8388", "shadowsocks://aes-256-gcm:pw@1.2.3.4:8388", ] { assert_eq!( WayfernManager::worker_upstream_url(carried).as_deref(), Some(carried), "{carried} is dialable by a donut-proxy worker and must pass through unchanged" ); } // VLESS is the whole point of this defect: a worker started on it can never // complete a single request, so it must not be offered as a transport. assert_eq!( WayfernManager::worker_upstream_url(&valid_vless_uri()), None, "a donut-proxy worker cannot speak VLESS" ); for unspeakable in [ "vless://uuid@1.2.3.4:443", "trojan://gw.example.com:443", "hysteria2://gw.example.com:443", "wireguard://gw.example.com:51820", "not a url", ] { assert_eq!( WayfernManager::worker_upstream_url(unspeakable), None, "{unspeakable} is not dialable by a donut-proxy worker" ); } // The "resolve at the exit" spellings mean the same thing to the worker, // which hands the target hostname to the SOCKS server rather than resolving // it here, but `dial_upstream` matches the scheme literally, so they have // to arrive spelled the way it matches. assert_eq!( WayfernManager::worker_upstream_url("socks5h://user:pass@gw.example.com:1080").as_deref(), Some("socks5://user:pass@gw.example.com:1080") ); assert_eq!( WayfernManager::worker_upstream_url("socks4a://1.2.3.4:1080").as_deref(), Some("socks4://1.2.3.4:1080") ); // An uppercase scheme is still the same scheme; `Url::parse` lowercases it // anyway, so normalizing here keeps the allow-list from rejecting it. assert_eq!( WayfernManager::worker_upstream_url("SS://aes-256-gcm:pw@1.2.3.4:8388").as_deref(), Some("ss://aes-256-gcm:pw@1.2.3.4:8388") ); } #[test] fn vless_is_probed_through_xray_or_not_at_all_but_never_through_a_worker() { // A complete VLESS URI: an Xray-core sidecar carries the hop, exactly as // browser_runner does for a VLESS launch. let uri = valid_vless_uri(); assert_eq!( WayfernManager::probe_route(&uri), ProbeRoute::Xray(uri.clone()) ); // A `vless://host:port` is what a stored VLESS proxy collapses to when it // is rendered as `type://host:port`: no id, no flow, no REALITY key. Xray // cannot dial it and neither can a worker, so the probe is skipped and the // location is left ungenerated rather than defaulted. for lossy in [ "vless://vpn.example.com:443", "vless://uuid@vpn.example.com:443", // Right shape, unsupported transport, still nothing that can carry it. "vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@a.com:443?security=tls&type=ws", ] { assert_eq!( WayfernManager::probe_route(lossy), ProbeRoute::Unroutable, "{lossy} has no transport and must not start a worker" ); } // And no VLESS shape may EVER resolve to a plain donut-proxy worker: that // worker answers every request with "Unsupported upstream proxy scheme", // which used to land as a default America/New_York in the fingerprint. for any_vless in [ uri.as_str(), "vless://vpn.example.com:443", "vless://uuid@127.0.0.1:443", "VLESS://vpn.example.com:443", ] { assert!( !matches!( WayfernManager::probe_route(any_vless), ProbeRoute::Worker(_) | ProbeRoute::Reqwest(_) ), "{any_vless} must never be handed to a donut-proxy worker or to reqwest" ); } } #[test] fn probe_route_sends_each_upstream_to_something_that_carries_it() { // reqwest proxies these itself; `httpstls` arrives already rewritten to the // `https` spelling reqwest understands. assert_eq!( WayfernManager::probe_route("http://gw.example.com:8080"), ProbeRoute::Reqwest("http://gw.example.com:8080".into()) ); assert_eq!( WayfernManager::probe_route("httpstls://user:pass@gw.example.com:443"), ProbeRoute::Reqwest("https://user:pass@gw.example.com:443".into()) ); // A loopback socks upstream IS the local worker the browser already uses, // so it needs no second one. This is the launch-time path. assert_eq!( WayfernManager::probe_route("socks5://127.0.0.1:24001"), ProbeRoute::Reqwest("socks5://127.0.0.1:24001".into()) ); // Remote SOCKS and Shadowsocks go through a worker, which speaks both. assert_eq!( WayfernManager::probe_route("socks5://user:pass@gw.example.com:1080"), ProbeRoute::Worker("socks5://user:pass@gw.example.com:1080".into()) ); assert_eq!( WayfernManager::probe_route("ss://aes-256-gcm:pw@1.2.3.4:8388"), ProbeRoute::Worker("ss://aes-256-gcm:pw@1.2.3.4:8388".into()) ); // The worker matches `socks5`, not `socks5h`, so the route carries the // spelling it dials rather than the stored one. assert_eq!( WayfernManager::probe_route("socks5h://1.2.3.4:1080"), ProbeRoute::Worker("socks5://1.2.3.4:1080".into()) ); // Schemes nothing here speaks. `proxy_type` is free text through the REST // API and MCP, so these are reachable without a code change, and each one // must end in a skipped probe rather than a fabricated location. for unroutable in [ "trojan://gw.example.com:443", "hysteria2://gw.example.com:443", "wireguard://gw.example.com:51820", "not a url", ] { assert_eq!( WayfernManager::probe_route(unroutable), ProbeRoute::Unroutable, "{unroutable} has no transport that carries it" ); } } #[test] fn a_pinned_geoip_ip_still_resolves_on_a_routed_profile() { // The probe is skipped only when it would actually leave this machine with // nothing to carry it. A routed profile whose location comes from a pinned // geoip IP resolves it locally, so it must not be skipped for want of a // transport it never needed. assert!(!WayfernManager::must_skip_probe(true, false, false)); // Geolocation genuinely going out over a routed profile's upstream, with no // transport built: this is the case that must never probe. assert!(WayfernManager::must_skip_probe(true, true, false)); // Transport built, or nothing routed: probe away. assert!(!WayfernManager::must_skip_probe(true, true, true)); assert!(!WayfernManager::must_skip_probe(false, true, false)); } #[tokio::test] async fn a_failed_geolocation_probe_leaves_the_location_ungenerated() { // A geoip string skips the network fetch entirely and fails in the local // MaxMind lookup, so this drives apply_geolocation's Err branch with no // network, no proxy and no worker. let mut fingerprint = json!({ "platform": "Win32" }); let applied = WayfernManager::apply_geolocation(&mut fingerprint, None, Some(&json!("not-an-ip"))).await; assert!(!applied, "a failed lookup must not report success"); let obj = fingerprint .as_object() .expect("fingerprint stays an object"); for invented in [ "timezone", "timezoneOffset", "latitude", "longitude", "language", "languages", ] { assert!( !obj.contains_key(invented), "a failed probe must not invent {invented}: {obj:?}" ); } assert_eq!(obj.get("platform"), Some(&json!("Win32"))); } #[tokio::test] async fn a_failed_geolocation_probe_does_not_overwrite_a_real_location() { // The other half: a fingerprint that already carries a resolved location // keeps it verbatim when a later probe fails. let mut fingerprint = json!({ "timezone": "Europe/Berlin", "timezoneOffset": -60, "latitude": 52.52, }); let applied = WayfernManager::apply_geolocation(&mut fingerprint, None, Some(&json!("not-an-ip"))).await; assert!(!applied); assert_eq!(fingerprint["timezone"], json!("Europe/Berlin")); assert_eq!(fingerprint["timezoneOffset"], json!(-60)); assert_eq!(fingerprint["latitude"], json!(52.52)); } fn obj(json: &str) -> serde_json::Map { serde_json::from_str(json).expect("test fixture must be an object") } fn identity_config(timezone: Option<&str>) -> WayfernConfig { let mut location = serde_json::Map::new(); if let Some(tz) = timezone { location.insert("timezone".into(), json!(tz)); location.insert("timezoneOffset".into(), json!(60)); } location.insert("language".into(), json!("de-DE")); location.insert("languages".into(), json!(["de-DE", "de"])); location.insert("latitude".into(), json!(52.52)); location.insert("longitude".into(), json!(13.405)); WayfernConfig { identity_id: Some("3fa85f64-5717-4562-b3fc-2c963f66afa6".into()), os: Some("windows".into()), location: Some(serde_json::Value::Object(location).to_string()), identity_overrides: Some(r#"{"hardwareConcurrency":8}"#.into()), ..Default::default() } } #[test] fn the_launch_identity_document_carries_exactly_what_the_browser_reads() { let document = WayfernManager::launch_identity_document(&identity_config(Some("Europe/Berlin"))) .expect("a complete identity profile yields a document"); assert_eq!( document, json!({ "identityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "operatingSystem": "windows", "timezone": "Europe/Berlin", "language": "de-DE", "latitude": 52.52, "longitude": 13.405, "overrides": {"hardwareConcurrency": 8} }) ); } #[test] fn the_launch_identity_document_needs_a_timezone_and_an_identity() { // The browser refuses a document without a timezone, and does not resolve // one itself, so no document is written at all. assert!(WayfernManager::launch_identity_document(&identity_config(None)).is_none()); // No claim means the host, which is what an omitted `operatingSystem` // means over CDP as well. let mut no_os = identity_config(Some("Europe/Berlin")); no_os.os = None; assert_eq!( WayfernManager::launch_identity_document(&no_os).unwrap()["operatingSystem"], json!(crate::profile::types::get_host_os()) ); let mut no_identity = identity_config(Some("Europe/Berlin")); no_identity.identity_id = Some(" ".into()); assert!(WayfernManager::launch_identity_document(&no_identity).is_none()); // A legacy device payload is only reproducible through setFingerprint. let mut legacy = identity_config(Some("Europe/Berlin")); legacy.fingerprint = Some("{}".into()); assert!(WayfernManager::launch_identity_document(&legacy).is_none()); } #[test] fn the_launch_identity_document_omits_what_the_location_lacks() { let mut config = identity_config(Some("Asia/Tokyo")); config.location = Some(r#"{"timezone":"Asia/Tokyo","latitude":35.6}"#.into()); config.identity_overrides = None; let document = WayfernManager::launch_identity_document(&config).unwrap(); assert_eq!( document, json!({ "identityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "operatingSystem": "windows", "timezone": "Asia/Tokyo" }), "a lone latitude, an absent language and empty overrides must not travel" ); } #[test] fn session_restore_is_only_for_a_person_on_a_device_committed_at_launch() { let config = WayfernConfig::default(); let interactive = |c: &WayfernConfig| { session_restore_verdict(c, LaunchKind::Interactive, false, false, false, true) }; assert_eq!(interactive(&config), Ok(())); assert!( session_restore_verdict(&config, LaunchKind::Automation, false, false, false, true).is_err() ); assert!( session_restore_verdict(&config, LaunchKind::Interactive, true, false, false, true).is_err() ); assert!( session_restore_verdict(&config, LaunchKind::Interactive, false, true, false, true).is_err() ); assert!( session_restore_verdict(&config, LaunchKind::Interactive, false, false, true, true).is_err() ); assert!( session_restore_verdict(&config, LaunchKind::Interactive, false, false, false, false) .is_err(), "a device applied after the window opens loses the race with a restored tab" ); let off = WayfernConfig { restore_session: Some(false), ..Default::default() }; assert!(interactive(&off).is_err()); let randomized = WayfernConfig { randomize_fingerprint_on_launch: Some(true), ..Default::default() }; assert!(interactive(&randomized).is_err()); let explicit_on = WayfernConfig { restore_session: Some(true), ..Default::default() }; assert_eq!(interactive(&explicit_on), Ok(())); } #[test] fn the_webrtc_mode_reads_the_new_field_then_the_legacy_flag() { assert_eq!( WebRtcMode::from_config(&WayfernConfig::default()), WebRtcMode::Auto ); let legacy = WayfernConfig { block_webrtc: Some(true), ..Default::default() }; assert_eq!(WebRtcMode::from_config(&legacy), WebRtcMode::Block); let both = WayfernConfig { block_webrtc: Some(true), webrtc_mode: Some("tcp_only".into()), ..Default::default() }; assert_eq!(WebRtcMode::from_config(&both), WebRtcMode::TcpOnly); let unknown = WayfernConfig { webrtc_mode: Some("sideways".into()), ..Default::default() }; assert_eq!(WebRtcMode::from_config(&unknown), WebRtcMode::Auto); assert_eq!(WebRtcMode::parse("TCP-ONLY"), Some(WebRtcMode::TcpOnly)); } #[test] fn the_webrtc_switches_carry_the_exit_only_when_it_can_be_used() { assert!(webrtc_switches("151.0.7922.76", WebRtcMode::Block, Some("8.8.8.8")).is_empty()); assert_eq!( webrtc_switches("152.0.7977.64", WebRtcMode::Block, Some("8.8.8.8")), vec!["--wayfern-webrtc-mode=block".to_string()] ); assert_eq!( webrtc_switches("152.0.7977.64", WebRtcMode::TcpOnly, Some(" 8.8.8.8 ")), vec![ "--wayfern-webrtc-mode=tcp_only".to_string(), "--wayfern-webrtc-exit-ip=8.8.8.8".to_string() ] ); assert_eq!( webrtc_switches("152.0.7977.64", WebRtcMode::Auto, Some("not an ip")), vec!["--wayfern-webrtc-mode=auto".to_string()], "a literal the browser would refuse is not passed at all" ); assert_eq!( webrtc_switches("152.0.7977.64", WebRtcMode::Auto, None), vec!["--wayfern-webrtc-mode=auto".to_string()] ); } #[test] fn the_entitlement_cache_lives_under_the_app_cache_and_only_on_152() { let root = tempfile::tempdir().unwrap(); assert_eq!(entitlement_cache_switch("151.0.7922.76", root.path()), None); let switch = entitlement_cache_switch("152.0.7977.64", root.path()).unwrap(); let expected = root.path().join("wayfern-entitlements"); assert_eq!( switch, format!("--wayfern-entitlement-cache-dir={}", expected.display()) ); assert!( expected.is_dir(), "the directory exists before the browser starts" ); } #[test] fn the_persona_document_is_written_per_profile_and_only_on_152() { let root = tempfile::tempdir().unwrap(); let dir = root.path().to_string_lossy().to_string(); let seed = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; assert_eq!(persona_switch("151.0.7922.76", &dir, seed, None), None); let switch = persona_switch("152.0.7977.64", &dir, seed, None).unwrap(); let path = root .path() .join("wayfern-persona.json") .canonicalize() .unwrap(); assert_eq!( switch, format!("--wayfern-profile-persona={}", path.display()) ); let document: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); assert_eq!( document["fields"].as_array().unwrap().len(), crate::wayfern_persona::FIELD_IDS.len() ); // An edit lands, and a malformed edit blob is ignored rather than fatal. persona_switch( "152.0.7977.64", &dir, seed, Some(r#"[{"id":"email","label":"Email","value":"me@example.com"}]"#), ) .unwrap(); let edited: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); assert!(edited["fields"] .as_array() .unwrap() .iter() .any(|f| f["id"] == "email" && f["value"] == "me@example.com")); assert!(persona_switch("152.0.7977.64", &dir, seed, Some("not json")).is_some()); } #[test] fn the_camera_switches_need_a_file_that_exists_and_a_sane_crop() { let root = tempfile::tempdir().unwrap(); let file = root.path().join("frame.png"); std::fs::write(&file, b"not really a png").unwrap(); let path = file.to_string_lossy().to_string(); let config = |file: Option<&str>, crop: Option<&str>| WayfernConfig { camera_file: file.map(str::to_string), camera_crop: crop.map(str::to_string), ..Default::default() }; assert!(camera_switches("151.0.7922.76", &config(Some(&path), None)).is_empty()); assert!(camera_switches("152.0.7977.64", &config(None, Some("0,0,10,10"))).is_empty()); assert!(camera_switches("152.0.7977.64", &config(Some("/nope/frame.png"), None)).is_empty()); assert_eq!( camera_switches("152.0.7977.64", &config(Some(&path), Some("0,0,640,480"))), vec![ format!("--wayfern-camera-file={path}"), "--wayfern-camera-crop=0,0,640,480".to_string() ] ); assert_eq!( camera_switches("152.0.7977.64", &config(Some(&path), Some("0,0,0,480"))), vec![format!("--wayfern-camera-file={path}")], "a crop with no area is dropped, the source is not" ); assert!(!valid_camera_crop("1,2,3")); assert!(!valid_camera_crop("-1,0,10,10")); assert!(valid_camera_crop(" 1, 2, 30, 40 ")); } #[test] fn widevine_travels_only_when_a_cdm_has_been_provisioned() { let root = tempfile::tempdir().unwrap(); assert_eq!(widevine_switch("152.0.7977.64", root.path()), None); let dir = root.path().join("WidevineCdm"); std::fs::create_dir_all(&dir).unwrap(); assert_eq!( widevine_switch("152.0.7977.64", root.path()), None, "an empty directory is not a provisioned CDM" ); std::fs::write(dir.join("manifest.json"), b"{}").unwrap(); assert_eq!(widevine_switch("151.0.7922.76", root.path()), None); assert_eq!( widevine_switch("152.0.7977.64", root.path()), Some(format!("--wayfern-widevine-cdm-dir={}", dir.display())), "a provisioned directory is passed even when its payload is missing, so the browser reports it" ); } #[test] fn a_screen_claim_is_only_reported_when_it_is_bigger_than_the_display() { let device = |w: u32, h: u32| format!(r#"{{"screenWidth":{w},"screenHeight":{h}}}"#); assert_eq!( screen_claim_over_host(Some(&device(3840, 2160)), Some((2560, 1440))), Some((3840, 2160, 2560, 1440)) ); // Taller but not wider still cannot be shown. assert_eq!( screen_claim_over_host(Some(&device(1920, 2160)), Some((2560, 1440))), Some((1920, 2160, 2560, 1440)) ); assert_eq!( screen_claim_over_host(Some(&device(1920, 1080)), Some((2560, 1440))), None ); assert_eq!( screen_claim_over_host(Some(&device(2560, 1440)), Some((2560, 1440))), None, "an exact fit is not a mismatch" ); // Nothing to compare: no device, no host, a device with no screen, or a // display that reports nothing. assert_eq!(screen_claim_over_host(None, Some((2560, 1440))), None); assert_eq!( screen_claim_over_host(Some(&device(3840, 2160)), None), None ); assert_eq!( screen_claim_over_host(Some(r#"{"platform":"MacIntel"}"#), Some((2560, 1440))), None ); assert_eq!( screen_claim_over_host(Some(&device(3840, 2160)), Some((0, 0))), None ); } #[test] fn the_session_switches_follow_the_verdict() { assert!(session_switches(false, None).is_empty()); assert_eq!( session_switches(true, None), vec!["--restore-last-session".to_string()] ); let file = Path::new("/tmp/p/wayfern-identity.json"); assert_eq!( session_switches(true, Some(file)), vec![ "--wayfern-identity-file=/tmp/p/wayfern-identity.json".to_string(), "--restore-last-session".to_string() ] ); } #[test] fn the_identity_verdict_trusts_the_browser_log_first() { let document = json!({ "identityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "operatingSystem": "macos", "timezone": "Europe/Berlin", "language": "de-DE" }); let tap = BrowserLogTap::default(); tap.push("[1:2:0908/173819.393301:ERROR:wayfern_launch_identity.cc(58)] Wayfern launch identity refused: the identity file carries no `timezone`".into()); let observed = json!({"identityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "identity": {}}); assert_eq!( WayfernManager::launch_identity_verdict(&document, Some(&observed), None, &tap), Err("the identity file carries no `timezone`".to_string()), "a logged refusal wins even over a matching id" ); let tap = BrowserLogTap::default(); assert!( WayfernManager::launch_identity_verdict(&document, Some(&observed), None, &tap).is_ok() ); // 152 reports no identityId for a launch identity; the applied line // settles it. let tap = BrowserLogTap::default(); tap.push("[1:2:0908/173819.393301:INFO:wayfern_launch_identity.cc(300)] Wayfern launch identity applied before the first navigation: os=macos timezone=Europe/Berlin language=de-DE version=1".into()); let anonymous = json!({"identity": {"timezone": "Europe/Berlin", "language": "de-DE", "platform": "MacIntel"}}); assert!( WayfernManager::launch_identity_verdict(&document, Some(&anonymous), None, &tap).is_ok() ); // No log line at all: the running device is compared with the document. let tap = BrowserLogTap::default(); assert!( WayfernManager::launch_identity_verdict(&document, Some(&anonymous), None, &tap).is_ok() ); let host = json!({"identity": {"timezone": "America/New_York", "language": "en-US", "platform": "MacIntel"}}); let error = WayfernManager::launch_identity_verdict(&document, Some(&host), None, &tap).unwrap_err(); assert!(error.contains("America/New_York"), "{error}"); assert!(error.contains("Europe/Berlin"), "{error}"); assert_eq!( WayfernManager::launch_identity_verdict(&document, None, Some("boom"), &tap), Err("Wayfern.getIdentity failed: boom".to_string()) ); } #[test] fn the_log_tap_keeps_the_newest_lines() { let tap = BrowserLogTap::default(); for i in 0..40 { tap.push(format!("line {i}")); } let lines = tap.lines(); assert_eq!(lines.len(), BrowserLogTap::CAPACITY); assert_eq!(lines.first().map(String::as_str), Some("line 8")); assert_eq!(lines.last().map(String::as_str), Some("line 39")); assert!(!tap.has_identity_verdict()); } #[cfg(unix)] #[tokio::test] async fn a_termination_request_ends_a_process_and_the_wait_notices() { let mut child = std::process::Command::new("sleep") .arg("30") .spawn() .expect("sleep spawns"); let pid = child.id(); assert!(!wait_for_exit(pid, Duration::from_millis(200)).await); terminate_process(pid); let _ = child.wait(); assert!(wait_for_exit(pid, Duration::from_secs(5)).await); } #[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")); assert!(!supports_wayfern_152("151.0.7922.76")); assert!(supports_wayfern_152("152.0.7977.64")); assert!(!supports_wayfern_152("garbage")); } #[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 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 the browser's own platform-to-OS mapping, including armv8l and // 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 the browser 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: Temporary fingerprint generation limit reached.", 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")); } fn cdp(code: i64, message: &str) -> String { format!("CDP error: {}", json!({ "code": code, "message": message })) } fn code_of(coded: &str) -> String { serde_json::from_str::(coded).unwrap()["code"] .as_str() .unwrap() .to_string() } #[test] fn every_browser_refusal_reaches_the_frontend_as_its_own_code() { for (message, expected) in [ ( "Custom fingerprints require a Donut Browser plan that includes them.", "WAYFERN_CUSTOM_FINGERPRINT_REQUIRES_PLAN", ), ( "Fingerprint authorization service is temporarily unavailable. Retry the command.", "WAYFERN_PLAN_CHECK_UNAVAILABLE", ), ( "Cross-OS fingerprinting authorization service is temporarily unavailable. Retry the command.", "WAYFERN_PLAN_CHECK_UNAVAILABLE", ), ( "Cross-OS fingerprinting requires a paid plan. Provide a wayfernToken parameter.", "WAYFERN_CROSS_OS_REQUIRES_PLAN", ), ( "Cross-OS fingerprinting authorization failed. Token is invalid or expired.", "WAYFERN_CROSS_OS_REQUIRES_PLAN", ), ( "Temporary fingerprint generation limit reached.", "WAYFERN_GENERATION_LIMIT_REACHED", ), ( "Fingerprint generation state is unavailable.", "WAYFERN_GENERATION_UNAVAILABLE", ), ( "A fingerprint identity change is in progress", "WAYFERN_BROWSER_BUSY", ), ( "Target closed during fingerprint identity change", "WAYFERN_BROWSER_BUSY", ), ( "Fingerprint generation is not available for this profile.", "WAYFERN_FINGERPRINT_GENERATION_FAILED", ), ] { let coded = wayfern_failure( &cdp(-32000, message), "WAYFERN_FINGERPRINT_GENERATION_FAILED", Some("macos"), ); assert_eq!(code_of(&coded), expected, "{message}"); } let generic = wayfern_failure( &format!( "Failed to create identity: {}", cdp(-32000, "Some new refusal.") ), "WAYFERN_FINGERPRINT_GENERATION_FAILED", None, ); assert_eq!(code_of(&generic), "WAYFERN_FINGERPRINT_GENERATION_FAILED"); assert!(generic.contains("Some new refusal.")); assert!(!generic.contains("-32000")); let already_coded = crate::backend_error("WAYFERN_INSTANCE_LIMIT_REACHED"); assert_eq!( wayfern_failure(&already_coded, "WAYFERN_FINGERPRINT_APPLY_FAILED", None), already_coded ); } #[test] fn launch_identity_refusals_keep_their_meaning() { for (reason, expected) in [ ( "a cross-OS claim requires a valid paid plan token; the browser is starting on its host device instead", "WAYFERN_CROSS_OS_REQUIRES_PLAN", ), ( "another identity change already owns this profile", "WAYFERN_BROWSER_BUSY", ), ("the derived identity was not committed", "WAYFERN_BROWSER_BUSY"), ( "the identity file carries no valid `identityId`", "WAYFERN_IDENTITY_REFUSED", ), ] { let coded = wayfern_failure(reason, "WAYFERN_IDENTITY_REFUSED", Some("windows")); assert_eq!(code_of(&coded), expected, "{reason}"); } } #[test] fn a_dropped_override_batch_is_visible_to_the_launcher() { let tap = BrowserLogTap::default(); assert!(!tap.dropped_overrides()); tap.push("[WARNING] Wayfern launch identity applied before the first navigation".to_string()); assert!(!tap.dropped_overrides()); tap.push( "[WARNING] Wayfern launch identity: dropping 3 override(s); the plan check is \ temporarily unavailable. The identity is applied as derived." .to_string(), ); assert!(tap.dropped_overrides()); assert!(tap.identity_refusal().is_none()); } #[test] fn a_browser_that_exits_before_it_is_ready_says_why() { let status = exit_status(1); let limit = early_exit_error( "Wayfern instance limit reached: 4 browsers are already running on this plan.", &status, ); assert_eq!(code_of(&limit), "WAYFERN_INSTANCE_LIMIT_REACHED"); let terms = early_exit_error( "Before using Wayfern, you must read and agree to our", &status, ); assert_eq!(code_of(&terms), "WAYFERN_TERMS_REQUIRED"); let unknown = early_exit_error("", &status); assert_eq!(code_of(&unknown), "WAYFERN_BROWSER_EXITED"); assert!(is_temporary_wayfern_failure(&limit)); assert!(!is_temporary_wayfern_failure(&terms)); assert!(!is_temporary_wayfern_failure(&unknown)); } #[cfg(unix)] fn exit_status(code: i32) -> std::process::ExitStatus { use std::os::unix::process::ExitStatusExt; std::process::ExitStatus::from_raw(code << 8) } #[cfg(windows)] fn exit_status(code: u32) -> std::process::ExitStatus { use std::os::windows::process::ExitStatusExt; std::process::ExitStatus::from_raw(code) } #[test] fn protocol_codes_are_read_from_the_error_object() { assert!(cdp_error_is_invalid_params(&cdp(-32602, "bad"))); assert!(!cdp_error_is_invalid_params(&cdp(-32000, "bad"))); assert!(!cdp_error_is_invalid_params("not a protocol error")); assert_eq!(cdp_error_message(&cdp(-32000, "the words")), "the words"); assert_eq!(cdp_error_message(" plain text "), "plain text"); } #[test] fn the_launch_payload_never_invents_a_location() { // The regression, and the whole reason the gate is allowed to say "nothing // was compared": the launcher used to insert `America/New_York` and offset // 300 into any fingerprint that declared no timezone. The browser then // presented a US clock behind whatever exit the profile routed through, // while the app told the user the timezone had never been compared, the // exact mismatch `fingerprint_consistency` exists to surface, manufactured // by the launcher and then hidden by it. for stored in [ r#"{"platform": "Win32"}"#, // The legacy wrapper takes the same path. r#"{"fingerprint": {"platform": "Win32"}}"#, ] { let payload = WayfernManager::launch_fingerprint_payload(stored).expect("a stored fingerprint parses"); let obj = payload.as_object().expect("stays an object"); for invented in ["timezone", "timezoneOffset", "latitude", "longitude"] { assert!( !obj.contains_key(invented), "the launch payload invented {invented} for {stored}: {obj:?}" ); } assert_eq!(obj.get("platform"), Some(&json!("Win32"))); } } #[test] fn the_launch_payload_unwraps_the_legacy_shape_and_keeps_a_declared_location() { let payload = WayfernManager::launch_fingerprint_payload( r#"{"fingerprint": {"timezone": "Europe/Berlin", "timezoneOffset": -60, "languages": "de-DE, de"}}"#, ) .expect("the legacy wrapper parses"); assert_eq!(payload["timezone"], json!("Europe/Berlin")); assert_eq!(payload["timezoneOffset"], json!(-60)); // A comma-separated ladder still becomes the array the browser expects. assert_eq!(payload["languages"], json!(["de-DE", "de"])); // The wrapper itself must never reach the browser. assert!(payload.get("fingerprint").is_none()); } #[test] fn the_launch_payload_refuses_unparsable_json() { assert!(WayfernManager::launch_fingerprint_payload("not json").is_err()); } #[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 ); } const WORK_AREA: Option<(u32, u32)> = Some((1536, 816)); #[test] fn a_window_that_fits_is_left_exactly_as_asked() { assert_eq!(fit_window_to_work_area((1268, 764), WORK_AREA), (1268, 764)); assert_eq!(fit_window_to_work_area((1536, 816), WORK_AREA), (1536, 816)); } #[test] fn an_oversized_window_is_brought_back_inside_the_usable_area() { for requested in [(1920, 1050), (1658, 1222), (2560, 1392)] { assert!( requested.0 > 1536 || requested.1 > 816, "{requested:?} already fits, so this case proves nothing" ); let fitted = fit_window_to_work_area(requested, WORK_AREA); assert!( fitted.0 <= 1536 && fitted.1 <= 816, "{requested:?} still overflows the usable area as {fitted:?}" ); } } #[test] fn only_the_axis_that_overflows_moves() { assert_eq!(fit_window_to_work_area((1920, 700), WORK_AREA), (1536, 700)); assert_eq!( fit_window_to_work_area((1200, 1222), WORK_AREA), (1200, 816) ); } #[test] fn an_unknown_or_degenerate_usable_area_changes_nothing() { assert_eq!(fit_window_to_work_area((1920, 1050), None), (1920, 1050)); assert_eq!( fit_window_to_work_area((1920, 1050), Some((0, 0))), (1920, 1050) ); assert_eq!( fit_window_to_work_area((1920, 1050), Some((1536, 0))), (1920, 1050) ); } #[test] fn fitting_never_grows_a_window() { assert_eq!( fit_window_to_work_area((800, 600), Some((3840, 2160))), (800, 600) ); } }