use crate::browser_runner::BrowserRunner; use crate::profile::BrowserProfile; use directories::BaseDirs; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; use std::time::Duration; use tauri::AppHandle; use tokio::process::Command as TokioCommand; use tokio::sync::Mutex as AsyncMutex; use tokio_tungstenite::{connect_async, tungstenite::Message}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct WayfernConfig { #[serde(default)] pub fingerprint: Option, #[serde(default)] pub randomize_fingerprint_on_launch: Option, #[serde(default)] pub os: Option, #[serde(default)] pub screen_max_width: Option, #[serde(default)] pub screen_max_height: Option, #[serde(default)] pub screen_min_width: Option, #[serde(default)] pub screen_min_height: Option, #[serde(default)] pub geoip: Option, // For compatibility with shared config form #[serde(default)] pub block_images: Option, // For compatibility with shared config form #[serde(default)] pub block_webrtc: Option, #[serde(default)] pub block_webgl: Option, #[serde(default)] pub executable_path: Option, #[serde(default, skip_serializing)] pub proxy: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[allow(non_snake_case)] pub struct WayfernLaunchResult { pub id: String, #[serde(alias = "process_id")] pub processId: Option, #[serde(alias = "profile_path")] pub profilePath: Option, pub url: Option, pub cdp_port: Option, } #[derive(Debug)] struct WayfernInstance { #[allow(dead_code)] id: String, process_id: Option, profile_path: Option, url: Option, cdp_port: Option, } struct WayfernManagerInner { instances: HashMap, } pub struct WayfernManager { inner: Arc>, #[allow(dead_code)] base_dirs: BaseDirs, http_client: Client, } #[derive(Debug, Deserialize)] struct CdpTarget { #[serde(rename = "type")] target_type: String, #[serde(rename = "webSocketDebuggerUrl")] websocket_debugger_url: Option, } impl WayfernManager { fn new() -> Self { Self { inner: Arc::new(AsyncMutex::new(WayfernManagerInner { instances: HashMap::new(), })), base_dirs: BaseDirs::new().expect("Failed to get base directories"), http_client: Client::new(), } } pub fn instance() -> &'static WayfernManager { &WAYFERN_MANAGER } #[allow(dead_code)] pub fn get_profiles_dir(&self) -> PathBuf { let mut path = self.base_dirs.data_local_dir().to_path_buf(); path.push(if cfg!(debug_assertions) { "DonutBrowserDev" } else { "DonutBrowser" }); path.push("profiles"); path } #[allow(dead_code)] fn get_binaries_dir(&self) -> PathBuf { let mut path = self.base_dirs.data_local_dir().to_path_buf(); path.push(if cfg!(debug_assertions) { "DonutBrowserDev" } else { "DonutBrowser" }); path.push("binaries"); path } async fn find_free_port() -> Result> { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; let port = listener.local_addr()?.port(); drop(listener); Ok(port) } /// Normalize fingerprint data from Wayfern CDP format to our storage format. /// Wayfern returns fields like fonts, webglParameters as JSON strings which we keep as-is. fn normalize_fingerprint(fingerprint: serde_json::Value) -> serde_json::Value { // Our storage format matches what Wayfern returns: // - fonts, plugins, mimeTypes, voices are JSON strings // - webglParameters, webgl2Parameters, etc. are JSON strings // The form displays them as JSON text areas, so no conversion needed. fingerprint } /// Denormalize fingerprint data from our storage format to Wayfern CDP format. /// Wayfern expects certain fields as JSON strings. fn denormalize_fingerprint(fingerprint: serde_json::Value) -> serde_json::Value { // Our storage format matches what Wayfern expects: // - fonts, plugins, mimeTypes, voices are JSON strings // - webglParameters, webgl2Parameters, etc. are JSON strings // So no conversion is needed fingerprint } async fn wait_for_cdp_ready( &self, port: u16, ) -> Result<(), Box> { let url = format!("http://127.0.0.1:{port}/json/version"); let max_attempts = 50; let delay = Duration::from_millis(100); for attempt in 0..max_attempts { match self.http_client.get(&url).send().await { Ok(resp) if resp.status().is_success() => { log::info!("CDP ready on port {port} after {attempt} attempts"); return Ok(()); } _ => { tokio::time::sleep(delay).await; } } } Err(format!("CDP not ready after {max_attempts} attempts on port {port}").into()) } async fn get_cdp_targets( &self, port: u16, ) -> Result, Box> { let url = format!("http://127.0.0.1:{port}/json"); let resp = self.http_client.get(&url).send().await?; let targets: Vec = resp.json().await?; Ok(targets) } async fn send_cdp_command( &self, ws_url: &str, method: &str, params: serde_json::Value, ) -> Result> { let (mut ws_stream, _) = connect_async(ws_url).await?; let command = json!({ "id": 1, "method": method, "params": params }); use futures_util::sink::SinkExt; use futures_util::stream::StreamExt; ws_stream .send(Message::Text(command.to_string().into())) .await?; while let Some(msg) = ws_stream.next().await { match msg? { Message::Text(text) => { let response: serde_json::Value = serde_json::from_str(text.as_str())?; if response.get("id") == Some(&json!(1)) { if let Some(error) = response.get("error") { return Err(format!("CDP error: {}", error).into()); } return Ok(response.get("result").cloned().unwrap_or(json!({}))); } } Message::Close(_) => break, _ => {} } } Err("No response received from CDP".into()) } pub async fn generate_fingerprint_config( &self, _app_handle: &AppHandle, profile: &BrowserProfile, config: &WayfernConfig, ) -> Result> { let executable_path = if let Some(path) = &config.executable_path { let p = PathBuf::from(path); if p.exists() { p } else { log::warn!("Stored Wayfern executable path does not exist: {path}, falling back to dynamic resolution"); BrowserRunner::instance() .get_browser_executable_path(profile) .map_err(|e| format!("Failed to get Wayfern executable path: {e}"))? } } else { BrowserRunner::instance() .get_browser_executable_path(profile) .map_err(|e| format!("Failed to get Wayfern executable path: {e}"))? }; let port = Self::find_free_port().await?; log::info!("Launching headless Wayfern on port {port} for fingerprint generation"); let temp_profile_dir = std::env::temp_dir().join(format!("wayfern_fingerprint_{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&temp_profile_dir)?; let mut cmd = TokioCommand::new(&executable_path); cmd .arg("--headless=new") .arg(format!("--remote-debugging-port={port}")) .arg("--remote-debugging-address=127.0.0.1") .arg(format!("--user-data-dir={}", temp_profile_dir.display())) .arg("--disable-gpu") .arg("--no-first-run") .arg("--no-default-browser-check") .arg("--disable-background-mode") .stdout(Stdio::null()) .stderr(Stdio::null()); let child = cmd.spawn()?; let child_id = child.id(); let cleanup = || async { if let Some(id) = child_id { #[cfg(unix)] { use nix::sys::signal::{kill, Signal}; use nix::unistd::Pid; let _ = kill(Pid::from_raw(id as i32), Signal::SIGTERM); } #[cfg(windows)] { let _ = std::process::Command::new("taskkill") .args(["/PID", &id.to_string(), "/F"]) .output(); } } let _ = std::fs::remove_dir_all(&temp_profile_dir); }; if let Err(e) = self.wait_for_cdp_ready(port).await { cleanup().await; return Err(e); } let targets = match self.get_cdp_targets(port).await { Ok(t) => t, Err(e) => { cleanup().await; return Err(e); } }; let page_target = targets .iter() .find(|t| t.target_type == "page" && t.websocket_debugger_url.is_some()); let ws_url = match page_target { Some(target) => target.websocket_debugger_url.as_ref().unwrap().clone(), None => { cleanup().await; return Err("No page target found for CDP".into()); } }; let os = config .os .as_deref() .unwrap_or(if cfg!(target_os = "macos") { "macos" } else if cfg!(target_os = "linux") { "linux" } else { "windows" }); let refresh_result = self .send_cdp_command( &ws_url, "Wayfern.refreshFingerprint", json!({ "operatingSystem": os }), ) .await; if let Err(e) = refresh_result { cleanup().await; return Err(format!("Failed to refresh fingerprint: {e}").into()); } let get_result = self .send_cdp_command(&ws_url, "Wayfern.getFingerprint", json!({})) .await; let fingerprint = match get_result { Ok(result) => { // Wayfern.getFingerprint returns { fingerprint: {...} } // We need to extract just the fingerprint object let fp = result.get("fingerprint").cloned().unwrap_or(result); // Normalize the fingerprint: convert JSON string fields to proper types let mut normalized = Self::normalize_fingerprint(fp); // Add default timezone/geolocation if not present // Wayfern's Bayesian network generator doesn't include these fields, // so we need to add sensible defaults if let Some(obj) = normalized.as_object_mut() { if !obj.contains_key("timezone") { obj.insert("timezone".to_string(), json!("America/New_York")); } if !obj.contains_key("timezoneOffset") { obj.insert("timezoneOffset".to_string(), json!(300)); // EST = UTC-5 = 300 minutes } // Note: latitude/longitude are intentionally not set by default // as they reveal precise location. Users should set these manually if needed. } normalized } Err(e) => { cleanup().await; return Err(format!("Failed to get fingerprint: {e}").into()); } }; cleanup().await; let fingerprint_json = serde_json::to_string(&fingerprint) .map_err(|e| format!("Failed to serialize fingerprint: {e}"))?; log::info!( "Generated Wayfern fingerprint for OS: {}, fields: {:?}", os, fingerprint .as_object() .map(|o| o.keys().collect::>()) ); // Log timezone/geolocation fields specifically for debugging if let Some(obj) = fingerprint.as_object() { log::info!( "Generated fingerprint - timezone: {:?}, timezoneOffset: {:?}, latitude: {:?}, longitude: {:?}, language: {:?}", obj.get("timezone"), obj.get("timezoneOffset"), obj.get("latitude"), obj.get("longitude"), obj.get("language") ); } Ok(fingerprint_json) } pub async fn launch_wayfern( &self, _app_handle: &AppHandle, profile: &BrowserProfile, profile_path: &str, config: &WayfernConfig, url: Option<&str>, proxy_url: Option<&str>, ) -> Result> { let executable_path = if let Some(path) = &config.executable_path { let p = PathBuf::from(path); if p.exists() { p } else { log::warn!("Stored Wayfern executable path does not exist: {path}, falling back to dynamic resolution"); BrowserRunner::instance() .get_browser_executable_path(profile) .map_err(|e| format!("Failed to get Wayfern executable path: {e}"))? } } else { BrowserRunner::instance() .get_browser_executable_path(profile) .map_err(|e| format!("Failed to get Wayfern executable path: {e}"))? }; let port = Self::find_free_port().await?; log::info!("Launching Wayfern on CDP port {port}"); let mut args = vec![ format!("--remote-debugging-port={port}"), "--remote-debugging-address=127.0.0.1".to_string(), format!("--user-data-dir={}", profile_path), "--no-first-run".to_string(), "--no-default-browser-check".to_string(), "--disable-background-mode".to_string(), "--disable-component-update".to_string(), "--disable-background-timer-throttling".to_string(), "--crash-server-url=".to_string(), "--disable-updater".to_string(), "--disable-session-crashed-bubble".to_string(), "--hide-crash-restore-bubble".to_string(), "--disable-infobars".to_string(), "--disable-quic".to_string(), "--disable-features=DialMediaRouteProvider".to_string(), "--use-mock-keychain".to_string(), "--password-store=basic".to_string(), ]; if let Some(proxy) = proxy_url { args.push(format!("--proxy-server={proxy}")); } // Don't add URL to args - we'll navigate via CDP after setting fingerprint // This ensures fingerprint is applied at navigation commit time let mut cmd = TokioCommand::new(&executable_path); cmd.args(&args); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); let child = cmd.spawn()?; let process_id = child.id(); self.wait_for_cdp_ready(port).await?; // Get CDP targets first - needed for both fingerprint and navigation let targets = self.get_cdp_targets(port).await?; log::info!("Found {} CDP targets", targets.len()); let page_targets: Vec<_> = targets.iter().filter(|t| t.target_type == "page").collect(); log::info!("Found {} page targets", page_targets.len()); // Apply fingerprint if configured if let Some(fingerprint_json) = &config.fingerprint { log::info!( "Applying fingerprint to Wayfern browser, fingerprint length: {} chars", fingerprint_json.len() ); let stored_value: serde_json::Value = serde_json::from_str(fingerprint_json) .map_err(|e| format!("Failed to parse stored fingerprint JSON: {e}"))?; // The stored fingerprint should be the fingerprint object directly (after our fix in generate_fingerprint_config) // But for backwards compatibility, also handle the wrapped format let mut fingerprint = if stored_value.get("fingerprint").is_some() { // Old format: {"fingerprint": {...}} - extract the inner fingerprint stored_value.get("fingerprint").cloned().unwrap() } else { // New format: fingerprint object directly {...} stored_value.clone() }; // Add default timezone if not present (for profiles created before timezone was added) if let Some(obj) = fingerprint.as_object_mut() { if !obj.contains_key("timezone") { obj.insert("timezone".to_string(), json!("America/New_York")); log::info!("Added default timezone to fingerprint"); } if !obj.contains_key("timezoneOffset") { obj.insert("timezoneOffset".to_string(), json!(300)); log::info!("Added default timezoneOffset to fingerprint"); } } // Denormalize fingerprint for Wayfern CDP (convert arrays/objects to JSON strings) let fingerprint_for_cdp = Self::denormalize_fingerprint(fingerprint); log::info!( "Fingerprint prepared for CDP command, fields: {:?}", fingerprint_for_cdp .as_object() .map(|o| o.keys().collect::>()) ); // Log timezone and geolocation fields specifically for debugging if let Some(obj) = fingerprint_for_cdp.as_object() { log::info!( "Timezone/Geolocation fields - timezone: {:?}, timezoneOffset: {:?}, latitude: {:?}, longitude: {:?}, language: {:?}, languages: {:?}", obj.get("timezone"), obj.get("timezoneOffset"), obj.get("latitude"), obj.get("longitude"), obj.get("language"), obj.get("languages") ); } for target in &page_targets { if let Some(ws_url) = &target.websocket_debugger_url { log::info!("Applying fingerprint to target via WebSocket: {}", ws_url); // Wayfern.setFingerprint expects the fingerprint object directly, NOT wrapped match self .send_cdp_command( ws_url, "Wayfern.setFingerprint", fingerprint_for_cdp.clone(), ) .await { Ok(result) => log::info!( "Successfully applied fingerprint to page target: {:?}", result ), Err(e) => log::error!("Failed to apply fingerprint to target: {e}"), } } } } else { log::warn!("No fingerprint found in config, browser will use default fingerprint"); } // Navigate to URL via CDP - fingerprint will be applied at navigation commit time if let Some(url) = url { log::info!("Navigating to URL via CDP: {}", url); if let Some(target) = page_targets.first() { if let Some(ws_url) = &target.websocket_debugger_url { match self .send_cdp_command(ws_url, "Page.navigate", json!({ "url": url })) .await { Ok(_) => log::info!("Successfully navigated to URL: {}", url), Err(e) => log::error!("Failed to navigate to URL: {e}"), } } } } let id = uuid::Uuid::new_v4().to_string(); let instance = WayfernInstance { id: id.clone(), process_id, profile_path: Some(profile_path.to_string()), url: url.map(|s| s.to_string()), cdp_port: Some(port), }; let mut inner = self.inner.lock().await; inner.instances.insert(id.clone(), instance); Ok(WayfernLaunchResult { id, processId: process_id, profilePath: Some(profile_path.to_string()), url: url.map(|s| s.to_string()), cdp_port: Some(port), }) } pub async fn stop_wayfern( &self, id: &str, ) -> Result<(), Box> { let mut inner = self.inner.lock().await; if let Some(instance) = inner.instances.remove(id) { if let Some(pid) = instance.process_id { #[cfg(unix)] { use nix::sys::signal::{kill, Signal}; use nix::unistd::Pid; let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM); } #[cfg(windows)] { let _ = std::process::Command::new("taskkill") .args(["/PID", &pid.to_string(), "/F"]) .output(); } log::info!("Stopped Wayfern instance {id} (PID: {pid})"); } } Ok(()) } /// Opens a URL in a new tab for an existing Wayfern instance using CDP. /// Returns Ok(()) if successful, or an error if the instance is not found or CDP fails. pub async fn open_url_in_tab( &self, profile_path: &str, url: &str, ) -> Result<(), Box> { let instance = self .find_wayfern_by_profile(profile_path) .await .ok_or("Wayfern instance not found for profile")?; let cdp_port = instance .cdp_port .ok_or("No CDP port available for Wayfern instance")?; // Get the browser target to create a new tab let targets = self.get_cdp_targets(cdp_port).await?; // Find a page target to get the WebSocket URL (we need any target to send commands) let page_target = targets .iter() .find(|t| t.target_type == "page" && t.websocket_debugger_url.is_some()) .ok_or("No page target found for CDP")?; let ws_url = page_target .websocket_debugger_url .as_ref() .ok_or("No WebSocket URL available")?; // Use Target.createTarget to open a new tab with the URL self .send_cdp_command(ws_url, "Target.createTarget", json!({ "url": url })) .await?; log::info!("Opened URL in new tab via CDP: {}", url); Ok(()) } pub async fn find_wayfern_by_profile(&self, profile_path: &str) -> Option { use sysinfo::{ProcessRefreshKind, RefreshKind, System}; let mut inner = self.inner.lock().await; // Find the instance with the matching profile path let mut found_id: Option = None; for (id, instance) in &inner.instances { if let Some(path) = &instance.profile_path { if path == profile_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() { // Process is still running 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 { // Process has died (e.g., Cmd+Q), remove from instances log::info!( "Wayfern process {} for profile {} is no longer running, cleaning up", pid, profile_path ); inner.instances.remove(&id); return None; } } } } None } #[allow(dead_code)] pub async fn launch_wayfern_profile( &self, app_handle: &AppHandle, profile: &BrowserProfile, config: &WayfernConfig, url: Option<&str>, proxy_url: Option<&str>, ) -> Result> { let profiles_dir = self.get_profiles_dir(); let profile_path = profiles_dir.join(profile.id.to_string()).join("profile"); let profile_path_str = profile_path.to_string_lossy().to_string(); std::fs::create_dir_all(&profile_path)?; if let Some(existing) = self.find_wayfern_by_profile(&profile_path_str).await { log::info!("Stopping existing Wayfern instance for profile"); self.stop_wayfern(&existing.id).await?; } self .launch_wayfern( app_handle, profile, &profile_path_str, config, url, proxy_url, ) .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); } } } lazy_static::lazy_static! { static ref WAYFERN_MANAGER: WayfernManager = WayfernManager::new(); }