diff --git a/AGENTS.md b/AGENTS.md index 89a1686..adb4354 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,16 @@ donutbrowser/ - The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter: `pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed. +## Logs (when debugging a running app) + +Three log surfaces, in order of usefulness: + +- **Donut Browser GUI** — `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_.log` are rotated). The GUI / Tauri / `browser_runner` / `proxy_manager` / `sync` all log here. Search for `Camoufox`, `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead. +- **donut-proxy worker** — `$TMPDIR/donut-proxy-.log`. One file per proxy worker process (each profile launch spawns a fresh one). Map a worker to its launch via the `Cleanup: browser PID X is dead, stopping proxy worker ` lines in DonutBrowser.log, or by mtime. CONNECT requests, upstream accept/reject (status lines like `HTTP/1.1 402 user reached limit`), and tunnel errors are at INFO/WARN — anything finer is at TRACE and requires `RUST_LOG=donut_proxy=trace`. The `Upstream CONNECT response coalesced N byte(s) of payload — these would be dropped without forwarding` warning marks a real bug in `handle_connect_from_buffer` if it ever fires. +- **Camoufox stderr** — `$TMPDIR/camoufox-stderr-.log`, written by `camoufox_manager::launch_camoufox`. Captures NSS / GPU Helper / juggler errors. Firefox does **not** print TLS/network errors here by default — set `MOZ_LOG=nsHttp:5,signaling:5` on the env if you need that. The `RustSearch.sys.mjs missing field 'recordType'` lines are noise from our `search.json.mozlz4` schema being slightly off for FF150+; not a network problem. + +Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropriate location (see `app_dirs::app_name()`), but the `$TMPDIR` worker logs are always under the system temp dir. + ## Code Quality - Don't leave comments that don't add value diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 8e908d4..11990ea 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -1750,13 +1750,15 @@ async fn run_profile( port }; - // Use the same launch method as the main app, but with remote debugging enabled - match crate::browser_runner::launch_browser_profile_with_debugging( + // Use the same launch path as the main app, but force a fresh instance with + // remote debugging enabled so the returned port is the one the browser binds. + match crate::browser_runner::launch_browser_profile_impl( state.app_handle.clone(), profile.clone(), url, Some(remote_debugging_port), headless, + true, ) .await { diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index bc67069..f256a62 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -7,78 +7,11 @@ use crate::platform_browser; use crate::profile::{BrowserProfile, ProfileManager}; use crate::proxy_manager::PROXY_MANAGER; use crate::wayfern_manager::{WayfernConfig, WayfernManager}; -use chrono::{Datelike, TimeZone, Utc}; use serde::Serialize; use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use sysinfo::System; -/// Fixed UTC hour at which Wayfern fingerprints rotate. Picked to land in a -/// low-traffic window for the average user; everyone shares the same UTC -/// instant so the value here doesn't track any one user's local schedule. -const FINGERPRINT_ROLLOVER_HOUR_UTC: u32 = 4; - -/// File name of the per-profile marker recording the last fingerprint -/// refresh time. Lives at `//.last-fp-refresh` -/// and is excluded from cloud sync (see `sync::manifest`) so each device -/// runs its own refresh schedule. -const LAST_FP_REFRESH_FILE: &str = ".last-fp-refresh"; - -/// Most recent rollover instant on or before `now` — used as a staleness -/// threshold for Wayfern fingerprints. Anything generated before this -/// timestamp is considered stale and gets regenerated on next launch. -fn most_recent_rollover_epoch() -> u64 { - let now = Utc::now(); - let today_threshold = Utc - .with_ymd_and_hms( - now.year(), - now.month(), - now.day(), - FINGERPRINT_ROLLOVER_HOUR_UTC, - 0, - 0, - ) - .single() - .unwrap_or(now); - let threshold = if now >= today_threshold { - today_threshold - } else { - today_threshold - chrono::Duration::days(1) - }; - threshold.timestamp().max(0) as u64 -} - -fn last_fp_refresh_path(profile_id: &str, profiles_dir: &std::path::Path) -> PathBuf { - profiles_dir.join(profile_id).join(LAST_FP_REFRESH_FILE) -} - -/// Read the epoch-seconds timestamp stored in the per-profile refresh marker. -/// Returns `None` if the file doesn't exist or its content can't be parsed — -/// both signal "needs a refresh" to the caller. -fn read_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path) -> Option { - let path = last_fp_refresh_path(profile_id, profiles_dir); - let content = std::fs::read_to_string(&path).ok()?; - content.trim().parse::().ok() -} - -/// Record `ts` (epoch seconds) as the most recent fingerprint refresh for -/// this profile. Failure is logged but never propagated — a missing marker -/// only costs an extra regen on the next launch, never blocks one. -fn write_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path, ts: u64) { - let path = last_fp_refresh_path(profile_id, profiles_dir); - if let Some(parent) = path.parent() { - if !parent.exists() { - if let Err(e) = std::fs::create_dir_all(parent) { - log::warn!("Failed to create profile dir for fingerprint refresh marker {profile_id}: {e}"); - return; - } - } - } - if let Err(e) = std::fs::write(&path, ts.to_string()) { - log::warn!("Failed to write fingerprint refresh marker for {profile_id}: {e}"); - } -} - pub struct BrowserRunner { pub profile_manager: &'static ProfileManager, pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry, @@ -448,6 +381,7 @@ impl BrowserRunner { camoufox_config, url, override_profile_path, + remote_debugging_port, headless, ) .await @@ -612,32 +546,12 @@ impl BrowserRunner { wayfern_config.proxy ); - // Decide whether to (re)generate the Wayfern fingerprint for this - // launch. Two triggers: - // - // 1. `randomize_fingerprint_on_launch = true` — explicit per-launch - // randomization the user opted into. - // 2. The fingerprint hasn't been refreshed since the most recent - // rollover instant. We check the per-profile marker file first - // (`.last-fp-refresh`); if it's absent we fall back to - // `profile.created_at` so brand-new profiles don't immediately - // regenerate the fingerprint they were just created with. - // Profiles with neither (truly legacy) are treated as ancient - // and refresh on next launch — once. + // Check if we need to generate a new fingerprint on every launch let mut updated_profile = profile.clone(); - let stale_threshold = most_recent_rollover_epoch(); - let profile_id_str = profile.id.to_string(); - let profiles_dir_for_marker = self.profile_manager.get_profiles_dir(); - let effective_last_refresh = - read_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker).or(profile.created_at); - let is_stale_profile = effective_last_refresh.is_none_or(|ts| ts < stale_threshold); - let randomize_every_launch = wayfern_config.randomize_fingerprint_on_launch == Some(true); - if randomize_every_launch || is_stale_profile { + if wayfern_config.randomize_fingerprint_on_launch == Some(true) { log::info!( - "Generating Wayfern fingerprint for profile {} (per-launch={}, rollover={})", - profile.name, - randomize_every_launch, - is_stale_profile + "Generating random fingerprint for Wayfern profile: {}", + profile.name ); // Create a config copy without the existing fingerprint to force generation of a new one @@ -659,24 +573,12 @@ impl BrowserRunner { // Update the config with the new fingerprint for launching wayfern_config.fingerprint = Some(new_fingerprint.clone()); - // Write the marker so the next launch within the same rollover - // window skips this branch. The marker is excluded from cloud - // sync (see `sync::manifest::DEFAULT_EXCLUDE_PATTERNS`), so each - // device's refresh schedule is independent. - let now_epoch = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(stale_threshold); - write_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker, now_epoch); - // 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 user's randomize-on-launch preference rather than - // forcing it on. The rollover path must not silently flip this - // flag for users who only opted into the scheduled refresh. - updated_wayfern_config.randomize_fingerprint_on_launch = - wayfern_config.randomize_fingerprint_on_launch; + // 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(); } @@ -935,57 +837,19 @@ impl BrowserRunner { remote_debugging_port: Option, headless: bool, ) -> Result> { - // Always start a local proxy for API launches - let upstream_proxy = self - .resolve_launch_proxy(profile) - .await - .map_err(|e| -> Box { e.into() })?; - - // Use a temporary PID (1) to start the proxy, we'll update it after browser launch - let temp_pid = 1u32; - let profile_id_str = profile.id.to_string(); - - // Start local proxy - if this fails, DO NOT launch browser - let blocklist_file = Self::resolve_blocklist_file(profile) - .await - .map_err(|e| -> Box { e.into() })?; - let internal_proxy = PROXY_MANAGER - .start_proxy( - app_handle.clone(), - upstream_proxy.as_ref(), - temp_pid, - Some(&profile_id_str), - profile.proxy_bypass_rules.clone(), - blocklist_file, - ) - .await - .map_err(|e| { - let error_msg = format!("Failed to start local proxy: {e}"); - log::error!("{}", error_msg); - error_msg - })?; - - let internal_proxy_settings = Some(internal_proxy.clone()); - - let result = self + // Camoufox and Wayfern start (and PID-reconcile) their 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.clone(), + app_handle, profile, url, - internal_proxy_settings.as_ref(), + None, remote_debugging_port, headless, ) - .await; - - // Update proxy with correct PID if launch succeeded - if let Ok(ref updated_profile) = result { - if let Some(actual_pid) = updated_profile.process_id { - let _ = PROXY_MANAGER.update_proxy_pid(temp_pid, actual_pid); - } - } - - result + .await } pub async fn launch_or_open_url( @@ -2395,6 +2259,17 @@ 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: {})", @@ -2424,9 +2299,6 @@ pub async fn launch_browser_profile( let browser_runner = BrowserRunner::instance(); - // Store the internal proxy settings for passing to launch_browser - let mut internal_proxy_settings: Option = None; - // 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 @@ -2448,112 +2320,36 @@ pub async fn launch_browser_profile( profile_for_launch.id ); - // Always start a local proxy before launching (non-Camoufox/Wayfern handled here; they have their own flow) - // This ensures all traffic goes through the local proxy for monitoring and future features - if profile.browser != "camoufox" && profile.browser != "wayfern" { - // Determine upstream proxy if configured; otherwise use DIRECT (no upstream) - // Refresh cloud proxy credentials and inject profile-specific sid - let mut upstream_proxy = BrowserRunner::instance() - .resolve_launch_proxy(&profile_for_launch) - .await?; - - // 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_for_launch.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, - }); - log::info!("VPN worker started for profile on port {}", port); - } - } - Err(e) => { - return Err(format!("Failed to start VPN worker: {e}")); - } - } - } - } - - // Use a temporary PID (1) to start the proxy, we'll update it after browser launch - let temp_pid = 1u32; - let profile_id_str = profile.id.to_string(); - - // Always start a local proxy, even if there's no upstream proxy - // This allows for traffic monitoring and future features - let blocklist_file = BrowserRunner::resolve_blocklist_file(&profile_for_launch).await?; - match PROXY_MANAGER - .start_proxy( - app_handle.clone(), - upstream_proxy.as_ref(), - temp_pid, - Some(&profile_id_str), - profile_for_launch.proxy_bypass_rules.clone(), - blocklist_file, - ) - .await - { - Ok(internal_proxy) => { - // Use internal proxy for subsequent launch - internal_proxy_settings = Some(internal_proxy.clone()); - - // For Firefox-based browsers, always apply PAC/user.js to point to the local proxy - if matches!( - profile_for_launch.browser.as_str(), - "firefox" | "firefox-developer" | "zen" - ) { - let profiles_dir = browser_runner.profile_manager.get_profiles_dir(); - let profile_path = profiles_dir - .join(profile_for_launch.id.to_string()) - .join("profile"); - - // Provide a dummy upstream (ignored when internal proxy is provided) - let dummy_upstream = ProxySettings { - proxy_type: "http".to_string(), - host: "127.0.0.1".to_string(), - port: internal_proxy.port, - username: None, - password: None, - }; - - browser_runner - .profile_manager - .apply_proxy_settings_to_profile(&profile_path, &dummy_upstream, Some(&internal_proxy)) - .map_err(|e| format!("Failed to update profile proxy: {e}"))?; - } - - log::info!( - "Local proxy prepared for profile: {} on port: {} (upstream: {})", - profile_for_launch.name, - internal_proxy.port, - upstream_proxy - .as_ref() - .map(|p| format!("{}:{}", p.host, p.port)) - .unwrap_or_else(|| "DIRECT".to_string()) - ); - } - Err(e) => { - let error_msg = format!("Failed to start local proxy: {e}"); - log::error!("{}", error_msg); - // DO NOT launch browser if proxy startup fails - all browsers must use local proxy - return Err(error_msg); - } - } - } - log::info!( "Starting browser launch for profile: {} (ID: {})", profile_for_launch.name, profile_for_launch.id ); - // Launch browser or open URL in existing instance - let updated_profile = browser_runner.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, internal_proxy_settings.as_ref()).await.map_err(|e| { + // Launch browser or open URL in existing instance. Camoufox and Wayfern + // start their own local proxies inside `launch_browser_internal`; any + // other browser type is rejected there (we only support those for import, + // not launch), 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 @@ -2710,28 +2506,6 @@ pub async fn kill_browser_profile( } } -pub async fn launch_browser_profile_with_debugging( - app_handle: tauri::AppHandle, - profile: BrowserProfile, - url: Option, - remote_debugging_port: Option, - headless: bool, -) -> Result { - 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"), - )); - } - - let browser_runner = BrowserRunner::instance(); - browser_runner - .launch_browser_with_debugging(app_handle, &profile, url, remote_debugging_port, headless) - .await - .map_err(|e| format!("Failed to launch browser with debugging: {e}")) -} - #[tauri::command] pub async fn open_url_with_profile( app_handle: tauri::AppHandle, diff --git a/src-tauri/src/camoufox_manager.rs b/src-tauri/src/camoufox_manager.rs index f8c27b1..6b388a3 100644 --- a/src-tauri/src/camoufox_manager.rs +++ b/src-tauri/src/camoufox_manager.rs @@ -200,6 +200,7 @@ impl CamoufoxManager { } /// Launch Camoufox browser by directly spawning the process + #[allow(clippy::too_many_arguments)] pub async fn launch_camoufox( &self, _app_handle: &AppHandle, @@ -207,6 +208,7 @@ impl CamoufoxManager { profile_path: &str, config: &CamoufoxConfig, url: Option<&str>, + remote_debugging_port: Option, headless: bool, ) -> Result> { let custom_config = if let Some(existing_fingerprint) = &config.fingerprint { @@ -249,7 +251,10 @@ impl CamoufoxManager { .to_string(), ]; - let cdp_port = Self::find_free_port().await?; + let cdp_port = match remote_debugging_port { + Some(p) => p, + None => Self::find_free_port().await?, + }; args.push(format!("--remote-debugging-port={cdp_port}")); // Add URL if provided @@ -666,6 +671,7 @@ impl CamoufoxManager { } impl CamoufoxManager { + #[allow(clippy::too_many_arguments)] pub async fn launch_camoufox_profile( &self, app_handle: AppHandle, @@ -673,6 +679,7 @@ impl CamoufoxManager { config: CamoufoxConfig, url: Option, override_profile_path: Option, + remote_debugging_port: Option, headless: bool, ) -> Result { // Get profile path @@ -817,6 +824,7 @@ impl CamoufoxManager { &profile_path_str, &config, url.as_deref(), + remote_debugging_port, headless, ) .await diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 69caa14..d04187f 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1832,7 +1832,7 @@ impl McpServer { })?; let url = arguments.get("url").and_then(|v| v.as_str()); - let _headless = arguments + let headless = arguments .get("headless") .and_then(|v| v.as_bool()) .unwrap_or(false); @@ -1876,19 +1876,21 @@ impl McpServer { message: "MCP server not properly initialized".to_string(), })?; - // Launch the browser - crate::browser_runner::BrowserRunner::instance() - .launch_browser( - app_handle.clone(), - profile, - url.map(|s| s.to_string()), - None, - ) - .await - .map_err(|e| McpError { - code: -32000, - message: format!("Failed to launch browser: {e}"), - })?; + // Launch a fresh instance, honoring the requested headless mode. The CDP + // port is self-allocated and discovered later via get_cdp_port_for_profile. + crate::browser_runner::launch_browser_profile_impl( + app_handle.clone(), + profile.clone(), + url.map(|s| s.to_string()), + None, + headless, + true, + ) + .await + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to launch browser: {e}"), + })?; Ok(serde_json::json!({ "content": [{