diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs index 61079dad..1a6b9187 100644 --- a/e2e/tests/browser.test.mjs +++ b/e2e/tests/browser.test.mjs @@ -503,6 +503,8 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc assert.equal(typeof checks.consistency, "object"); assert.equal(typeof checks.exit_probe_pending, "boolean"); assert.equal(typeof checks.exit_measurement_unreliable, "boolean"); + // A profile with no proxy or VPN has no route to check before launch. + assert.equal(checks.route_unreachable, null); // The third consistency state: what no probe can ever verify for this // profile. Reported so a launch that compared nothing is never rendered as // a launch that compared everything and agreed. @@ -1143,6 +1145,23 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc ); } + // The window's DevTools endpoint and network inspector reach the same + // browser the automation port does, and the capture the agent left + // running. + const endpoint = await app.invoke("get_profile_debug_endpoint", { + profileId: profile.id, + }); + assert.equal(endpoint.port, launched.value.remote_debugging_port); + assert.match( + endpoint.ws_url, + /^ws:\/\/127\.0\.0\.1:\d+\/devtools\/browser\//, + ); + const windowCapture = await app.invoke("start_network_capture", { + profileId: profile.id, + }); + assert.equal(windowCapture.capturing, true); + assert.equal(windowCapture.already_running, true); + const opened = await request(`${base}/v1/profiles/${profile.id}/open-url`, { method: "POST", token: saved.api_token, @@ -1158,6 +1177,26 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc }, { timeoutMs: 20_000, description: "API-opened Wayfern target" }, ); + const capturedRequest = await app.waitFor( + async () => { + const listed = await app.invoke("list_network_requests", { + profileId: profile.id, + }); + assert.equal(listed.capturing, true); + return ( + listed.requests.find((item) => + item.url.includes("/opened-via-api"), + ) ?? false + ); + }, + { timeoutMs: 20_000, description: "captured API-opened request" }, + ); + const capturedDetail = await app.invoke("get_network_request", { + profileId: profile.id, + requestId: capturedRequest.id, + }); + assert.equal(capturedDetail.id, capturedRequest.id); + assert.equal(typeof capturedDetail.request_headers, "object"); const killed = await request(`${base}/v1/profiles/${profile.id}/kill`, { method: "POST", @@ -1176,6 +1215,14 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc (await toolError("list_network_requests")).data.code, "NETWORK_CAPTURE_NOT_RUNNING", ); + assert.deepEqual( + await app.invoke("list_network_requests", { profileId: profile.id }), + { capturing: false }, + ); + assert.deepEqual( + await app.invoke("stop_network_capture", { profileId: profile.id }), + { stopped: false }, + ); const stoppedProfile = (await app.invoke("list_browser_profiles")).find( (item) => item.id === profile.id, ); @@ -1183,6 +1230,10 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc await app.invoke("check_browser_status", { profile: stoppedProfile }), false, ); + assert.equal( + await app.invoke("get_profile_debug_endpoint", { profileId: profile.id }), + null, + ); const batchProfile = await createRealProfile( app, @@ -1202,6 +1253,8 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc profile_ids: [batchProfile.id], url: `${fixtureUrl}/batch`, headless: true, + delay_ms: 0, + concurrency: 1, }, }); assert.equal(batchRun.response.status, 200); @@ -1374,6 +1427,14 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc temporaryPid, "the temporary profile must report the browser it started", ); + // The browser runs from the profile's RAM directory, and the tools must + // find it there: they once looked only under the profiles dir. + const ephemeralPage = await mcp("tools/call", { + name: "get_page_info", + arguments: { profile_id: temporaryId }, + }); + assert.notEqual(ephemeralPage.isError, true, JSON.stringify(ephemeralPage)); + assert.match(ephemeralPage.content[0].text, /temporary/); await request(`${base}/v1/profiles/${temporaryId}/kill`, { method: "POST", token: saved.api_token, diff --git a/src-tauri/src/browser_tabs.rs b/src-tauri/src/browser_tabs.rs index 68021f83..7eb96b99 100644 --- a/src-tauri/src/browser_tabs.rs +++ b/src-tauri/src/browser_tabs.rs @@ -13,7 +13,7 @@ use std::time::Duration; use serde::Serialize; use serde_json::Value; -use crate::profile::{BrowserProfile, ProfileManager}; +use crate::profile::BrowserProfile; const HTTP_TIMEOUT: Duration = Duration::from_secs(5); @@ -38,9 +38,8 @@ pub async fn local_port(profile: &BrowserProfile) -> Option { if !crate::browser_runner::browser_is_running_for(&profile.id.to_string()) { return None; } - let path = profile.get_profile_data_path(&ProfileManager::instance().get_profiles_dir()); crate::wayfern_manager::WayfernManager::instance() - .get_cdp_port(&path.to_string_lossy()) + .cdp_port_for_profile(profile) .await } diff --git a/src-tauri/src/cdp_target.rs b/src-tauri/src/cdp_target.rs index c6220de1..6aadb8a0 100644 --- a/src-tauri/src/cdp_target.rs +++ b/src-tauri/src/cdp_target.rs @@ -240,10 +240,6 @@ pub async fn resolve(profile: &BrowserProfile) -> Result Option { - let profiles_dir = crate::profile::manager::ProfileManager::instance().get_profiles_dir(); - let profile_path = profile.get_profile_data_path(&profiles_dir); - let profile_path_str = profile_path.to_string_lossy().to_string(); - // Port info is written once the process is up, so a tool called straight // after a launch has to wait for it. for attempt in 0..patience.attempts(10) { @@ -251,7 +247,7 @@ async fn local_cdp_port(profile: &BrowserProfile, patience: Patience) -> Option< tokio::time::sleep(Duration::from_secs(1)).await; } if let Some(port) = crate::wayfern_manager::WayfernManager::instance() - .get_cdp_port(&profile_path_str) + .cdp_port_for_profile(profile) .await { return Some(port); diff --git a/src-tauri/src/synchronizer.rs b/src-tauri/src/synchronizer.rs index dd383efb..d3b3bc73 100644 --- a/src-tauri/src/synchronizer.rs +++ b/src-tauri/src/synchronizer.rs @@ -1033,16 +1033,20 @@ impl SynchronizerManager { )) } } - "wheel" => { - let dx = -event.delta_x.unwrap_or(0.0); - let dy = -event.delta_y.unwrap_or(0.0); - Some(( - "Runtime.evaluate", - serde_json::json!({ - "expression": format!("window.scrollBy({dx},{dy})"), - }), - )) - } + // A wheel event at the pointer scrolls whatever is under it (a list, a + // pane, a modal), as on the leader. The captured deltas have the + // opposite sign of the command's. + "wheel" => Some(( + "Input.dispatchMouseEvent", + serde_json::json!({ + "type": "mouseWheel", + "x": event.x.unwrap_or(0.0), + "y": event.y.unwrap_or(0.0), + "deltaX": -event.delta_x.unwrap_or(0.0), + "deltaY": -event.delta_y.unwrap_or(0.0), + "modifiers": event.modifiers.unwrap_or(0), + }), + )), _ => None, }; @@ -1369,16 +1373,12 @@ impl SynchronizerManager { } async fn get_cdp_port(profile: &BrowserProfile) -> Result { - let profiles_dir = ProfileManager::instance().get_profiles_dir(); - let profile_path = profile.get_profile_data_path(&profiles_dir); - let profile_path_str = profile_path.to_string_lossy(); - for attempt in 0..15 { if attempt > 0 { tokio::time::sleep(std::time::Duration::from_secs(1)).await; } let port = crate::wayfern_manager::WayfernManager::instance() - .get_cdp_port(&profile_path_str) + .cdp_port_for_profile(profile) .await; if let Some(p) = port { return Ok(p); diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index 933787dc..13d9437e 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -3914,6 +3914,15 @@ impl WayfernManager { .unwrap_or_default() } + /// The DevTools port of the browser running `profile`. An ephemeral or + /// password-protected profile runs from its RAM directory, not from its + /// path under the profiles dir, so the lookup goes by the path it ran with. + pub async fn cdp_port_for_profile(&self, profile: &BrowserProfile) -> Option { + let profiles_dir = crate::profile::ProfileManager::instance().get_profiles_dir(); + let path = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir); + self.get_cdp_port(&path.to_string_lossy()).await + } + pub async fn get_cdp_port(&self, profile_path: &str) -> Option { let inner = self.inner.lock().await; let target_path = std::path::Path::new(profile_path) @@ -3933,65 +3942,69 @@ impl WayfernManager { None } + /// The in-memory instance registered for a profile path, if any. + fn instance_for_path( + inner: &WayfernManagerInner, + target_path: &std::path::Path, + ) -> Option { + inner.instances.iter().find_map(|(id, instance)| { + let path = instance.profile_path.as_deref()?; + let instance_path = std::path::Path::new(path) + .canonicalize() + .unwrap_or_else(|_| std::path::Path::new(path).to_path_buf()); + (instance_path == target_path).then(|| WayfernLaunchResult { + id: id.clone(), + processId: instance.process_id, + profilePath: instance.profile_path.clone(), + url: instance.url.clone(), + cdp_port: instance.cdp_port, + stored_profile: None, + }) + }) + } + + /// The running browser of a profile. Process checks run without the + /// `inner` lock, so launch, stop and CDP calls never wait on them. pub async fn find_wayfern_by_profile(&self, profile_path: &str) -> Option { - 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 = 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; - } - } - } + let known = { + let inner = self.inner.lock().await; + Self::instance_for_path(&inner, &target_path) + }; - // 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()), + if let Some(found) = known { + if let Some(pid) = found.processId { + if crate::proxy_storage::is_process_running(pid) { + return Some(found); + } + let mut inner = self.inner.lock().await; + let same_process = inner + .instances + .get(&found.id) + .is_some_and(|instance| instance.process_id == Some(pid)); + if same_process { + log::info!( + "Browser exited profile={} pid={pid}", + profile_id_in_path(profile_path) ); - 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, - stored_profile: None, - }); - } else { - log::info!( - "Browser exited profile={} pid={pid}", - profile_id_in_path(profile_path) - ); - inner.instances.remove(&id); - return None; - } + inner.instances.remove(&found.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) - { + let scan_path = target_path.clone(); + let scanned = + tokio::task::spawn_blocking(move || Self::find_wayfern_process_by_profile(&scan_path)) + .await + .ok() + .flatten(); + if let Some((pid, found_profile_path, cdp_port)) = scanned { // A browser seconds old is almost always one this app is still // launching: its instance is registered only once the launch finishes. let age_s = crate::proxy_storage::process_start_time(pid).map(|start| { @@ -4000,6 +4013,15 @@ impl WayfernManager { .map(|now| now.as_secs().saturating_sub(start)) .unwrap_or_default() }); + + let mut inner = self.inner.lock().await; + // A launch may have registered its browser while the scan ran. + if let Some(registered) = Self::instance_for_path(&inner, &target_path) + .filter(|registered| registered.processId.is_some()) + { + return Some(registered); + } + let level = if age_s.is_some_and(|age| age < 120) { log::Level::Debug } else { @@ -4040,10 +4062,14 @@ impl WayfernManager { fn find_wayfern_process_by_profile( target_path: &std::path::Path, ) -> Option<(u32, String, Option)> { - use sysinfo::{ProcessRefreshKind, RefreshKind, System}; + use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind}; let system = System::new_with_specifics( - RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()), + RefreshKind::nothing().with_processes( + ProcessRefreshKind::nothing() + .with_cmd(UpdateKind::OnlyIfNotSet) + .without_tasks(), + ), ); let target_path_str = target_path.to_string_lossy(); @@ -4138,27 +4164,32 @@ impl WayfernManager { #[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()); - } - } + let known: Vec<(String, u32)> = { + let inner = self.inner.lock().await; + inner + .instances + .iter() + .filter_map(|(id, instance)| instance.process_id.map(|pid| (id.clone(), pid))) + .collect() + }; + let dead: Vec<(String, u32)> = known + .into_iter() + .filter(|(_, pid)| !crate::proxy_storage::is_process_running(*pid)) + .collect(); + if dead.is_empty() { + return; } - for id in dead_ids { - log::debug!("Removed dead browser instance id={id}"); - inner.instances.remove(&id); + let mut inner = self.inner.lock().await; + for (id, pid) in dead { + if inner + .instances + .get(&id) + .is_some_and(|instance| instance.process_id == Some(pid)) + { + log::debug!("Removed dead browser instance id={id}"); + inner.instances.remove(&id); + } } } } @@ -6013,4 +6044,36 @@ mod tests { (800, 600) ); } + + #[tokio::test] + #[serial_test::serial] + async fn an_ephemeral_profile_finds_the_browser_running_from_its_ram_dir() { + let root = tempfile::tempdir().unwrap(); + std::env::set_var("DONUTBROWSER_EPHEMERAL_ROOT", root.path()); + let profile = BrowserProfile { + id: uuid::Uuid::new_v4(), + browser: "wayfern".to_string(), + ephemeral: true, + ..Default::default() + }; + let id = profile.id.to_string(); + let ram_dir = crate::ephemeral_dirs::create_ephemeral_dir(&id).unwrap(); + + let manager = WayfernManager::new(); + manager.inner.lock().await.instances.insert( + "launch".to_string(), + WayfernInstance { + process_id: Some(1), + profile_path: Some(ram_dir.to_string_lossy().into_owned()), + url: None, + cdp_port: Some(9333), + log_tap: BrowserLogTap::default(), + }, + ); + + assert_eq!(manager.cdp_port_for_profile(&profile).await, Some(9333)); + + crate::ephemeral_dirs::remove_ephemeral_dir(&id); + std::env::remove_var("DONUTBROWSER_EPHEMERAL_ROOT"); + } }