Files
donutbrowser/src-tauri/src/wayfern_manager.rs
T
2026-03-15 18:00:04 +04:00

1012 lines
33 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)]
pub executable_path: Option<String>,
#[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>,
}
#[derive(Debug)]
struct WayfernInstance {
#[allow(dead_code)]
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::new(),
}
}
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");
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<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 = 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")
.arg("--use-mock-keychain")
.arg("--password-store=basic")
.arg("--disable-features=DialMediaRouteProvider")
.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)]
{
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 {
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>,
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
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 = match remote_debugging_port {
Some(p) => p,
None => 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}"));
}
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(",")));
}
// Pass wayfern token as CLI flag so the browser can gate CDP features
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
if let Some(ref token) = wayfern_token {
args.push(format!("--wayfern-token={token}"));
log::info!("Wayfern token passed as CLI flag (length: {})", token.len());
} else {
log::warn!("No wayfern token available — CDP gated methods will be blocked");
}
// 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 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);
// Wayfern.setFingerprint expects the fingerprint object directly, NOT wrapped
match self
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.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");
}
// Set geolocation override via CDP so navigator.geolocation.getCurrentPosition() matches
if let Some(fingerprint_json) = &config.fingerprint {
if let Ok(fp) = serde_json::from_str::<serde_json::Value>(fingerprint_json) {
let fp_obj = if fp.get("fingerprint").is_some() {
fp.get("fingerprint").unwrap()
} else {
&fp
};
if let (Some(lat), Some(lng)) = (
fp_obj.get("latitude").and_then(|v| v.as_f64()),
fp_obj.get("longitude").and_then(|v| v.as_f64()),
) {
let accuracy = fp_obj
.get("accuracy")
.and_then(|v| v.as_f64())
.unwrap_or(100.0);
if let Some(target) = page_targets.first() {
if let Some(ws_url) = &target.websocket_debugger_url {
let _ = self
.send_cdp_command(
ws_url,
"Emulation.setGeolocationOverride",
json!({ "latitude": lat, "longitude": lng, "accuracy": accuracy }),
)
.await;
log::info!("Set geolocation override: lat={lat}, lng={lng}");
}
}
}
}
}
// 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<dyn std::error::Error + Send + Sync>> {
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)]
{
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 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<dyn std::error::Error + Send + Sync>> {
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 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,
},
);
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,
)
.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();
}