Files
donutbrowser/src-tauri/src/wayfern_manager.rs
T
2026-09-22 10:21:52 +04:00

4744 lines
177 KiB
Rust

use crate::browser_runner::BrowserRunner;
use crate::profile::BrowserProfile;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::{HashMap, 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<String>,
#[serde(default)]
pub randomize_fingerprint_on_launch: Option<bool>,
#[serde(default)]
pub os: Option<String>,
#[serde(default)]
pub screen_max_width: Option<u32>,
#[serde(default)]
pub screen_max_height: Option<u32>,
#[serde(default)]
pub screen_min_width: Option<u32>,
#[serde(default)]
pub screen_min_height: Option<u32>,
#[serde(default)]
pub geoip: Option<serde_json::Value>, // For compatibility with shared config form
#[serde(default)]
pub block_images: Option<bool>, // For compatibility with shared config form
/// 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<bool>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// `x,y,width,height` in SOURCE pixels, cropped before the frame is scaled.
#[serde(default)]
pub camera_crop: Option<String>,
/// 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<bool>,
#[serde(default)]
pub block_webgl: Option<bool>,
#[serde(default, skip_serializing)]
pub proxy: Option<String>,
/// Stable signature of the proxy/VPN/geoip the fingerprint's location data
/// (timezone, latitude/longitude, language) was last computed for. Compared
/// on launch to detect that the routing changed since creation, so the
/// location can be refreshed instead of showing stale data.
#[serde(default)]
pub geo_proxy_signature: Option<String>,
#[serde(default)]
pub identity_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_baseline: Option<String>,
/// The user's own edits to an identity-backed device, as a JSON object of
/// fingerprint fields. Sent verbatim as `setIdentity` overrides; everything
/// not listed here comes from the identity. `None` means no edits.
#[serde(default)]
pub identity_overrides: Option<String>,
/// 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<String>,
}
/// First Wayfern version that ships `createIdentity`/`setIdentity`/
/// `getIdentity`. Those commands are ADDITIVE: `setFingerprint`,
/// `getFingerprint` and `refreshFingerprint` are all still declared in the 151
/// protocol and still implemented, so this constant means "the identity API is
/// available here", never "the legacy commands are gone". A profile that
/// stores a whole device payload keeps being applied with `setFingerprint` on
/// 151, which is the only command that reproduces such a payload exactly.
///
/// Written as a full version rather than a major so it can be pinned to an
/// exact build if 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<String> {
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<Self> {
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<String> {
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::<std::net::IpAddr>() {
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::<f64>(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<WorkArea> {
let monitor = app_handle.primary_monitor().ok().flatten()?;
let scale = monitor.scale_factor();
let area = monitor.work_area();
let size = area.size.to_logical::<f64>(scale);
let origin = area.position.to_logical::<f64>(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<String> {
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::<serde_json::Value>(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::<serde_json::Value>(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<String> {
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<String> {
if !supports_wayfern_152(version) {
return None;
}
let edits: Vec<crate::wayfern_persona::PersonaField> = 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<String> {
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::<u32>())
.collect::<Result<Vec<_>, _>>()
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
/// `<data>/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<String> {
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<std::sync::Mutex<VecDeque<String>>>);
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<String> {
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<String> {
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<Self, Box<dyn std::error::Error + Send + Sync>> {
let executable_path = BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?;
let port = 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<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
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<String>,
donut_worker_id: Option<String>,
xray_worker_id: Option<String>,
}
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<String>,
/// `WayfernConfig::location` for the exit this device was generated
/// against, or `None` when no location field was resolved.
pub location: Option<String>,
/// Whether fresh geolocation was resolved and applied. Callers must only
/// stamp `geo_proxy_signature` when this is true.
pub geolocation_applied: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(non_snake_case)]
pub struct WayfernLaunchResult {
pub id: String,
#[serde(alias = "process_id")]
pub processId: Option<u32>,
#[serde(alias = "profile_path")]
pub profilePath: Option<String>,
pub url: Option<String>,
pub cdp_port: Option<u16>,
}
struct WayfernInstance {
id: String,
process_id: Option<u32>,
profile_path: Option<String>,
url: Option<String>,
cdp_port: Option<u16>,
/// 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<String, WayfernInstance>,
}
pub struct WayfernManager {
inner: Arc<AsyncMutex<WayfernManagerInner>>,
http_client: Client,
}
#[derive(Debug, Deserialize)]
struct CdpTarget {
#[serde(rename = "type")]
target_type: String,
#[serde(rename = "webSocketDebuggerUrl")]
websocket_debugger_url: Option<String>,
}
impl WayfernManager {
fn new() -> Self {
Self {
inner: Arc::new(AsyncMutex::new(WayfernManagerInner {
instances: HashMap::new(),
})),
// CDP is always on loopback. Disable env/system proxies so a Windows
// WinHTTP/IE proxy (or HTTP_PROXY) cannot intercept /json/version and
// return 502 Bad Gateway while the browser is actually listening.
http_client: Client::builder()
.timeout(Duration::from_secs(2))
.no_proxy()
.build()
.expect("Failed to build reqwest client for wayfern_manager"),
}
}
pub fn instance() -> &'static WayfernManager {
&WAYFERN_MANAGER
}
#[allow(dead_code)]
pub fn get_profiles_dir(&self) -> PathBuf {
crate::app_dirs::profiles_dir()
}
#[allow(dead_code)]
fn get_binaries_dir(&self) -> PathBuf {
crate::app_dirs::binaries_dir()
}
pub(crate) async fn find_free_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
drop(listener);
Ok(port)
}
/// Normalize fingerprint data from Wayfern CDP format to our storage format.
/// Wayfern returns fields like fonts, webglParameters as JSON strings which we keep as-is.
fn normalize_fingerprint(fingerprint: serde_json::Value) -> serde_json::Value {
// Our storage format matches what Wayfern returns:
// - fonts, plugins, mimeTypes, voices are JSON strings
// - webglParameters, webgl2Parameters, etc. are JSON strings
// The form displays them as JSON text areas, so no conversion needed.
fingerprint
}
/// Denormalize fingerprint data from our storage format to Wayfern CDP format.
/// Wayfern expects certain fields as JSON strings.
fn denormalize_fingerprint(fingerprint: serde_json::Value) -> serde_json::Value {
// Our storage format matches what Wayfern expects:
// - fonts, plugins, mimeTypes, voices are JSON strings
// - webglParameters, webgl2Parameters, etc. are JSON strings
// So no conversion is needed
fingerprint
}
/// Derive the on-screen window size Chromium should open at, from the stored
/// fingerprint. Applying a device over CDP only spoofs what the page
/// *reports* for `windowOuterWidth`/`screenWidth`/etc.; it does not move or
/// resize the real top-level window. Without `--window-size` the OS window keeps
/// Chromium's default, so the visible window contradicts the reported
/// dimensions — a detectable mismatch. We pass `--window-size` so the actual
/// window matches the fingerprint.
///
/// Keys are the camelCase fields Wayfern uses in its fingerprint
/// (`windowOuterWidth`, `screenAvailWidth`, …) — NOT the dotted
/// Preference order, matching how the fingerprint
/// describes the window:
/// 1. `windowOuterWidth` / `windowOuterHeight` — the real window size.
/// 2. `screenAvailWidth` / `screenAvailHeight` — usable screen area.
/// 3. `screenWidth` / `screenHeight` — full screen.
///
/// Returns `None` when the fingerprint carries no usable dimensions, leaving
/// Chromium's default untouched. The fingerprint JSON may be the bare object
/// or the legacy `{ "fingerprint": {...} }` wrapper.
fn window_size_from_fingerprint(fingerprint_json: &str) -> Option<(u32, u32)> {
let 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<u32> {
let v = obj.get(key)?;
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
.filter(|n| *n > 0)
.map(|n| n as u32)
};
let pair = |w: &str, h: &str| -> Option<(u32, u32)> { Some((read(w)?, read(h)?)) };
pair("windowOuterWidth", "windowOuterHeight")
.or_else(|| pair("screenAvailWidth", "screenAvailHeight"))
.or_else(|| pair("screenWidth", "screenHeight"))
}
/// 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<serde_json::Map<String, serde_json::Value>> {
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<serde_json::Value, String> {
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<String, serde_json::Value> {
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<String, serde_json::Value>) -> Option<String> {
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<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
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<String, serde_json::Value>,
baseline: &serde_json::Map<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
let synthesised = Self::donut_synthesised_geo_fields(current);
let mut overrides = serde_json::Map::new();
for (key, value) in current {
if GEO_PARAM_KEYS.contains(&key.as_str()) || DERIVED_PROVENANCE_KEYS.contains(&key.as_str()) {
continue;
}
if GEO_DERIVED_KEYS.contains(&key.as_str()) && synthesised.get(key) == Some(value) {
continue;
}
if baseline.get(key) != Some(value) {
overrides.insert(key.clone(), value.clone());
}
}
overrides
}
/// What `apply_geolocation` would write into `GEO_DERIVED_KEYS` for the
/// `timezone`/`language` this fingerprint already carries.
///
/// Built by calling the same helpers the writer calls, so the two cannot
/// compute a different answer for the same input. A key is absent when what
/// it is derived from is missing or unparsable, which leaves the value
/// looking like an edit — the conservative direction, since it only means an
/// override travels that did not have to.
fn donut_synthesised_geo_fields(
fingerprint: &serde_json::Map<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
let mut derived = serde_json::Map::new();
if let Some(minutes) = fingerprint
.get("timezone")
.and_then(|v| v.as_str())
.and_then(Self::timezone_offset_minutes)
{
derived.insert("timezoneOffset".to_string(), json!(minutes));
}
if let Some(locale) = fingerprint.get("language").and_then(|v| v.as_str()) {
derived.insert(
"languages".to_string(),
json!([locale, Self::base_language(locale)]),
);
}
derived
}
/// The offset of `timezone` from UTC in minutes, in the sign convention
/// `Date.prototype.getTimezoneOffset` uses (positive west of UTC), or `None`
/// when the IANA name does not parse.
///
/// It reads the offset AT THE CURRENT INSTANT, so a zone that observes DST
/// answers differently either side of a transition. That is what a browser
/// reports too, and it is why `apply_geolocation` and `identity_overrides`
/// share this one implementation instead of each computing their own.
fn timezone_offset_minutes(timezone: &str) -> Option<i32> {
use chrono::Offset;
let tz = timezone.parse::<chrono_tz::Tz>().ok()?;
let offset_seconds = chrono::Utc::now()
.with_timezone(&tz)
.offset()
.fix()
.local_minus_utc();
Some(-(offset_seconds / 60))
}
/// The bare language subtag of a BCP 47 tag (`de-DE` -> `de`), which is the
/// second entry of the `languages` ladder donut builds. `Locale::language` is
/// itself the first `-`-separated part of the tag, so this reproduces it.
fn base_language(locale: &str) -> &str {
locale.split('-').next().unwrap_or(locale)
}
/// The `setIdentity` geolocation parameters carried by a stored fingerprint.
fn geo_params(
fingerprint: &serde_json::Map<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
let mut params = serde_json::Map::new();
for key in GEO_PARAM_KEYS {
if let Some(value) = fingerprint.get(key) {
if !value.is_null() {
params.insert(key.to_string(), value.clone());
}
}
}
params
}
/// 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<String, serde_json::Value>>,
) -> Option<&'static str> {
if let Some(os) = config.os.as_deref().and_then(Self::normalize_os_name) {
return Some(os);
}
stored
.and_then(|fp| fp.get("platform"))
.and_then(|v| v.as_str())
.and_then(Self::os_from_platform)
}
/// Translate a refused apply into a code the frontend can explain.
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<serde_json::Value> {
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<PathBuf, String> {
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<dyn std::error::Error + Send + Sync>> {
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<String> = 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<Vec<CdpTarget>, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("http://127.0.0.1:{port}/json");
let resp = self.http_client.get(&url).send().await?;
let targets: Vec<CdpTarget> = resp.json().await?;
Ok(targets)
}
pub(crate) async fn send_cdp_command(
&self,
ws_url: &str,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
let (mut ws_stream, _) = connect_async(ws_url).await?;
let command = json!({
"id": 1,
"method": method,
"params": params
});
use futures_util::sink::SinkExt;
use futures_util::stream::StreamExt;
ws_stream
.send(Message::Text(command.to_string().into()))
.await?;
while let Some(msg) = ws_stream.next().await {
match msg? {
Message::Text(text) => {
let response: serde_json::Value = serde_json::from_str(text.as_str())?;
if response.get("id") == Some(&json!(1)) {
if let Some(error) = response.get("error") {
return Err(format!("CDP error: {}", error).into());
}
return Ok(response.get("result").cloned().unwrap_or(json!({})));
}
}
Message::Close(_) => break,
_ => {}
}
}
Err("No response received from CDP".into())
}
/// Stable signature describing what determines this profile's geolocation
/// (timezone, latitude/longitude, language): the geoip mode first, then the
/// VPN, the proxy, or a direct connection. Compared across creation and
/// launch to detect a change. The VPN case keys off `vpn_id` rather than the
/// per-launch local port, and the proxy case off type/host/port/username so
/// that editing the proxy is also caught.
pub fn geo_signature(
proxy: Option<&crate::browser::ProxySettings>,
vpn_id: Option<&str>,
geoip: Option<&serde_json::Value>,
) -> String {
// The "v2:" prefix invalidates every signature stamped before geolocation
// failures stopped being stamped: those may describe fingerprints that
// silently carry the host's location, so each pre-v2 profile gets one
// launch-time refresh and is re-stamped in the current format.
let base = match geoip {
Some(serde_json::Value::Bool(false)) => "off".to_string(),
Some(serde_json::Value::String(ip)) if !ip.is_empty() => format!("ip:{ip}"),
_ => {
if let Some(id) = vpn_id {
format!("vpn:{id}")
} else if let Some(p) = proxy {
format!(
"proxy:{}://{}@{}:{}",
p.proxy_type.to_lowercase(),
p.username.as_deref().unwrap_or(""),
p.host,
p.port
)
} else {
"direct".to_string()
}
}
};
format!("v2:{base}")
}
/// Apply timezone/geolocation fields to a fingerprint object from the proxy's
/// exit IP (or a fixed geoip IP). Mutates `fingerprint` in place. Returns true
/// if fresh geolocation was fetched and applied, false if geolocation is
/// disabled or could not be resolved (in which case only safe defaults are
/// filled in). Shared by fingerprint generation and the launch-time refresh
/// so both produce identical location data.
async fn apply_geolocation(
fingerprint: &mut serde_json::Value,
proxy: Option<&str>,
geoip: Option<&serde_json::Value>,
) -> bool {
// Default to auto-detect; only an explicit `false` disables geolocation.
let should_geolocate = !matches!(geoip, Some(serde_json::Value::Bool(false)));
if !should_geolocate {
return false;
}
let geo_result = async {
let ip = match geoip {
Some(serde_json::Value::String(ip_str)) => ip_str.clone(),
_ => crate::ip_utils::fetch_public_ip(proxy)
.await
.map_err(|e| format!("Failed to fetch public IP: {e}"))?,
};
crate::geolocation::get_geolocation(&ip)
.map_err(|e| format!("Failed to get geolocation for IP {ip}: {e}"))
}
.await;
match geo_result {
Ok(geo) => {
if let Some(obj) = fingerprint.as_object_mut() {
obj.insert("timezone".to_string(), json!(geo.timezone));
// Both derived fields go through the same helpers `identity_overrides`
// uses to recognise them, so a value written here can never look like
// a user edit to the override diff.
if let Some(offset_minutes) = Self::timezone_offset_minutes(&geo.timezone) {
obj.insert("timezoneOffset".to_string(), json!(offset_minutes));
}
obj.insert("latitude".to_string(), json!(geo.latitude));
obj.insert("longitude".to_string(), json!(geo.longitude));
let locale_str = geo.locale.as_string();
obj.insert("language".to_string(), json!(&locale_str));
obj.insert(
"languages".to_string(),
json!([&locale_str, Self::base_language(&locale_str)]),
);
}
log::info!(
"Applied geolocation to Wayfern fingerprint: {} ({})",
geo.locale.as_string(),
geo.timezone
);
true
}
Err(e) => {
// 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<String> {
let mut fp: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?;
if Self::apply_geolocation(&mut fp, proxy, geoip).await {
serde_json::to_string(&fp).ok()
} else {
None
}
}
/// True when `url` is a socks proxy on a remote (non-loopback) host — the
/// case where reqwest's SOCKS connector can't be trusted with the
/// geolocation fetch. Loopback socks URLs are the app's own donut-proxy
/// workers, whose single-segment replies don't trigger the connector bug.
/// 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::<std::net::IpAddr>()
.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<String> {
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<dyn Error>`, 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<String>,
) -> 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<GeneratedFingerprint, Box<dyn std::error::Error + Send + Sync>> {
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::<Vec<_>>())
);
// Log timezone/geolocation fields specifically for debugging
if let Some(obj) = fingerprint.as_object() {
log::info!(
"Generated fingerprint - timezone: {:?}, timezoneOffset: {:?}, latitude: {:?}, longitude: {:?}, language: {:?}",
obj.get("timezone"),
obj.get("timezoneOffset"),
obj.get("latitude"),
obj.get("longitude"),
obj.get("language")
);
}
if use_identity_api && identity_id.is_none() {
// Without the handle the stored fingerprint is not reproducible, which
// would leave a profile that silently changes device on every launch.
// The headless browser is already torn down by the `cleanup()` above.
return Err("Wayfern.createIdentity returned no identityId".into());
}
Ok(GeneratedFingerprint {
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<u16>,
headless: bool,
kind: LaunchKind,
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
let executable_path = BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?;
let port = match remote_debugging_port {
Some(p) => p,
None => Self::find_free_port().await?,
};
log::info!("Launching Wayfern on CDP port {port} (detached)");
// Diagnostic: verify critical profile files and test cookie decryption
{
let profile_path_buf = std::path::PathBuf::from(profile_path);
let key_path = profile_path_buf.join("os_crypt_key");
let cookies_path = {
let network = profile_path_buf
.join("Default")
.join("Network")
.join("Cookies");
if network.exists() {
network
} else {
profile_path_buf.join("Default").join("Cookies")
}
};
if key_path.exists() {
// Length only. The contents are the profile's encryption key, and this
// log is the first thing a user attaches to a bug report.
let key_len = std::fs::metadata(&key_path).map(|m| m.len()).unwrap_or(0);
log::info!("Pre-launch: os_crypt_key present ({key_len} bytes)");
} else {
log::warn!("Pre-launch: os_crypt_key NOT FOUND");
}
if cookies_path.exists() {
// Try to open Cookies DB and check if encrypted cookies can be decrypted
if let Ok(conn) = rusqlite::Connection::open_with_flags(
&cookies_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
) {
let cookie_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM cookies WHERE length(encrypted_value) > 0",
[],
|r| r.get(0),
)
.unwrap_or(0);
let total_count: i64 = conn
.query_row("SELECT COUNT(*) FROM cookies", [], |r| r.get(0))
.unwrap_or(0);
log::info!(
"Pre-launch: Cookies DB has {} total cookies, {} encrypted",
total_count,
cookie_count
);
// Try decrypting one cookie using the cookie_manager
if let Some(encryption_key) =
crate::cookie_manager::chrome_decrypt::get_encryption_key(&profile_path_buf)
{
if let Ok(mut stmt) = conn.prepare(
"SELECT name, host_key, encrypted_value FROM cookies WHERE length(encrypted_value) > 0 LIMIT 1",
) {
if let Ok(mut rows) = stmt.query([]) {
if let Ok(Some(row)) = rows.next() {
let name: String = row.get(0).unwrap_or_default();
let host: String = row.get(1).unwrap_or_default();
let encrypted: Vec<u8> = row.get(2).unwrap_or_default();
let decrypted = crate::cookie_manager::chrome_decrypt::decrypt(
&encrypted,
&host,
&encryption_key,
);
match decrypted {
Some(val) => log::info!(
"Pre-launch: Cookie decryption SUCCEEDED for '{}' (host: {}, decrypted {} bytes)",
name, host, val.len()
),
None => log::error!(
"Pre-launch: Cookie decryption FAILED for '{}' (host: {}, encrypted {} bytes)",
name, host, encrypted.len()
),
}
}
}
}
} else {
log::error!("Pre-launch: Failed to derive encryption key from os_crypt_key");
}
}
} else {
log::warn!("Pre-launch: Cookies NOT FOUND");
}
}
let mut args = vec![
format!("--remote-debugging-port={port}"),
"--remote-debugging-address=127.0.0.1".to_string(),
format!("--user-data-dir={profile_path}"),
"--no-first-run".to_string(),
"--no-default-browser-check".to_string(),
"--disable-background-mode".to_string(),
"--disable-component-update".to_string(),
"--disable-background-timer-throttling".to_string(),
"--crash-server-url=".to_string(),
"--disable-updater".to_string(),
"--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<dyn std::error::Error + Send + Sync> {
let hint = if e.raw_os_error() == Some(14001) {
". This usually means the Visual C++ Redistributable is not installed. \
Download it from https://aka.ms/vs/17/release/vc_redist.x64.exe"
} else {
""
};
format!("Failed to spawn Wayfern: {e}{hint}").into()
})?;
let process_id = child.id();
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<serde_json::Value> = None;
let mut last_error: Option<String> = 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::<Vec<_>>()
);
let mut applied_ok = false;
let mut last_apply_error: Option<String> = None;
for target in &page_targets {
if let Some(ws_url) = &target.websocket_debugger_url {
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::<Vec<_>>())
);
// Log timezone and geolocation fields specifically for debugging
if let Some(obj) = fingerprint_for_cdp.as_object() {
log::info!(
"Timezone/Geolocation fields - timezone: {:?}, timezoneOffset: {:?}, latitude: {:?}, longitude: {:?}, language: {:?}, languages: {:?}",
obj.get("timezone"),
obj.get("timezoneOffset"),
obj.get("latitude"),
obj.get("longitude"),
obj.get("language"),
obj.get("languages")
);
}
// Include wayfern token if available (enables cross-OS fingerprinting for paid users)
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
// The device as donut holds it, 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<String> = None;
for target in &page_targets {
if let Some(ws_url) = &target.websocket_debugger_url {
log::info!("Applying fingerprint to page target");
match self
.send_cdp_command(ws_url, "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<dyn std::error::Error + Send + Sync>> {
// 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<u16>) -> 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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
let inner = self.inner.lock().await;
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
let port = inner
.instances
.values()
.find(|i| {
i.profile_path
.as_deref()
.map(|p| {
std::path::Path::new(p)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(p).to_path_buf())
== target_path
})
.unwrap_or(false)
})
.and_then(|i| i.cdp_port)
.ok_or("Wayfern instance (with CDP port) not found for profile")?;
drop(inner);
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<dyn std::error::Error + Send + Sync>> {
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<String> {
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<u16> {
let inner = self.inner.lock().await;
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
for instance in inner.instances.values() {
if let Some(path) = &instance.profile_path {
let instance_path = std::path::Path::new(path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
if instance_path == target_path {
return instance.cdp_port;
}
}
}
None
}
pub async fn find_wayfern_by_profile(&self, profile_path: &str) -> Option<WayfernLaunchResult> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let mut inner = self.inner.lock().await;
// Canonicalize the target path for comparison
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
// Find the instance with the matching profile path
let mut found_id: Option<String> = None;
for (id, instance) in &inner.instances {
if let Some(path) = &instance.profile_path {
let instance_path = std::path::Path::new(path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
if instance_path == target_path {
found_id = Some(id.clone());
break;
}
}
}
// If we found an instance, verify the process is still running
if let Some(id) = found_id {
if let Some(instance) = inner.instances.get(&id) {
if let Some(pid) = instance.process_id {
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let sysinfo_pid = sysinfo::Pid::from_u32(pid);
if system.process(sysinfo_pid).is_some() {
return Some(WayfernLaunchResult {
id: id.clone(),
processId: instance.process_id,
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
cdp_port: instance.cdp_port,
});
} 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<u16>)> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let target_path_str = target_path.to_string_lossy();
for (pid, process) in system.processes() {
let cmd = process.cmd();
if cmd.is_empty() {
continue;
}
let exe_name = process.name().to_string_lossy().to_lowercase();
let is_chromium_like = exe_name.contains("wayfern")
|| exe_name.contains("chromium")
|| exe_name.contains("chrome");
if !is_chromium_like {
continue;
}
// Skip child processes (renderer, GPU, utility, zygote, etc.)
// Only the main browser process lacks a --type= argument
let is_child = cmd
.iter()
.any(|a| a.to_str().is_some_and(|s| s.starts_with("--type=")));
if is_child {
continue;
}
let mut matched = false;
let mut cdp_port: Option<u16> = None;
for arg in cmd.iter() {
if let Some(arg_str) = arg.to_str() {
if let Some(dir_val) = arg_str.strip_prefix("--user-data-dir=") {
let cmd_path = std::path::Path::new(dir_val)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(dir_val).to_path_buf());
if cmd_path == target_path {
matched = true;
}
}
if let Some(port_val) = arg_str.strip_prefix("--remote-debugging-port=") {
cdp_port = port_val.parse().ok();
}
}
}
if matched {
return Some((pid.as_u32(), target_path_str.to_string(), cdp_port));
}
}
None
}
#[allow(dead_code)]
pub async fn launch_wayfern_profile(
&self,
app_handle: &AppHandle,
profile: &BrowserProfile,
config: &WayfernConfig,
url: Option<&str>,
proxy_url: Option<&str>,
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
let profile_path_str = profile_path.to_string_lossy().to_string();
std::fs::create_dir_all(&profile_path)?;
if let Some(existing) = self.find_wayfern_by_profile(&profile_path_str).await {
log::info!("Stopping existing Wayfern instance for profile");
self.stop_wayfern(&existing.id).await?;
}
self
.launch_wayfern(
app_handle,
profile,
&profile_path_str,
config,
url,
proxy_url,
profile.ephemeral,
&[],
None,
false,
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<String, serde_json::Value> {
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(&current, &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(&current, &baseline);
assert_eq!(overrides.len(), 1);
assert_eq!(overrides.get("doNotTrack"), Some(&json!("1")));
let geo = WayfernManager::geo_params(&current);
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(&current, &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(&current, &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(&current, &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::<serde_json::Value>(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)
);
}
}