use crate::browser::ProxySettings; use crate::cloud_auth::CLOUD_AUTH; use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry; use crate::events; use crate::profile::{BrowserProfile, ProfileManager}; use crate::proxy_manager::PROXY_MANAGER; use crate::wayfern_manager::{WayfernConfig, WayfernManager}; use serde::Serialize; use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, LazyLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; static PROFILE_LAUNCH_LOCKS: LazyLock< tokio::sync::Mutex>>>, > = LazyLock::new(|| tokio::sync::Mutex::new(HashMap::new())); async fn lock_profile_launch(profile_id: &str) -> tokio::sync::OwnedMutexGuard<()> { let lock = { let mut locks = PROFILE_LAUNCH_LOCKS.lock().await; locks .entry(profile_id.to_string()) .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) .clone() }; lock.lock_owned().await } pub struct BrowserRunner { pub profile_manager: &'static ProfileManager, pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry, auto_updater: &'static crate::auto_updater::AutoUpdater, wayfern_manager: &'static WayfernManager, } impl BrowserRunner { fn new() -> Self { Self { profile_manager: ProfileManager::instance(), downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(), auto_updater: crate::auto_updater::AutoUpdater::instance(), wayfern_manager: WayfernManager::instance(), } } pub fn instance() -> &'static BrowserRunner { &BROWSER_RUNNER } pub fn get_binaries_dir(&self) -> PathBuf { crate::app_dirs::binaries_dir() } /// Resolve the DNS blocklist level to a cached file path plus whether that /// file should be treated as an allowlist. If a level is set but the cache /// is missing, fetches/compiles on demand (blocks until done). async fn resolve_blocklist_file( profile: &crate::profile::BrowserProfile, ) -> Result<(Option, bool), String> { let Some(ref level_str) = profile.dns_blocklist else { return Ok((None, false)); }; let Some(level) = crate::dns_blocklist::BlocklistLevel::parse_level(level_str) else { return Ok((None, false)); }; if level == crate::dns_blocklist::BlocklistLevel::None { return Ok((None, false)); } // Only the user's custom list can be an allowlist; the Hagezi tiers are // always block lists. let allowlist_mode = level == crate::dns_blocklist::BlocklistLevel::Custom && crate::dns_blocklist::CustomDnsConfig::load().allowlist_mode; let path = crate::dns_blocklist::BlocklistManager::ensure_cached(level) .await .map_err(|e| format!("Failed to fetch DNS blocklist: {e}"))?; Ok((Some(path.to_string_lossy().to_string()), allowlist_mode)) } /// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy, /// then resolve the proxy settings with profile-specific sid for sticky sessions. async fn resolve_proxy_with_refresh( &self, proxy_id: Option<&String>, profile_id: Option<&str>, ) -> Result, String> { let proxy_id = match proxy_id { Some(id) => id, None => return Ok(None), }; if PROXY_MANAGER.is_cloud_or_derived(proxy_id) { log::info!("Refreshing cloud proxy credentials before launch for proxy {proxy_id}"); CLOUD_AUTH.sync_cloud_proxy().await; } // For cloud-derived proxies, inject profile-specific sid for sticky sessions if let Some(pid) = profile_id { if PROXY_MANAGER.is_cloud_or_derived(proxy_id) { return Ok(PROXY_MANAGER.resolve_proxy_for_profile(proxy_id, pid)); } } Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id)) } fn fire_launch_hook(profile: &BrowserProfile) { let Some(raw_url) = profile.launch_hook.as_deref() else { return; }; let trimmed = raw_url.trim(); if trimmed.is_empty() { return; } let parsed = match url::Url::parse(trimmed) { Ok(u) => u, Err(e) => { log::warn!( "Skipping launch hook for profile {} (ID: {}): invalid URL: {e}", profile.name, profile.id ); return; } }; if !matches!(parsed.scheme(), "http" | "https") { log::warn!( "Skipping launch hook for profile {} (ID: {}): URL must be http or https", profile.name, profile.id ); return; } let url = parsed.to_string(); let url_label = crate::log_redaction::url_label(&url); log::info!("Firing launch hook GET {url_label}"); tokio::spawn(async move { let client = match reqwest::Client::builder() .timeout(Duration::from_secs(5)) .build() { Ok(c) => c, Err(e) => { log::warn!( "Launch hook client build failed: {}", crate::log_redaction::text(&e.to_string()) ); return; } }; match client.get(&url).send().await { Ok(resp) => { log::info!("Launch hook {url_label} returned status {}", resp.status()); } Err(e) => { log::warn!( "Launch hook {url_label} failed: {}", crate::log_redaction::text(&e.to_string()) ); } } }); } async fn resolve_launch_proxy( &self, profile: &BrowserProfile, ) -> Result, String> { Self::fire_launch_hook(profile); self .resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string())) .await } /// Get the executable path for a browser profile /// This is a common helper to eliminate code duplication across the codebase pub fn get_browser_executable_path( &self, profile: &BrowserProfile, ) -> Result> { // Create browser instance to get executable path let browser_type = crate::browser::BrowserType::from_str(&profile.browser) .map_err(|e| format!("Invalid browser type: {e}"))?; let browser = crate::browser::create_browser(browser_type); // Construct browser directory path: binaries/// let mut browser_dir = self.get_binaries_dir(); browser_dir.push(&profile.browser); browser_dir.push(&profile.version); // Get platform-specific executable path browser .get_executable_path(&browser_dir) .map_err(|e| format!("Failed to get executable path for {}: {e}", profile.browser).into()) } async fn launch_browser_internal( &self, app_handle: tauri::AppHandle, profile: &BrowserProfile, url: Option, _local_proxy_settings: Option<&ProxySettings>, remote_debugging_port: Option, headless: bool, ) -> Result> { // Handle Wayfern profiles using WayfernManager if profile.browser == "wayfern" { // Get or create wayfern config let mut wayfern_config = profile.wayfern_config.clone().unwrap_or_else(|| { log::info!( "No wayfern config found for profile {}, using default", profile.name ); WayfernConfig::default() }); // Always start a local proxy for Wayfern (for traffic monitoring and geoip support) let mut upstream_proxy = self .resolve_launch_proxy(profile) .await .map_err(|e| -> Box { e.into() })?; let geo_proxy_signature_settings = upstream_proxy.clone(); struct XrayLaunchGuard { worker_id: Option, profile_name: String, } impl Drop for XrayLaunchGuard { fn drop(&mut self) { let Some(worker_id) = self.worker_id.take() else { return; }; log::warn!( "Launch failed after Xray-core start for profile {}; stopping worker", self.profile_name ); if let Err(error) = crate::xray_worker_runner::stop_xray_worker_now(&worker_id) { log::warn!("Failed to stop Xray-core worker after failed launch: {error}"); } } } let mut xray_launch_guard = XrayLaunchGuard { worker_id: None, profile_name: profile.name.clone(), }; if upstream_proxy .as_ref() .is_some_and(|proxy| proxy.proxy_type.eq_ignore_ascii_case("vless")) { let vless_uri = upstream_proxy .as_ref() .and_then(|proxy| proxy.vless_uri.as_deref()) .ok_or_else(|| crate::backend_error("VLESS_CONFIG_INVALID"))?; let worker = crate::xray_worker_runner::start_xray_worker(Some(&profile.id.to_string()), vless_uri) .await .map_err(|error| -> Box { error.to_string().into() })?; log::info!( "Xray-core worker started for Wayfern profile on port {}", worker.local_port ); xray_launch_guard.worker_id = Some(worker.id.clone()); upstream_proxy = Some(worker.local_proxy_settings()); } // If profile has a VPN instead of proxy, start VPN worker and use it as upstream if upstream_proxy.is_none() { if let Some(ref vpn_id) = profile.vpn_id { match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await { Ok(vpn_worker) => { if let Some(port) = vpn_worker.local_port { upstream_proxy = Some(ProxySettings { proxy_type: "socks5".to_string(), host: "127.0.0.1".to_string(), port, username: None, password: None, vless_uri: None, }); log::info!("VPN worker started for Wayfern profile on port {}", port); } } Err(e) => { return Err(format!("Failed to start VPN worker: {e}").into()); } } } } log::info!( "Starting local proxy for Wayfern profile: {} (upstream: {})", profile.name, upstream_proxy .as_ref() .map(|p| format!("{}:{}", p.host, p.port)) .unwrap_or_else(|| "DIRECT".to_string()) ); // Start the proxy and get local proxy settings // If proxy startup fails, DO NOT launch Wayfern - it requires local proxy let profile_id_str = profile.id.to_string(); let (blocklist_file, dns_allowlist_mode) = Self::resolve_blocklist_file(profile).await?; // Unique per-launch key: a shared constant here would let concurrent // launches overwrite each other's active_proxies entry, ending with one // browser's worker tracked under another browser's PID. let launch_placeholder_pid = crate::proxy_manager::next_launch_placeholder_pid(); let local_proxy = PROXY_MANAGER .start_proxy( app_handle.clone(), upstream_proxy.as_ref(), launch_placeholder_pid, Some(&profile_id_str), profile.proxy_bypass_rules.clone(), blocklist_file, dns_allowlist_mode, // Wayfern (Chromium) uses a local SOCKS5 proxy so QUIC and WebRTC // UDP can be routed through it (via SOCKS5 UDP ASSOCIATE) without // leaking the real IP, rather than being forced direct as they // would be over an HTTP CONNECT proxy. "socks5", ) .await .map_err(|e| { let error_msg = crate::wrap_backend_error(e, "Failed to start local proxy for Wayfern"); log::error!("{}", error_msg); error_msg })?; // If any step below fails before the browser is up, the detached worker // must be stopped here: its config never gets a browser_pid, so neither // the GUI sweeps nor the worker's own watchdog would ever reap it — it // would survive until machine reboot. struct ProxyLaunchGuard { app_handle: tauri::AppHandle, routing_pid: u32, profile_name: String, armed: bool, } impl Drop for ProxyLaunchGuard { fn drop(&mut self) { if self.armed { log::warn!( "Launch failed after local proxy start for profile {}; stopping proxy worker", self.profile_name ); let app_handle = self.app_handle.clone(); let pid = self.routing_pid; tauri::async_runtime::spawn(async move { if let Err(e) = PROXY_MANAGER.stop_proxy(app_handle, pid).await { log::warn!("Failed to stop proxy worker after failed launch: {e}"); } }); } } } let mut proxy_launch_guard = ProxyLaunchGuard { app_handle: app_handle.clone(), routing_pid: launch_placeholder_pid, profile_name: profile.name.clone(), armed: true, }; // Format proxy URL for wayfern - use SOCKS5 for the local proxy so // Chromium proxies UDP (QUIC/WebRTC), not just TCP. let proxy_url = format!("socks5://{}:{}", local_proxy.host, local_proxy.port); // Set proxy in wayfern config wayfern_config.proxy = Some(proxy_url); log::info!( "Configured local proxy for Wayfern: {:?}", wayfern_config.proxy ); // Check if we need to generate a new fingerprint on every launch let mut updated_profile = profile.clone(); if wayfern_config.randomize_fingerprint_on_launch == Some(true) { log::info!( "Generating random fingerprint for Wayfern profile: {}", profile.name ); // Create a config copy without the existing fingerprint to force generation of a new one let mut config_for_generation = wayfern_config.clone(); config_for_generation.fingerprint = None; // Generate a new fingerprint let (new_fingerprint, geolocation_applied) = self .wayfern_manager .generate_fingerprint_config(&app_handle, profile, &config_for_generation) .await .map_err(|e| format!("Failed to generate random fingerprint: {e}"))?; log::info!( "New fingerprint generated, length: {} chars", new_fingerprint.len() ); // Update the config with the new fingerprint for launching wayfern_config.fingerprint = Some(new_fingerprint.clone()); // Save the updated fingerprint to the profile so it persists. let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default(); updated_wayfern_config.fingerprint = Some(new_fingerprint); // Preserve the randomize flag so it persists across launches updated_wayfern_config.randomize_fingerprint_on_launch = Some(true); // Preserve the OS setting so it's used for future fingerprint generation if wayfern_config.os.is_some() { updated_wayfern_config.os = wayfern_config.os.clone(); } // Record which routing this fresh fingerprint's geolocation was built // for (provenance only — nothing rewrites the fingerprint from it). // Only when geolocation actually applied; otherwise leave it unset so a // later on-demand match can tell the location was never resolved. updated_wayfern_config.geo_proxy_signature = if geolocation_applied { Some(crate::wayfern_manager::WayfernManager::geo_signature( geo_proxy_signature_settings.as_ref(), profile.vpn_id.as_deref(), wayfern_config.geoip.as_ref(), )) } else { None }; updated_profile.wayfern_config = Some(updated_wayfern_config.clone()); log::info!( "Updated profile wayfern_config with new fingerprint for profile: {}, fingerprint length: {}", profile.name, updated_wayfern_config.fingerprint.as_ref().map(|f| f.len()).unwrap_or(0) ); } // A non-randomize profile keeps its configured fingerprint verbatim, even // when its proxy/VPN routing has changed since the fingerprint was built. // We deliberately do NOT silently rewrite its timezone/language to match // the new exit: that hid every real fingerprint-vs-exit mismatch (a US // fingerprint behind a German exit would be quietly relabelled German // before the launch-time consistency check could see it). The check now // surfaces the mismatch, and the user re-matches on demand via // `match_profile_fingerprint_to_exit`. // Create ephemeral dir for ephemeral or password-protected profiles if profile.password_protected { crate::profile::password::prepare_for_launch(profile) .map_err(|e| -> Box { e.into() })?; } else if profile.ephemeral { crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string()) .map_err(|e| -> Box { e.into() })?; } // Launch Wayfern browser log::info!("Launching Wayfern for profile: {}", profile.name); // Get profile path for Wayfern let profiles_dir = self.profile_manager.get_profiles_dir(); let profile_data_path = crate::ephemeral_dirs::get_effective_profile_path(&updated_profile, &profiles_dir); let profile_path_str = profile_data_path.to_string_lossy().to_string(); // Install extensions if an extension group is assigned let mut extension_paths = Vec::new(); if updated_profile.extension_group_id.is_some() { let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); match mgr.install_extensions_for_profile(&updated_profile, &profile_data_path) { Ok(paths) => { if !paths.is_empty() { log::info!( "Prepared {} Chromium extensions for profile: {}", paths.len(), updated_profile.name ); } extension_paths = paths; } Err(e) => { log::warn!("Failed to install extensions for Wayfern profile: {e}"); } } } // Get proxy URL from config let proxy_url = wayfern_config.proxy.as_deref(); let wayfern_result = self .wayfern_manager .launch_wayfern( &app_handle, &updated_profile, &profile_path_str, &wayfern_config, url.as_deref(), proxy_url, profile.ephemeral, &extension_paths, remote_debugging_port, headless, ) .await .map_err(|e| -> Box { format!("Failed to launch Wayfern: {e}").into() })?; // Get the process ID from launch result let Some(process_id) = wayfern_result.processId.filter(|pid| *pid != 0) else { if let Err(error) = self.wayfern_manager.stop_wayfern(&wayfern_result.id).await { log::warn!("Failed to stop Wayfern after it omitted its process ID: {error}"); } return Err( crate::backend_error_with_detail( "INTERNAL_ERROR", "Wayfern did not report a process identifier", ) .into(), ); }; log::info!("Wayfern launched successfully with PID: {process_id}"); if let Err(error) = PROXY_MANAGER.update_proxy_pid(launch_placeholder_pid, process_id) { if let Err(stop_error) = self.wayfern_manager.stop_wayfern(&wayfern_result.id).await { log::warn!("Failed to stop Wayfern after proxy PID mapping failed: {stop_error}"); } return Err(crate::backend_error_with_detail("INTERNAL_ERROR", error).into()); } proxy_launch_guard.routing_pid = process_id; log::info!( "Updated proxy PID mapping from launch placeholder {launch_placeholder_pid} to actual PID: {process_id}" ); if !PROXY_MANAGER.set_browser_pid_for_profile(&updated_profile.id.to_string(), process_id) { if let Err(error) = self.wayfern_manager.stop_wayfern(&wayfern_result.id).await { log::warn!("Failed to stop Wayfern after proxy worker reassignment failed: {error}"); } return Err(crate::backend_error("INTERNAL_ERROR").into()); } if let Some(worker_id) = xray_launch_guard.worker_id.as_deref() { if !crate::xray_worker_runner::set_browser_pid(worker_id, process_id) { if let Err(error) = self.wayfern_manager.stop_wayfern(&wayfern_result.id).await { log::warn!("Failed to stop Wayfern after Xray worker reassignment failed: {error}"); } return Err(crate::backend_error("XRAY_START_FAILED").into()); } } // The browser and both detached routing workers now share one verified // process identity, so later profile-persistence failures must not tear // down a live route. proxy_launch_guard.armed = false; xray_launch_guard.worker_id = None; // Wayfern.setFingerprint echoes back the fingerprint the browser actually // applied, which may be UPGRADED from the stored one (e.g. when the // stored fingerprint targets an older browser version). Persist it so the // next launch starts from the upgraded value — saved below via // save_process_info(&updated_profile). if let Some(used_fp) = wayfern_result.used_fingerprint.clone() { let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default(); if cfg.fingerprint.as_deref() != Some(used_fp.as_str()) { log::info!( "Persisting upgraded fingerprint from Wayfern.setFingerprint for profile: {} (len {})", profile.name, used_fp.len() ); cfg.fingerprint = Some(used_fp); updated_profile.wayfern_config = Some(cfg); } } // Update profile with the process info updated_profile.process_id = Some(process_id); updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()); // Save the updated profile log::info!( "Saving profile {} with wayfern_config fingerprint length: {}", updated_profile.name, updated_profile .wayfern_config .as_ref() .and_then(|c| c.fingerprint.as_ref()) .map(|f| f.len()) .unwrap_or(0) ); self.save_process_info(&updated_profile)?; let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { let _ = tm.rebuild_from_profiles(&self.profile_manager.list_profiles().unwrap_or_default()); }); log::info!( "Successfully saved profile with process info: {}", updated_profile.name ); // Emit profiles-changed to trigger frontend to reload profiles from disk if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Warning: Failed to emit profiles-changed event: {e}"); } log::info!( "Emitting profile events for successful Wayfern launch: {}", updated_profile.name ); // Emit profile update event to frontend if let Err(e) = events::emit("profile-updated", &updated_profile) { log::warn!("Warning: Failed to emit profile update event: {e}"); } // Emit minimal running changed event to frontend #[derive(Serialize)] struct RunningChangedPayload { id: String, is_running: bool, } let payload = RunningChangedPayload { id: updated_profile.id.to_string(), is_running: updated_profile.process_id.is_some(), }; if let Err(e) = events::emit("profile-running-changed", &payload) { log::warn!("Warning: Failed to emit profile running changed event: {e}"); } else { log::info!( "Successfully emitted profile-running-changed event for Wayfern {}: running={}", updated_profile.name, payload.is_running ); } return Ok(updated_profile); } Err(format!("Unsupported browser type: {}", profile.browser).into()) } pub async fn open_url_in_existing_browser( &self, _app_handle: tauri::AppHandle, profile: &BrowserProfile, url: &str, _internal_proxy_settings: Option<&ProxySettings>, ) -> Result<(), Box> { // Handle Wayfern profiles using WayfernManager if profile.browser == "wayfern" { let profiles_dir = self.profile_manager.get_profiles_dir(); let profile_data_path = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir); let profile_path_str = profile_data_path.to_string_lossy(); // Check if the process is running match self .wayfern_manager .find_wayfern_by_profile(&profile_path_str) .await { Some(_wayfern_process) => { log::info!( "Opening URL in existing Wayfern process for profile: {} (ID: {})", profile.name, profile.id ); // Use CDP to open URL in a new tab self .wayfern_manager .open_url_in_tab(&profile_path_str, url) .await?; return Ok(()); } None => { return Err("Wayfern browser is not running".into()); } } } Err(format!("Unsupported browser type: {}", profile.browser).into()) } pub async fn launch_browser_with_debugging( &self, app_handle: tauri::AppHandle, profile: &BrowserProfile, url: Option, remote_debugging_port: Option, headless: bool, ) -> Result> { // Wayfern starts (and PID-reconciles) its own local proxy // inside `launch_browser_internal`, so we hand it None here rather than // staging a second, orphaned proxy worker. self .launch_browser_internal( app_handle, profile, url, None, remote_debugging_port, headless, ) .await } pub async fn launch_or_open_url( &self, app_handle: tauri::AppHandle, profile: &BrowserProfile, url: Option, internal_proxy_settings: Option<&ProxySettings>, ) -> Result> { log::info!( "launch_or_open_url called for profile: {} (ID: {})", profile.name, profile.id ); // Get the most up-to-date profile data let profiles = self .profile_manager .list_profiles() .map_err(|e| format!("Failed to list profiles in launch_or_open_url: {e}"))?; let updated_profile = profiles .into_iter() .find(|p| p.id == profile.id) .unwrap_or_else(|| profile.clone()); log::info!( "Checking browser status for profile: {} (ID: {})", updated_profile.name, updated_profile.id ); // Check if browser is already running let is_running = self .check_browser_status(app_handle.clone(), &updated_profile) .await .map_err(|e| format!("Failed to check browser status: {e}"))?; // Get the updated profile again after status check (PID might have been updated) let profiles = self .profile_manager .list_profiles() .map_err(|e| format!("Failed to list profiles after status check: {e}"))?; let final_profile = profiles .into_iter() .find(|p| p.id == profile.id) .unwrap_or_else(|| updated_profile.clone()); log::info!( "Browser status check: running={is_running}, URL requested={}, PID present={}", url.is_some(), final_profile.process_id.is_some() ); if is_running { if let Some(url_ref) = url.as_ref() { log::info!( "Opening {} in existing browser", crate::log_redaction::url_label(url_ref) ); match self .open_url_in_existing_browser( app_handle.clone(), &final_profile, url_ref, internal_proxy_settings, ) .await { Ok(()) => { log::info!("Successfully opened URL in existing browser"); Ok(final_profile) } Err(e) => { log::info!( "Failed to open URL in existing browser: {}", crate::log_redaction::text(&e.to_string()) ); Err(e) } } } else { log::info!("Browser is already running and no URL was requested"); Ok(final_profile) } } else { log::info!("Launching new browser instance - browser not running"); self .launch_browser_internal( app_handle.clone(), &final_profile, url, internal_proxy_settings, None, false, ) .await } } fn save_process_info( &self, profile: &BrowserProfile, ) -> Result<(), Box> { // Use the regular save_profile method which handles the UUID structure self.profile_manager.save_profile(profile).map_err(|e| { let error_string = e.to_string(); Box::new(std::io::Error::other(error_string)) as Box }) } pub async fn check_browser_status( &self, app_handle: tauri::AppHandle, profile: &BrowserProfile, ) -> Result> { self .profile_manager .check_browser_status(app_handle, profile) .await } pub async fn kill_browser_process( &self, app_handle: tauri::AppHandle, profile: &BrowserProfile, ) -> Result<(), Box> { let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await; self .kill_browser_process_unlocked(app_handle, profile) .await } async fn kill_browser_process_unlocked( &self, app_handle: tauri::AppHandle, profile: &BrowserProfile, ) -> Result<(), Box> { // Handle Wayfern profiles using WayfernManager if profile.browser == "wayfern" { let profiles_dir = self.profile_manager.get_profiles_dir(); let profile_data_path = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir); let profile_path_str = profile_data_path.to_string_lossy(); log::info!( "Attempting to kill Wayfern process for profile: {} (ID: {})", profile.name, profile.id ); // Stop the proxy associated with this profile first let profile_id_str = profile.id.to_string(); if let Err(e) = PROXY_MANAGER .stop_proxy_by_profile_id(app_handle.clone(), &profile_id_str) .await { log::warn!( "Warning: Failed to stop proxy for profile {}: {e}", profile_id_str ); } if let Err(error) = crate::xray_worker_runner::stop_xray_worker_by_profile_id(&profile_id_str).await { log::warn!( "Warning: Failed to stop Xray-core worker for profile {}: {error}", profile_id_str ); } let mut process_actually_stopped = false; match self .wayfern_manager .find_wayfern_by_profile(&profile_path_str) .await { Some(wayfern_process) => { log::info!( "Found Wayfern process: {} (PID: {:?})", wayfern_process.id, wayfern_process.processId ); match self.wayfern_manager.stop_wayfern(&wayfern_process.id).await { Ok(_) => { if let Some(pid) = wayfern_process.processId { // Verify the process actually died by checking after a short delay use tokio::time::{sleep, Duration}; sleep(Duration::from_millis(500)).await; use sysinfo::{Pid, System}; let system = System::new_all(); process_actually_stopped = system.process(Pid::from(pid as usize)).is_none(); if process_actually_stopped { log::info!( "Successfully stopped Wayfern process: {} (PID: {:?}) - verified process is dead", wayfern_process.id, pid ); } else { log::warn!( "Wayfern stop command returned success but process {} (PID: {:?}) is still running - forcing kill", wayfern_process.id, pid ); // Force kill the process #[cfg(target_os = "macos")] { use crate::platform_browser; if let Err(e) = platform_browser::macos::kill_browser_process_impl( pid, Some(&profile_path_str), ) .await { log::error!("Failed to force kill Wayfern process {}: {}", pid, e); } else { sleep(Duration::from_millis(500)).await; let system = System::new_all(); process_actually_stopped = system.process(Pid::from(pid as usize)).is_none(); if process_actually_stopped { log::info!( "Successfully force killed Wayfern process {} (PID: {:?})", wayfern_process.id, pid ); } } } #[cfg(target_os = "linux")] { use crate::platform_browser; if let Err(e) = platform_browser::linux::kill_browser_process_impl( pid, Some(&profile_path_str), ) .await { log::error!("Failed to force kill Wayfern process {}: {}", pid, e); } else { sleep(Duration::from_millis(500)).await; let system = System::new_all(); process_actually_stopped = system.process(Pid::from(pid as usize)).is_none(); if process_actually_stopped { log::info!( "Successfully force killed Wayfern process {} (PID: {:?})", wayfern_process.id, pid ); } } } #[cfg(target_os = "windows")] { use crate::platform_browser; if let Err(e) = platform_browser::windows::kill_browser_process_impl(pid).await { log::error!("Failed to force kill Wayfern process {}: {}", pid, e); } else { sleep(Duration::from_millis(500)).await; let system = System::new_all(); process_actually_stopped = system.process(Pid::from(pid as usize)).is_none(); if process_actually_stopped { log::info!( "Successfully force killed Wayfern process {} (PID: {:?})", wayfern_process.id, pid ); } } } } } else { process_actually_stopped = true; } } Err(e) => { log::error!( "Error stopping Wayfern process {}: {}", wayfern_process.id, e ); // Try to force kill if we have a PID if let Some(pid) = wayfern_process.processId { log::info!( "Attempting force kill after stop_wayfern error for PID: {}", pid ); #[cfg(target_os = "macos")] { use crate::platform_browser; if let Err(kill_err) = platform_browser::macos::kill_browser_process_impl(pid, Some(&profile_path_str)) .await { log::error!("Failed to force kill Wayfern process {}: {}", pid, kill_err); } else { use tokio::time::{sleep, Duration}; sleep(Duration::from_millis(500)).await; use sysinfo::{Pid, System}; let system = System::new_all(); process_actually_stopped = system.process(Pid::from(pid as usize)).is_none(); } } #[cfg(target_os = "linux")] { use crate::platform_browser; if let Err(kill_err) = platform_browser::linux::kill_browser_process_impl(pid, Some(&profile_path_str)) .await { log::error!("Failed to force kill Wayfern process {}: {}", pid, kill_err); } else { use tokio::time::{sleep, Duration}; sleep(Duration::from_millis(500)).await; use sysinfo::{Pid, System}; let system = System::new_all(); process_actually_stopped = system.process(Pid::from(pid as usize)).is_none(); } } #[cfg(target_os = "windows")] { use crate::platform_browser; if let Err(kill_err) = platform_browser::windows::kill_browser_process_impl(pid).await { log::error!("Failed to force kill Wayfern process {}: {}", pid, kill_err); } else { use tokio::time::{sleep, Duration}; sleep(Duration::from_millis(500)).await; use sysinfo::{Pid, System}; let system = System::new_all(); process_actually_stopped = system.process(Pid::from(pid as usize)).is_none(); } } } } } } None => { log::info!( "No running Wayfern process found for profile: {} (ID: {})", profile.name, profile.id ); process_actually_stopped = true; } } // If process wasn't confirmed stopped, return an error if !process_actually_stopped { log::error!( "Failed to stop Wayfern process for profile: {} (ID: {}) - process may still be running", profile.name, profile.id ); return Err( format!( "Failed to stop Wayfern process for profile {} - process may still be running", profile.name ) .into(), ); } // Clear the process ID from the profile and save immediately so that // subsequent calls to update_profile_version (which re-reads from disk) // see the cleared process_id. let mut updated_profile = profile.clone(); updated_profile.process_id = None; self .save_process_info(&updated_profile) .map_err(|e| format!("Failed to update profile: {e}"))?; // Check for pending updates and apply them if let Ok(Some(pending_update)) = self .auto_updater .get_pending_update(&profile.browser, &profile.version) { log::info!( "Found pending update for Wayfern profile {}: {} -> {}", profile.name, profile.version, pending_update.new_version ); match self.profile_manager.update_profile_version( &app_handle, &profile.id.to_string(), &pending_update.new_version, ) { Ok(updated_profile_after_update) => { log::info!( "Successfully updated Wayfern profile {} from version {} to {}", profile.name, profile.version, pending_update.new_version ); updated_profile = updated_profile_after_update; if let Err(e) = self .auto_updater .dismiss_update_notification(&pending_update.id) { log::warn!("Warning: Failed to dismiss pending update notification: {e}"); } } Err(e) => { log::error!( "Failed to apply pending update for Wayfern profile {}: {}", profile.name, e ); } } } // If no pending update was applied, check if a newer installed version exists if updated_profile.version == profile.version { if let Some(p) = self .auto_updater .update_profile_to_latest_installed(&app_handle, &updated_profile) { updated_profile = p; } } log::info!( "Emitting profile events for successful Wayfern kill: {}", updated_profile.name ); // Emit profile update event to frontend if let Err(e) = events::emit("profile-updated", &updated_profile) { log::warn!("Warning: Failed to emit profile update event: {e}"); } // Emit minimal running changed event #[derive(Serialize)] struct RunningChangedPayload { id: String, is_running: bool, } let payload = RunningChangedPayload { id: updated_profile.id.to_string(), is_running: false, }; if let Err(e) = events::emit("profile-running-changed", &payload) { log::warn!("Warning: Failed to emit profile running changed event: {e}"); } else { log::info!( "Successfully emitted profile-running-changed event for Wayfern {}: running={}", updated_profile.name, payload.is_running ); } if profile.password_protected { // Await the re-encryption so the queued sync (released later by // `mark_profile_stopped` in `kill_browser`) sees fresh ciphertext on // disk instead of the previous snapshot. crate::profile::password::complete_after_quit_and_wait(profile).await; } else if profile.ephemeral { crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string()); } else if profile.clear_on_close { // Awaited for the same reason as re-encryption above: a queued sync // must see the cleared dir, not the pre-clear snapshot. crate::profile::clear_on_close::clear_profile_browsing_data(profile).await; } log::info!( "Wayfern process cleanup completed for profile: {} (ID: {})", profile.name, profile.id ); // Consolidate browser versions after stopping a browser if let Ok(consolidated) = self .downloaded_browsers_registry .consolidate_browser_versions(&app_handle) { if !consolidated.is_empty() { log::info!("Post-stop version consolidation results:"); for action in &consolidated { log::info!(" {action}"); } } } return Ok(()); } Err( format!( "Unsupported browser '{}' for profile '{}' — only Wayfern is supported", profile.browser, profile.name ) .into(), ) } pub async fn open_url_with_profile( &self, app_handle: tauri::AppHandle, profile_id: String, url: String, ) -> Result<(), String> { // Get the profile by name let profiles = self .profile_manager .list_profiles() .map_err(|e| format!("Failed to list profiles: {e}"))?; let profile = profiles .into_iter() .find(|p| p.id.to_string() == profile_id) .ok_or_else(|| format!("Profile '{profile_id}' not found"))?; let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await; if profile.is_cross_os() { return Err(format!( "Cannot open URL with profile '{}': this profile was created on {} and cannot be used on a different operating system", profile.name, profile.host_os.as_deref().unwrap_or("another OS"), )); } log::info!("Opening URL with selected profile"); // Use launch_or_open_url which handles both launching new instances and opening in existing ones self .launch_or_open_url(app_handle, &profile, Some(url.clone()), None) .await .map_err(|e| { log::info!( "Failed to open URL with selected profile: {}", crate::log_redaction::text(&e.to_string()) ); format!("Failed to open URL with profile: {e}") })?; log::info!("Successfully opened URL with selected profile"); Ok(()) } } #[tauri::command] pub async fn launch_browser_profile( app_handle: tauri::AppHandle, profile: BrowserProfile, url: Option, ) -> Result { launch_browser_profile_impl(app_handle, profile, url, None, false, false).await } pub async fn launch_browser_profile_impl( app_handle: tauri::AppHandle, profile: BrowserProfile, url: Option, remote_debugging_port: Option, headless: bool, force_new: bool, ) -> Result { log::info!( "Launch request received for profile: {} (ID: {})", profile.name, profile.id ); let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await; if profile.is_cross_os() { return Err(format!( "Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system", profile.name, profile.host_os.as_deref().unwrap_or("another OS"), )); } // Team lock check: if profile is sync-enabled and user is on a team, acquire lock crate::team_lock::acquire_team_lock_if_needed(&profile).await?; // Notify sync scheduler that profile is now running and queue sync for when it stops if let Some(scheduler) = crate::sync::get_global_scheduler() { let pid = profile.id.to_string(); scheduler.mark_profile_running(&pid).await; if profile.is_sync_enabled() { scheduler.queue_profile_sync(pid).await; } } let browser_runner = BrowserRunner::instance(); // Resolve the most up-to-date profile from disk by ID to avoid using stale proxy_id/browser state let profile_for_launch = match browser_runner .profile_manager .list_profiles() .map_err(|e| format!("Failed to list profiles: {e}")) { Ok(profiles) => profiles .into_iter() .find(|p| p.id == profile.id) .unwrap_or_else(|| profile.clone()), Err(e) => { return Err(e); } }; log::info!( "Resolved profile for launch: {} (ID: {})", profile_for_launch.name, profile_for_launch.id ); log::info!( "Starting browser launch for profile: {} (ID: {})", profile_for_launch.name, profile_for_launch.id ); if force_new && browser_runner .check_browser_status(app_handle.clone(), &profile_for_launch) .await .map_err(|error| { crate::wrap_backend_error(error, "Failed to check browser status before launch") })? { return Err(crate::backend_error("PROFILE_RUNNING")); } // Launch browser or open URL in existing instance. Wayfern starts its // own local proxy inside `launch_browser_internal`; other browser types // are rejected there, so no proxy needs to be staged here. // // `force_new` callers (API/MCP) always start a fresh instance with the // requested debug port and headless mode, bypassing the "open URL in the // existing window" path which would otherwise ignore both. let launch_result = if force_new { browser_runner .launch_browser_with_debugging( app_handle.clone(), &profile_for_launch, url, remote_debugging_port, headless, ) .await } else { browser_runner .launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None) .await }; let updated_profile = launch_result.map_err(|e| { log::info!("Browser launch failed for profile: {}, error: {}", profile_for_launch.name, e); // Emit a failure event to clear loading states in the frontend #[derive(serde::Serialize)] struct RunningChangedPayload { id: String, is_running: bool, } let payload = RunningChangedPayload { id: profile_for_launch.id.to_string(), is_running: false, }; if let Err(e) = events::emit("profile-running-changed", &payload) { log::warn!("Warning: Failed to emit profile running changed event: {e}"); } // Check if this is an architecture compatibility issue if let Some(io_error) = e.downcast_ref::() { if io_error.kind() == std::io::ErrorKind::Other && io_error.to_string().contains("Exec format error") { return format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH); } } crate::wrap_backend_error(e, "Failed to launch browser or open URL") })?; log::info!( "Browser launch completed for profile: {} (ID: {})", updated_profile.name, updated_profile.id ); // The proxy PID mapping was already reconciled inside launch_browser_internal // (placeholder → real browser PID); nothing is ever keyed by a constant here. Ok(updated_profile) } #[tauri::command] pub fn check_browser_exists(browser_str: String, version: String) -> bool { // This is an alias for is_browser_downloaded to provide clearer semantics for auto-updates let runner = BrowserRunner::instance(); runner .downloaded_browsers_registry .is_browser_downloaded(&browser_str, &version) } #[tauri::command] pub async fn kill_browser_profile( app_handle: tauri::AppHandle, profile: BrowserProfile, ) -> Result<(), String> { log::info!( "Kill request received for profile: {} (ID: {})", profile.name, profile.id ); let browser_runner = BrowserRunner::instance(); match browser_runner .kill_browser_process(app_handle.clone(), &profile) .await { Ok(()) => { log::info!( "Successfully killed browser profile: {} (ID: {})", profile.name, profile.id ); // Release team lock if applicable crate::team_lock::release_team_lock_if_needed(&profile).await; // Notify sync scheduler that profile stopped (sync was queued at launch) if let Some(scheduler) = crate::sync::get_global_scheduler() { scheduler .mark_profile_stopped(&profile.id.to_string()) .await; } // Auto-update non-running profiles and cleanup unused binaries let browser_for_update = profile.browser.clone(); let app_handle_for_update = app_handle.clone(); tauri::async_runtime::spawn(async move { let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(); let mut versions = registry.get_downloaded_versions(&browser_for_update); if !versions.is_empty() { versions.sort_by(|a, b| crate::api_client::compare_versions(b, a)); let latest_version = &versions[0]; let auto_updater = crate::auto_updater::AutoUpdater::instance(); match auto_updater .auto_update_profile_versions( &app_handle_for_update, &browser_for_update, latest_version, ) .await { Ok(updated) => { if !updated.is_empty() { log::info!( "Auto-updated {} profiles after stop: {:?}", updated.len(), updated ); } } Err(e) => { log::error!("Failed to auto-update profile versions after stop: {e}"); } } } match registry.cleanup_unused_binaries() { Ok(cleaned) => { if !cleaned.is_empty() { log::info!("Cleaned up unused binaries after stop: {:?}", cleaned); } } Err(e) => { log::error!("Failed to cleanup unused binaries after stop: {e}"); } } }); Ok(()) } Err(e) => { log::info!("Failed to kill browser profile {}: {}", profile.name, e); // Emit a failure event to clear loading states in the frontend #[derive(serde::Serialize)] struct RunningChangedPayload { id: String, is_running: bool, } // On kill failure, we assume the process is still running let payload = RunningChangedPayload { id: profile.id.to_string(), is_running: true, }; if let Err(e) = events::emit("profile-running-changed", &payload) { log::warn!("Warning: Failed to emit profile running changed event: {e}"); } Err(format!("Failed to kill browser: {e}")) } } } #[tauri::command] pub async fn open_url_with_profile( app_handle: tauri::AppHandle, profile_id: String, url: String, ) -> Result<(), String> { let browser_runner = BrowserRunner::instance(); browser_runner .open_url_with_profile(app_handle, profile_id, url) .await } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn profile_launch_lock_serializes_only_the_same_profile() { let profile = format!("launch-lock-{}", uuid::Uuid::new_v4()); let other_profile = format!("launch-lock-{}", uuid::Uuid::new_v4()); let first = lock_profile_launch(&profile).await; assert!(tokio::time::timeout( Duration::from_millis(100), lock_profile_launch(&other_profile) ) .await .is_ok()); assert!( tokio::time::timeout(Duration::from_millis(100), lock_profile_launch(&profile)) .await .is_err() ); drop(first); assert!( tokio::time::timeout(Duration::from_millis(100), lock_profile_launch(&profile)) .await .is_ok() ); } } // Global singleton instance lazy_static::lazy_static! { static ref BROWSER_RUNNER: BrowserRunner = BrowserRunner::new(); }