mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-05 22:56:34 +02:00
1201 lines
40 KiB
Rust
1201 lines
40 KiB
Rust
use crate::browser_runner::BrowserRunner;
|
|
use crate::profile::BrowserProfile;
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::process::Stdio;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tauri::AppHandle;
|
|
use tokio::process::Command as TokioCommand;
|
|
use tokio::sync::Mutex as AsyncMutex;
|
|
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct WayfernConfig {
|
|
#[serde(default)]
|
|
pub fingerprint: Option<String>,
|
|
#[serde(default)]
|
|
pub randomize_fingerprint_on_launch: Option<bool>,
|
|
#[serde(default)]
|
|
pub os: Option<String>,
|
|
#[serde(default)]
|
|
pub screen_max_width: Option<u32>,
|
|
#[serde(default)]
|
|
pub screen_max_height: Option<u32>,
|
|
#[serde(default)]
|
|
pub screen_min_width: Option<u32>,
|
|
#[serde(default)]
|
|
pub screen_min_height: Option<u32>,
|
|
#[serde(default)]
|
|
pub geoip: Option<serde_json::Value>, // For compatibility with shared config form
|
|
#[serde(default)]
|
|
pub block_images: Option<bool>, // For compatibility with shared config form
|
|
#[serde(default)]
|
|
pub block_webrtc: Option<bool>,
|
|
#[serde(default)]
|
|
pub block_webgl: Option<bool>,
|
|
#[serde(default, skip_serializing)]
|
|
pub proxy: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[allow(non_snake_case)]
|
|
pub struct WayfernLaunchResult {
|
|
pub id: String,
|
|
#[serde(alias = "process_id")]
|
|
pub processId: Option<u32>,
|
|
#[serde(alias = "profile_path")]
|
|
pub profilePath: Option<String>,
|
|
pub url: Option<String>,
|
|
pub cdp_port: Option<u16>,
|
|
/// The fingerprint Wayfern actually applied, echoed back by
|
|
/// Wayfern.setFingerprint. It may be UPGRADED from the stored fingerprint
|
|
/// (e.g. when the stored one targets an older browser version). Internal
|
|
/// only — the caller persists it to the profile; never sent to the frontend.
|
|
#[serde(default, skip_serializing)]
|
|
pub used_fingerprint: Option<String>,
|
|
}
|
|
|
|
struct WayfernInstance {
|
|
id: String,
|
|
process_id: Option<u32>,
|
|
profile_path: Option<String>,
|
|
url: Option<String>,
|
|
cdp_port: Option<u16>,
|
|
}
|
|
|
|
struct WayfernManagerInner {
|
|
instances: HashMap<String, WayfernInstance>,
|
|
}
|
|
|
|
pub struct WayfernManager {
|
|
inner: Arc<AsyncMutex<WayfernManagerInner>>,
|
|
http_client: Client,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct CdpTarget {
|
|
#[serde(rename = "type")]
|
|
target_type: String,
|
|
#[serde(rename = "webSocketDebuggerUrl")]
|
|
websocket_debugger_url: Option<String>,
|
|
}
|
|
|
|
impl WayfernManager {
|
|
fn new() -> Self {
|
|
Self {
|
|
inner: Arc::new(AsyncMutex::new(WayfernManagerInner {
|
|
instances: HashMap::new(),
|
|
})),
|
|
http_client: Client::builder()
|
|
.timeout(Duration::from_secs(2))
|
|
.build()
|
|
.expect("Failed to build reqwest client for wayfern_manager"),
|
|
}
|
|
}
|
|
|
|
pub fn instance() -> &'static WayfernManager {
|
|
&WAYFERN_MANAGER
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn get_profiles_dir(&self) -> PathBuf {
|
|
crate::app_dirs::profiles_dir()
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn get_binaries_dir(&self) -> PathBuf {
|
|
crate::app_dirs::binaries_dir()
|
|
}
|
|
|
|
async fn find_free_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
|
let port = listener.local_addr()?.port();
|
|
drop(listener);
|
|
Ok(port)
|
|
}
|
|
|
|
/// Normalize fingerprint data from Wayfern CDP format to our storage format.
|
|
/// Wayfern returns fields like fonts, webglParameters as JSON strings which we keep as-is.
|
|
fn normalize_fingerprint(fingerprint: serde_json::Value) -> serde_json::Value {
|
|
// Our storage format matches what Wayfern returns:
|
|
// - fonts, plugins, mimeTypes, voices are JSON strings
|
|
// - webglParameters, webgl2Parameters, etc. are JSON strings
|
|
// The form displays them as JSON text areas, so no conversion needed.
|
|
fingerprint
|
|
}
|
|
|
|
/// Denormalize fingerprint data from our storage format to Wayfern CDP format.
|
|
/// Wayfern expects certain fields as JSON strings.
|
|
fn denormalize_fingerprint(fingerprint: serde_json::Value) -> serde_json::Value {
|
|
// Our storage format matches what Wayfern expects:
|
|
// - fonts, plugins, mimeTypes, voices are JSON strings
|
|
// - webglParameters, webgl2Parameters, etc. are JSON strings
|
|
// So no conversion is needed
|
|
fingerprint
|
|
}
|
|
|
|
async fn wait_for_cdp_ready(
|
|
&self,
|
|
port: u16,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let url = format!("http://127.0.0.1:{port}/json/version");
|
|
// On first launch, macOS Gatekeeper verifies the binary which can take 30+ seconds.
|
|
// Use a generous timeout (60s) to handle this.
|
|
let max_attempts = 120;
|
|
let delay = Duration::from_millis(500);
|
|
|
|
let mut last_error: Option<String> = None;
|
|
for attempt in 0..max_attempts {
|
|
match self.http_client.get(&url).send().await {
|
|
Ok(resp) if resp.status().is_success() => {
|
|
log::info!("CDP ready on port {port} after {attempt} attempts");
|
|
return Ok(());
|
|
}
|
|
Ok(resp) => {
|
|
last_error = Some(format!("HTTP {} from {url}", resp.status()));
|
|
tokio::time::sleep(delay).await;
|
|
}
|
|
Err(e) => {
|
|
last_error = Some(format!("request failed: {e}"));
|
|
tokio::time::sleep(delay).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
let detail = last_error.unwrap_or_else(|| "no attempts completed".to_string());
|
|
// Log at error level so we can diagnose Windows/AV/firewall-induced CDP hangs
|
|
// in customer reports without needing them to reproduce in the moment.
|
|
log::error!("CDP not ready after {max_attempts} attempts on port {port}: {detail}");
|
|
Err(format!("CDP not ready after {max_attempts} attempts on port {port}: {detail}").into())
|
|
}
|
|
|
|
async fn get_cdp_targets(
|
|
&self,
|
|
port: u16,
|
|
) -> Result<Vec<CdpTarget>, Box<dyn std::error::Error + Send + Sync>> {
|
|
let url = format!("http://127.0.0.1:{port}/json");
|
|
let resp = self.http_client.get(&url).send().await?;
|
|
let targets: Vec<CdpTarget> = resp.json().await?;
|
|
Ok(targets)
|
|
}
|
|
|
|
async fn send_cdp_command(
|
|
&self,
|
|
ws_url: &str,
|
|
method: &str,
|
|
params: serde_json::Value,
|
|
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
|
let (mut ws_stream, _) = connect_async(ws_url).await?;
|
|
|
|
let command = json!({
|
|
"id": 1,
|
|
"method": method,
|
|
"params": params
|
|
});
|
|
|
|
use futures_util::sink::SinkExt;
|
|
use futures_util::stream::StreamExt;
|
|
|
|
ws_stream
|
|
.send(Message::Text(command.to_string().into()))
|
|
.await?;
|
|
|
|
while let Some(msg) = ws_stream.next().await {
|
|
match msg? {
|
|
Message::Text(text) => {
|
|
let response: serde_json::Value = serde_json::from_str(text.as_str())?;
|
|
if response.get("id") == Some(&json!(1)) {
|
|
if let Some(error) = response.get("error") {
|
|
return Err(format!("CDP error: {}", error).into());
|
|
}
|
|
return Ok(response.get("result").cloned().unwrap_or(json!({})));
|
|
}
|
|
}
|
|
Message::Close(_) => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
Err("No response received from CDP".into())
|
|
}
|
|
|
|
pub async fn generate_fingerprint_config(
|
|
&self,
|
|
_app_handle: &AppHandle,
|
|
profile: &BrowserProfile,
|
|
config: &WayfernConfig,
|
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
|
let executable_path = BrowserRunner::instance()
|
|
.get_browser_executable_path(profile)
|
|
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?;
|
|
|
|
let port = Self::find_free_port().await?;
|
|
log::info!("Launching headless Wayfern on port {port} for fingerprint generation");
|
|
|
|
let temp_profile_dir =
|
|
std::env::temp_dir().join(format!("wayfern_fingerprint_{}", uuid::Uuid::new_v4()));
|
|
std::fs::create_dir_all(&temp_profile_dir)?;
|
|
|
|
let mut cmd = TokioCommand::new(&executable_path);
|
|
cmd
|
|
.arg("--headless=new")
|
|
.arg(format!("--remote-debugging-port={port}"))
|
|
.arg("--remote-debugging-address=127.0.0.1")
|
|
.arg(format!("--user-data-dir={}", temp_profile_dir.display()))
|
|
.arg("--disable-gpu")
|
|
.arg("--no-first-run")
|
|
.arg("--no-default-browser-check")
|
|
.arg("--disable-background-mode")
|
|
.arg("--use-mock-keychain")
|
|
.arg("--password-store=basic")
|
|
.arg("--disable-features=DialMediaRouteProvider");
|
|
|
|
#[cfg(target_os = "linux")]
|
|
cmd
|
|
.arg("--no-sandbox")
|
|
.arg("--disable-setuid-sandbox")
|
|
.arg("--disable-dev-shm-usage");
|
|
|
|
cmd.stdout(Stdio::null()).stderr(Stdio::piped());
|
|
|
|
let child = cmd.spawn().map_err(|e| {
|
|
// OS error 14001 = SxS / missing Visual C++ Redistributable
|
|
let hint = if e.raw_os_error() == Some(14001) {
|
|
". This usually means the Visual C++ Redistributable is not installed. \
|
|
Download it from https://aka.ms/vs/17/release/vc_redist.x64.exe"
|
|
} else {
|
|
""
|
|
};
|
|
format!("Failed to spawn headless Wayfern: {e}{hint}")
|
|
})?;
|
|
let child_id = child.id();
|
|
|
|
let cleanup = || async {
|
|
if let Some(id) = child_id {
|
|
#[cfg(unix)]
|
|
{
|
|
use nix::sys::signal::{kill, Signal};
|
|
use nix::unistd::Pid;
|
|
let _ = kill(Pid::from_raw(id as i32), Signal::SIGTERM);
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt;
|
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
let _ = std::process::Command::new("taskkill")
|
|
.args(["/PID", &id.to_string(), "/F"])
|
|
.creation_flags(CREATE_NO_WINDOW)
|
|
.output();
|
|
}
|
|
}
|
|
let _ = std::fs::remove_dir_all(&temp_profile_dir);
|
|
};
|
|
|
|
if let Err(e) = self.wait_for_cdp_ready(port).await {
|
|
// Try to capture stderr from the failed process for diagnostics
|
|
let stderr_output = if let Some(id) = child_id {
|
|
// Check if process is still running
|
|
let is_running = sysinfo::System::new_with_specifics(
|
|
sysinfo::RefreshKind::nothing().with_processes(sysinfo::ProcessRefreshKind::nothing()),
|
|
)
|
|
.process(sysinfo::Pid::from(id as usize))
|
|
.is_some();
|
|
|
|
if !is_running {
|
|
// Process exited — try to read its stderr
|
|
String::from("(process exited before CDP became ready)")
|
|
} else {
|
|
String::from("(process still running but not responding on CDP)")
|
|
}
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
log::error!(
|
|
"Fingerprint-generation Wayfern (headless, pid={child_id:?}) never became CDP-ready: {e}. {stderr_output}"
|
|
);
|
|
cleanup().await;
|
|
return Err(e);
|
|
}
|
|
|
|
let targets = match self.get_cdp_targets(port).await {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
cleanup().await;
|
|
return Err(e);
|
|
}
|
|
};
|
|
|
|
let page_target = targets
|
|
.iter()
|
|
.find(|t| t.target_type == "page" && t.websocket_debugger_url.is_some());
|
|
|
|
let ws_url = match page_target {
|
|
Some(target) => target.websocket_debugger_url.as_ref().unwrap().clone(),
|
|
None => {
|
|
cleanup().await;
|
|
return Err("No page target found for CDP".into());
|
|
}
|
|
};
|
|
|
|
let os = config
|
|
.os
|
|
.as_deref()
|
|
.unwrap_or(if cfg!(target_os = "macos") {
|
|
"macos"
|
|
} else if cfg!(target_os = "linux") {
|
|
"linux"
|
|
} else {
|
|
"windows"
|
|
});
|
|
|
|
// 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 refresh_params = json!({ "operatingSystem": os });
|
|
if let Some(ref token) = wayfern_token {
|
|
refresh_params
|
|
.as_object_mut()
|
|
.unwrap()
|
|
.insert("wayfernToken".to_string(), json!(token));
|
|
}
|
|
|
|
let refresh_result = self
|
|
.send_cdp_command(&ws_url, "Wayfern.refreshFingerprint", refresh_params)
|
|
.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);
|
|
|
|
// Apply geolocation based on proxy IP or geoip config
|
|
let geoip_option = config.geoip.as_ref();
|
|
let should_geolocate = match geoip_option {
|
|
Some(serde_json::Value::Bool(false)) => false,
|
|
_ => true, // Default to auto-detect
|
|
};
|
|
|
|
if should_geolocate {
|
|
let geo_result = async {
|
|
let ip = match geoip_option {
|
|
Some(serde_json::Value::String(ip_str)) => ip_str.clone(),
|
|
_ => {
|
|
// Auto-detect IP, optionally through proxy
|
|
crate::ip_utils::fetch_public_ip(config.proxy.as_deref())
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch public IP: {e}"))?
|
|
}
|
|
};
|
|
|
|
crate::camoufox::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) = normalized.as_object_mut() {
|
|
obj.insert("timezone".to_string(), json!(geo.timezone));
|
|
// Calculate timezone offset from IANA timezone name
|
|
if let Ok(tz) = geo.timezone.parse::<chrono_tz::Tz>() {
|
|
use chrono::Offset;
|
|
let now = chrono::Utc::now().with_timezone(&tz);
|
|
let offset_seconds = now.offset().fix().local_minus_utc();
|
|
let offset_minutes = -(offset_seconds / 60);
|
|
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, &geo.locale.language]),
|
|
);
|
|
}
|
|
log::info!(
|
|
"Applied geolocation to Wayfern fingerprint: {} ({})",
|
|
geo.locale.as_string(),
|
|
geo.timezone
|
|
);
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Geolocation failed, using defaults: {e}");
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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::<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")
|
|
);
|
|
}
|
|
|
|
Ok(fingerprint_json)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn launch_wayfern(
|
|
&self,
|
|
_app_handle: &AppHandle,
|
|
profile: &BrowserProfile,
|
|
profile_path: &str,
|
|
config: &WayfernConfig,
|
|
url: Option<&str>,
|
|
proxy_url: Option<&str>,
|
|
ephemeral: bool,
|
|
extension_paths: &[String],
|
|
remote_debugging_port: Option<u16>,
|
|
headless: bool,
|
|
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
let executable_path = BrowserRunner::instance()
|
|
.get_browser_executable_path(profile)
|
|
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?;
|
|
|
|
let port = match remote_debugging_port {
|
|
Some(p) => p,
|
|
None => Self::find_free_port().await?,
|
|
};
|
|
log::info!("Launching Wayfern on CDP port {port} (detached)");
|
|
|
|
// Diagnostic: verify critical profile files and test cookie decryption
|
|
{
|
|
let profile_path_buf = std::path::PathBuf::from(profile_path);
|
|
let key_path = profile_path_buf.join("os_crypt_key");
|
|
let cookies_path = {
|
|
let network = profile_path_buf
|
|
.join("Default")
|
|
.join("Network")
|
|
.join("Cookies");
|
|
if network.exists() {
|
|
network
|
|
} else {
|
|
profile_path_buf.join("Default").join("Cookies")
|
|
}
|
|
};
|
|
|
|
if key_path.exists() {
|
|
let key_text = std::fs::read_to_string(&key_path).unwrap_or_default();
|
|
log::info!(
|
|
"Pre-launch: os_crypt_key present ({} bytes, content: '{}')",
|
|
key_text.len(),
|
|
key_text.trim()
|
|
);
|
|
} else {
|
|
log::warn!("Pre-launch: os_crypt_key NOT FOUND");
|
|
}
|
|
|
|
if cookies_path.exists() {
|
|
// Try to open Cookies DB and check if encrypted cookies can be decrypted
|
|
if let Ok(conn) = rusqlite::Connection::open_with_flags(
|
|
&cookies_path,
|
|
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
|
) {
|
|
let cookie_count: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM cookies WHERE length(encrypted_value) > 0",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap_or(0);
|
|
let total_count: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM cookies", [], |r| r.get(0))
|
|
.unwrap_or(0);
|
|
log::info!(
|
|
"Pre-launch: Cookies DB has {} total cookies, {} encrypted",
|
|
total_count,
|
|
cookie_count
|
|
);
|
|
|
|
// Try decrypting one cookie using the cookie_manager
|
|
if let Some(encryption_key) =
|
|
crate::cookie_manager::chrome_decrypt::get_encryption_key(&profile_path_buf)
|
|
{
|
|
if let Ok(mut stmt) = conn.prepare(
|
|
"SELECT name, host_key, encrypted_value FROM cookies WHERE length(encrypted_value) > 0 LIMIT 1",
|
|
) {
|
|
if let Ok(mut rows) = stmt.query([]) {
|
|
if let Ok(Some(row)) = rows.next() {
|
|
let name: String = row.get(0).unwrap_or_default();
|
|
let host: String = row.get(1).unwrap_or_default();
|
|
let encrypted: Vec<u8> = row.get(2).unwrap_or_default();
|
|
let decrypted = crate::cookie_manager::chrome_decrypt::decrypt(
|
|
&encrypted,
|
|
&host,
|
|
&encryption_key,
|
|
);
|
|
match decrypted {
|
|
Some(val) => log::info!(
|
|
"Pre-launch: Cookie decryption SUCCEEDED for '{}' (host: {}, decrypted {} bytes)",
|
|
name, host, val.len()
|
|
),
|
|
None => log::error!(
|
|
"Pre-launch: Cookie decryption FAILED for '{}' (host: {}, encrypted {} bytes)",
|
|
name, host, encrypted.len()
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
log::error!("Pre-launch: Failed to derive encryption key from os_crypt_key");
|
|
}
|
|
}
|
|
} else {
|
|
log::warn!("Pre-launch: Cookies NOT FOUND");
|
|
}
|
|
}
|
|
|
|
let mut args = vec![
|
|
format!("--remote-debugging-port={port}"),
|
|
"--remote-debugging-address=127.0.0.1".to_string(),
|
|
format!("--user-data-dir={profile_path}"),
|
|
"--no-first-run".to_string(),
|
|
"--no-default-browser-check".to_string(),
|
|
"--disable-background-mode".to_string(),
|
|
"--disable-component-update".to_string(),
|
|
"--disable-background-timer-throttling".to_string(),
|
|
"--crash-server-url=".to_string(),
|
|
"--disable-updater".to_string(),
|
|
"--disable-session-crashed-bubble".to_string(),
|
|
"--hide-crash-restore-bubble".to_string(),
|
|
"--disable-infobars".to_string(),
|
|
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns".to_string(),
|
|
"--use-mock-keychain".to_string(),
|
|
"--password-store=basic".to_string(),
|
|
];
|
|
|
|
if headless {
|
|
args.push("--headless=new".to_string());
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
args.push("--no-sandbox".to_string());
|
|
args.push("--disable-setuid-sandbox".to_string());
|
|
args.push("--disable-dev-shm-usage".to_string());
|
|
}
|
|
|
|
if ephemeral {
|
|
args.push("--disk-cache-size=1".to_string());
|
|
args.push("--disable-breakpad".to_string());
|
|
args.push("--disable-crash-reporter".to_string());
|
|
args.push("--no-service-autorun".to_string());
|
|
args.push("--disable-sync".to_string());
|
|
}
|
|
|
|
if !extension_paths.is_empty() {
|
|
args.push(format!("--load-extension={}", extension_paths.join(",")));
|
|
}
|
|
|
|
let mut wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
|
if wayfern_token.is_none()
|
|
&& crate::cloud_auth::CLOUD_AUTH
|
|
.has_active_paid_subscription()
|
|
.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)"
|
|
);
|
|
}
|
|
}
|
|
if let Some(ref token) = wayfern_token {
|
|
args.push(format!("--wayfern-token={token}"));
|
|
log::info!("Wayfern token passed as CLI flag (length: {})", token.len());
|
|
}
|
|
|
|
if let Some(proxy) = proxy_url {
|
|
let pac_data = format!(
|
|
"data:application/x-ns-proxy-autoconfig,function FindProxyForURL(url,host){{return \"PROXY {}\";}}",
|
|
proxy.trim_start_matches("http://").trim_start_matches("https://")
|
|
);
|
|
args.push(format!("--proxy-pac-url={pac_data}"));
|
|
args.push("--dns-prefetch-disable".to_string());
|
|
}
|
|
|
|
let mut command = TokioCommand::new(&executable_path);
|
|
command
|
|
.args(&args)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
|
|
let child = command
|
|
.spawn()
|
|
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
|
let hint = if e.raw_os_error() == Some(14001) {
|
|
". This usually means the Visual C++ Redistributable is not installed. \
|
|
Download it from https://aka.ms/vs/17/release/vc_redist.x64.exe"
|
|
} else {
|
|
""
|
|
};
|
|
format!("Failed to spawn Wayfern: {e}{hint}").into()
|
|
})?;
|
|
let process_id = child.id();
|
|
drop(child);
|
|
|
|
self.wait_for_cdp_ready(port).await?;
|
|
|
|
let targets = self.get_cdp_targets(port).await?;
|
|
log::info!("Found {} CDP targets", targets.len());
|
|
|
|
let page_targets: Vec<_> = targets.iter().filter(|t| t.target_type == "page").collect();
|
|
log::info!("Found {} page targets", page_targets.len());
|
|
|
|
// Apply fingerprint if configured
|
|
let mut used_fingerprint: Option<String> = None;
|
|
if let Some(fingerprint_json) = &config.fingerprint {
|
|
log::info!(
|
|
"Applying fingerprint to Wayfern browser, fingerprint length: {} chars",
|
|
fingerprint_json.len()
|
|
);
|
|
|
|
let stored_value: serde_json::Value = serde_json::from_str(fingerprint_json)
|
|
.map_err(|e| format!("Failed to parse stored fingerprint JSON: {e}"))?;
|
|
|
|
// The stored fingerprint should be the fingerprint object directly (after our fix in generate_fingerprint_config)
|
|
// But for backwards compatibility, also handle the wrapped format
|
|
let mut fingerprint = if stored_value.get("fingerprint").is_some() {
|
|
// Old format: {"fingerprint": {...}} - extract the inner fingerprint
|
|
stored_value.get("fingerprint").cloned().unwrap()
|
|
} else {
|
|
// New format: fingerprint object directly {...}
|
|
stored_value.clone()
|
|
};
|
|
|
|
// Add default timezone if not present (for profiles created before timezone was added)
|
|
if let Some(obj) = fingerprint.as_object_mut() {
|
|
if !obj.contains_key("timezone") {
|
|
obj.insert("timezone".to_string(), json!("America/New_York"));
|
|
log::info!("Added default timezone to fingerprint");
|
|
}
|
|
if !obj.contains_key("timezoneOffset") {
|
|
obj.insert("timezoneOffset".to_string(), json!(300));
|
|
log::info!("Added default timezoneOffset to fingerprint");
|
|
}
|
|
}
|
|
|
|
// Denormalize fingerprint for Wayfern CDP (convert arrays/objects to JSON strings)
|
|
let mut fingerprint_for_cdp = Self::denormalize_fingerprint(fingerprint);
|
|
|
|
// Normalize languages: if it's a comma-separated string, convert to array
|
|
if let Some(obj) = fingerprint_for_cdp.as_object_mut() {
|
|
if let Some(serde_json::Value::String(s)) = obj.get("languages").cloned() {
|
|
let arr: Vec<&str> = s.split(',').map(|l| l.trim()).collect();
|
|
obj.insert("languages".to_string(), json!(arr));
|
|
}
|
|
}
|
|
|
|
log::info!(
|
|
"Fingerprint prepared for CDP command, fields: {:?}",
|
|
fingerprint_for_cdp
|
|
.as_object()
|
|
.map(|o| o.keys().collect::<Vec<_>>())
|
|
);
|
|
|
|
// Log timezone and geolocation fields specifically for debugging
|
|
if let Some(obj) = fingerprint_for_cdp.as_object() {
|
|
log::info!(
|
|
"Timezone/Geolocation fields - timezone: {:?}, timezoneOffset: {:?}, latitude: {:?}, longitude: {:?}, language: {:?}, languages: {:?}",
|
|
obj.get("timezone"),
|
|
obj.get("timezoneOffset"),
|
|
obj.get("latitude"),
|
|
obj.get("longitude"),
|
|
obj.get("language"),
|
|
obj.get("languages")
|
|
);
|
|
}
|
|
|
|
// Include wayfern token if available (enables cross-OS fingerprinting for paid users)
|
|
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
|
let mut fingerprint_params = fingerprint_for_cdp.clone();
|
|
if let Some(ref token) = wayfern_token {
|
|
if let Some(obj) = fingerprint_params.as_object_mut() {
|
|
obj.insert("wayfernToken".to_string(), json!(token));
|
|
}
|
|
}
|
|
|
|
for target in &page_targets {
|
|
if let Some(ws_url) = &target.websocket_debugger_url {
|
|
log::info!("Applying fingerprint to target via WebSocket: {}", ws_url);
|
|
match self
|
|
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
|
|
.await
|
|
{
|
|
Ok(result) => {
|
|
log::info!(
|
|
"Successfully applied fingerprint to page target: {:?}",
|
|
result
|
|
);
|
|
// Wayfern.setFingerprint echoes back the fingerprint it actually
|
|
// used, which may be UPGRADED from what we sent (e.g. when the
|
|
// stored fingerprint targets an older browser version). Capture
|
|
// it once, from the first target that succeeds, so the caller can
|
|
// persist the upgraded value to the profile.
|
|
if used_fingerprint.is_none() {
|
|
// getFingerprint/setFingerprint wrap the object as
|
|
// { fingerprint: {...} }; tolerate a bare object too.
|
|
let fp = result.get("fingerprint").cloned().unwrap_or(result);
|
|
if fp.is_object() {
|
|
match serde_json::to_string(&Self::normalize_fingerprint(fp)) {
|
|
Ok(s) => used_fingerprint = Some(s),
|
|
Err(e) => {
|
|
log::warn!("Failed to serialize used fingerprint: {e}")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => log::error!("Failed to apply fingerprint to target: {e}"),
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
log::warn!("No fingerprint found in config, browser will use default fingerprint");
|
|
}
|
|
|
|
// Geolocation is handled internally by the browser binary.
|
|
|
|
if let Some(url) = url {
|
|
log::info!("Navigating to URL via CDP: {}", url);
|
|
if let Some(target) = page_targets.first() {
|
|
if let Some(ws_url) = &target.websocket_debugger_url {
|
|
if let Err(e) = self
|
|
.send_cdp_command(ws_url, "Page.navigate", json!({ "url": url }))
|
|
.await
|
|
{
|
|
log::error!("Failed to navigate to URL: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for target in &page_targets {
|
|
if let Some(ws_url) = &target.websocket_debugger_url {
|
|
let _ = self
|
|
.send_cdp_command(ws_url, "Emulation.clearDeviceMetricsOverride", json!({}))
|
|
.await;
|
|
let _ = self
|
|
.send_cdp_command(
|
|
ws_url,
|
|
"Emulation.setFocusEmulationEnabled",
|
|
json!({ "enabled": false }),
|
|
)
|
|
.await;
|
|
let _ = self
|
|
.send_cdp_command(
|
|
ws_url,
|
|
"Emulation.setEmulatedMedia",
|
|
json!({ "media": "", "features": [] }),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
let id = uuid::Uuid::new_v4().to_string();
|
|
let instance = WayfernInstance {
|
|
id: id.clone(),
|
|
process_id,
|
|
profile_path: Some(profile_path.to_string()),
|
|
url: url.map(|s| s.to_string()),
|
|
cdp_port: Some(port),
|
|
};
|
|
|
|
let mut inner = self.inner.lock().await;
|
|
inner.instances.insert(id.clone(), instance);
|
|
|
|
Ok(WayfernLaunchResult {
|
|
id,
|
|
processId: process_id,
|
|
profilePath: Some(profile_path.to_string()),
|
|
url: url.map(|s| s.to_string()),
|
|
cdp_port: Some(port),
|
|
used_fingerprint,
|
|
})
|
|
}
|
|
|
|
pub async fn stop_wayfern(
|
|
&self,
|
|
id: &str,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut inner = self.inner.lock().await;
|
|
|
|
if let Some(instance) = inner.instances.remove(id) {
|
|
log::info!("Cleaning up Wayfern instance {}", instance.id);
|
|
if let Some(pid) = instance.process_id {
|
|
#[cfg(unix)]
|
|
{
|
|
use nix::sys::signal::{kill, Signal};
|
|
use nix::unistd::Pid;
|
|
let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM);
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt;
|
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
let _ = std::process::Command::new("taskkill")
|
|
.args(["/PID", &pid.to_string(), "/F"])
|
|
.creation_flags(CREATE_NO_WINDOW)
|
|
.output();
|
|
}
|
|
log::info!("Stopped Wayfern instance {id} (PID: {pid})");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Opens a URL in a new tab for an existing Wayfern instance.
|
|
pub async fn open_url_in_tab(
|
|
&self,
|
|
profile_path: &str,
|
|
url: &str,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let inner = self.inner.lock().await;
|
|
let target_path = std::path::Path::new(profile_path)
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
|
|
|
|
let port = inner
|
|
.instances
|
|
.values()
|
|
.find(|i| {
|
|
i.profile_path
|
|
.as_deref()
|
|
.map(|p| {
|
|
std::path::Path::new(p)
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| std::path::Path::new(p).to_path_buf())
|
|
== target_path
|
|
})
|
|
.unwrap_or(false)
|
|
})
|
|
.and_then(|i| i.cdp_port)
|
|
.ok_or("Wayfern instance (with CDP port) not found for profile")?;
|
|
drop(inner);
|
|
|
|
// Open the URL in a new tab via the CDP HTTP convenience endpoint.
|
|
let new_tab_url = format!(
|
|
"http://127.0.0.1:{port}/json/new?{}",
|
|
urlencoding::encode(url)
|
|
);
|
|
let resp = self
|
|
.http_client
|
|
.put(&new_tab_url)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to open new tab: {e}"))?;
|
|
if !resp.status().is_success() {
|
|
return Err(format!("CDP /json/new returned HTTP {}", resp.status()).into());
|
|
}
|
|
|
|
log::info!("Opened URL in new tab via CDP: {}", url);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_cdp_port(&self, profile_path: &str) -> Option<u16> {
|
|
let inner = self.inner.lock().await;
|
|
let target_path = std::path::Path::new(profile_path)
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
|
|
|
|
for instance in inner.instances.values() {
|
|
if let Some(path) = &instance.profile_path {
|
|
let instance_path = std::path::Path::new(path)
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
|
|
if instance_path == target_path {
|
|
return instance.cdp_port;
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub async fn find_wayfern_by_profile(&self, profile_path: &str) -> Option<WayfernLaunchResult> {
|
|
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
|
|
|
let mut inner = self.inner.lock().await;
|
|
|
|
// Canonicalize the target path for comparison
|
|
let target_path = std::path::Path::new(profile_path)
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
|
|
|
|
// Find the instance with the matching profile path
|
|
let mut found_id: Option<String> = None;
|
|
for (id, instance) in &inner.instances {
|
|
if let Some(path) = &instance.profile_path {
|
|
let instance_path = std::path::Path::new(path)
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
|
|
if instance_path == target_path {
|
|
found_id = Some(id.clone());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we found an instance, verify the process is still running
|
|
if let Some(id) = found_id {
|
|
if let Some(instance) = inner.instances.get(&id) {
|
|
if let Some(pid) = instance.process_id {
|
|
let system = System::new_with_specifics(
|
|
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
|
);
|
|
let sysinfo_pid = sysinfo::Pid::from_u32(pid);
|
|
|
|
if system.process(sysinfo_pid).is_some() {
|
|
return Some(WayfernLaunchResult {
|
|
id: id.clone(),
|
|
processId: instance.process_id,
|
|
profilePath: instance.profile_path.clone(),
|
|
url: instance.url.clone(),
|
|
cdp_port: instance.cdp_port,
|
|
used_fingerprint: None,
|
|
});
|
|
} else {
|
|
log::info!(
|
|
"Wayfern process {} for profile {} is no longer running, cleaning up",
|
|
pid,
|
|
profile_path
|
|
);
|
|
inner.instances.remove(&id);
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If not found in in-memory instances, scan system processes.
|
|
// This handles the case where the GUI was restarted but Wayfern is still running.
|
|
if let Some((pid, found_profile_path, cdp_port)) =
|
|
Self::find_wayfern_process_by_profile(&target_path)
|
|
{
|
|
log::info!(
|
|
"Found running Wayfern process (PID: {}) for profile path via system scan",
|
|
pid
|
|
);
|
|
|
|
let instance_id = format!("recovered_{}", pid);
|
|
inner.instances.insert(
|
|
instance_id.clone(),
|
|
WayfernInstance {
|
|
id: instance_id.clone(),
|
|
process_id: Some(pid),
|
|
profile_path: Some(found_profile_path.clone()),
|
|
url: None,
|
|
cdp_port,
|
|
},
|
|
);
|
|
|
|
return Some(WayfernLaunchResult {
|
|
id: instance_id,
|
|
processId: Some(pid),
|
|
profilePath: Some(found_profile_path),
|
|
url: None,
|
|
cdp_port,
|
|
used_fingerprint: None,
|
|
});
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Scan system processes to find a Wayfern/Chromium process using a specific profile path
|
|
fn find_wayfern_process_by_profile(
|
|
target_path: &std::path::Path,
|
|
) -> Option<(u32, String, Option<u16>)> {
|
|
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
|
|
|
let system = System::new_with_specifics(
|
|
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
|
);
|
|
|
|
let target_path_str = target_path.to_string_lossy();
|
|
|
|
for (pid, process) in system.processes() {
|
|
let cmd = process.cmd();
|
|
if cmd.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let exe_name = process.name().to_string_lossy().to_lowercase();
|
|
let is_chromium_like = exe_name.contains("wayfern")
|
|
|| exe_name.contains("chromium")
|
|
|| exe_name.contains("chrome");
|
|
|
|
if !is_chromium_like {
|
|
continue;
|
|
}
|
|
|
|
// Skip child processes (renderer, GPU, utility, zygote, etc.)
|
|
// Only the main browser process lacks a --type= argument
|
|
let is_child = cmd
|
|
.iter()
|
|
.any(|a| a.to_str().is_some_and(|s| s.starts_with("--type=")));
|
|
if is_child {
|
|
continue;
|
|
}
|
|
|
|
let mut matched = false;
|
|
let mut cdp_port: Option<u16> = None;
|
|
|
|
for arg in cmd.iter() {
|
|
if let Some(arg_str) = arg.to_str() {
|
|
if let Some(dir_val) = arg_str.strip_prefix("--user-data-dir=") {
|
|
let cmd_path = std::path::Path::new(dir_val)
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| std::path::Path::new(dir_val).to_path_buf());
|
|
if cmd_path == target_path {
|
|
matched = true;
|
|
}
|
|
}
|
|
|
|
if let Some(port_val) = arg_str.strip_prefix("--remote-debugging-port=") {
|
|
cdp_port = port_val.parse().ok();
|
|
}
|
|
}
|
|
}
|
|
|
|
if matched {
|
|
return Some((pid.as_u32(), target_path_str.to_string(), cdp_port));
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub async fn launch_wayfern_profile(
|
|
&self,
|
|
app_handle: &AppHandle,
|
|
profile: &BrowserProfile,
|
|
config: &WayfernConfig,
|
|
url: Option<&str>,
|
|
proxy_url: Option<&str>,
|
|
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
|
|
let profiles_dir = self.get_profiles_dir();
|
|
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
|
let profile_path_str = profile_path.to_string_lossy().to_string();
|
|
|
|
std::fs::create_dir_all(&profile_path)?;
|
|
|
|
if let Some(existing) = self.find_wayfern_by_profile(&profile_path_str).await {
|
|
log::info!("Stopping existing Wayfern instance for profile");
|
|
self.stop_wayfern(&existing.id).await?;
|
|
}
|
|
|
|
self
|
|
.launch_wayfern(
|
|
app_handle,
|
|
profile,
|
|
&profile_path_str,
|
|
config,
|
|
url,
|
|
proxy_url,
|
|
profile.ephemeral,
|
|
&[],
|
|
None,
|
|
false,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub async fn cleanup_dead_instances(&self) {
|
|
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
|
|
|
let mut inner = self.inner.lock().await;
|
|
let mut dead_ids = Vec::new();
|
|
|
|
let system = System::new_with_specifics(
|
|
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
|
);
|
|
|
|
for (id, instance) in &inner.instances {
|
|
if let Some(pid) = instance.process_id {
|
|
let pid = sysinfo::Pid::from_u32(pid);
|
|
if !system.processes().contains_key(&pid) {
|
|
dead_ids.push(id.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
for id in dead_ids {
|
|
log::info!("Cleaning up dead Wayfern instance: {id}");
|
|
inner.instances.remove(&id);
|
|
}
|
|
}
|
|
}
|
|
|
|
lazy_static::lazy_static! {
|
|
static ref WAYFERN_MANAGER: WayfernManager = WayfernManager::new();
|
|
}
|