mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 06:51:24 +02:00
fix: ephemeral CDP attach
This commit is contained in:
1 parent
42783778f1
commit
448303ee40
5 files changed
+209
-90
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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<u16> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -240,10 +240,6 @@ pub async fn resolve(profile: &BrowserProfile) -> Result<CdpTarget, ResolveError
|
||||
|
||||
/// The debugging port a locally launched browser registered for this profile.
|
||||
async fn local_cdp_port(profile: &BrowserProfile, patience: Patience) -> Option<u16> {
|
||||
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);
|
||||
|
||||
@@ -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<u16, String> {
|
||||
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);
|
||||
|
||||
@@ -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<u16> {
|
||||
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<u16> {
|
||||
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<WayfernLaunchResult> {
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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<u16>)> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user