use std::collections::HashMap;
use serde_json::json;
use crate::cdp::client::CdpClient;
use crate::commands;
use crate::element_ref::ElementRef;
use crate::session::{self, BrowserSession, SessionStore};
pub use crate::kill::{KillOutcome, close_message, kill_pid};
pub async fn connect_page(
http_endpoint: &str,
target_id: &str,
stealth: bool,
) -> Result<CdpClient, crate::BoxError> {
let mut last_err = String::new();
for attempt in 0..8u32 {
match crate::browser::get_page_ws_url(http_endpoint, target_id).await {
Ok(page_ws) => match CdpClient::connect(&page_ws).await {
Ok(client) => {
if let Err(e) = client.call::<_, serde_json::Value>(
"Runtime.evaluate",
json!({"expression": "1", "returnByValue": true}),
).await {
last_err = format!("Connection verify failed: {e}");
drop(client);
if attempt < 7 {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
continue;
}
if let Err(e) = client.enable("Page").await {
last_err = format!("Page.enable failed: {e}");
drop(client);
if attempt < 7 {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
}
continue;
}
commands::console::inject(&client).await;
if stealth {
crate::setup::apply_stealth(&client).await;
} else {
let _ = client.enable("Runtime").await;
}
return Ok(client);
}
Err(e) => last_err = e.to_string(),
},
Err(e) => last_err = e.to_string(),
}
if attempt < 7 {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
}
}
Err(format!("Failed to connect to page after 8 attempts: {last_err}").into())
}
pub struct ActionReport {
pub inspect: bool,
pub changes: bool,
pub budget: usize,
pub max_depth: Option<usize>,
}
#[derive(Clone, Copy)]
pub struct ReportPolicy {
pub changes: bool,
pub budget: usize,
pub on_intercept: crate::hit_test::OnIntercept,
}
impl ReportPolicy {
pub const fn for_action(self, inspect: bool, max_depth: Option<usize>) -> ActionReport {
ActionReport { inspect, changes: self.changes, budget: self.budget, max_depth }
}
}
pub use crate::read_back::{bulk_fill_report, check_report, fill_value_report, select_report};
pub async fn target_details(
client: &CdpClient,
selector: Option<&str>,
uid: Option<&str>,
) -> Option<serde_json::Value> {
match (selector, uid) {
(Some(sel), _) => {
let handle = crate::hit_test::resolve_selector(client, sel).await.ok()?;
let mut out = json!({"uid": handle.uid?});
if let Some(role) = handle.role {
out["role"] = json!(role);
}
if let Some(name) = handle.name {
out["name"] = json!(name);
}
Some(out)
}
(None, Some(uid)) => Some(json!({"uid": uid})),
(None, None) => None,
}
}
#[must_use]
pub fn merge_details(
first: Option<serde_json::Value>,
second: Option<serde_json::Value>,
) -> Option<serde_json::Value> {
match (first, second) {
(Some(mut a), Some(b)) => {
if let (Some(target), Some(extra)) = (a.as_object_mut(), b.as_object()) {
for (key, value) in extra {
target.insert(key.clone(), value.clone());
}
}
Some(a)
}
(Some(only), None) | (None, Some(only)) => Some(only),
(None, None) => None,
}
}
pub async fn output_action(
client: &CdpClient,
store: &mut SessionStore,
browser_name: &str,
page_name: &str,
target_id: &str,
msg: String,
report: &ActionReport,
json_mode: bool,
) -> Result<(), crate::BoxError> {
output_action_with(client, store, browser_name, page_name, target_id, msg, report, json_mode, None).await
}
#[allow(clippy::too_many_arguments)]
pub async fn output_action_with(
client: &CdpClient,
store: &mut SessionStore,
browser_name: &str,
page_name: &str,
target_id: &str,
msg: String,
report: &ActionReport,
json_mode: bool,
details: Option<serde_json::Value>,
) -> Result<(), crate::BoxError> {
let mut obj = json!({"ok": true, "message": msg});
if let Some(fields) = details.as_ref().and_then(serde_json::Value::as_object) {
for (key, value) in fields {
obj[key.as_str()] = value.clone();
}
}
let mut trailer = String::new();
let mut observation = if report.changes {
crate::verdict::Observation::NoBaseline
} else {
crate::verdict::Observation::ReportingDisabled
};
if report.inspect || report.changes {
crate::snapshot::settle(client, 100, 1000).await;
let display_depth = if report.inspect { report.max_depth } else { None };
let Ok(views) = commands::inspect::views(client, false, display_depth, None, None).await
else {
let assessment = crate::pipe_report::attach_verdict_for(
client,
&mut obj,
crate::verdict::Observation::ReadFailed,
);
if json_mode {
json_output(&obj);
} else {
print_action(&msg, "", &obj, assessment);
}
return Ok(());
};
let shown = report.inspect.then(|| views.shown().to_string());
let snapshot = views.full;
if report.changes {
let previous = store
.browsers
.get(browser_name)
.and_then(|b| b.pages.get(page_name))
.map(|p| {
(
p.last_snapshot.clone(),
p.last_snapshot_frame.clone().zip(p.last_snapshot_loader.clone()),
)
});
if let Some((Some(old_text), stored)) = previous {
let identity = commands::diff::Identity::from_loader(
stored.as_ref().map(|(f, l)| (f.as_str(), l.as_str())),
snapshot.identity.as_ref().map(|(f, l)| (f.as_str(), l.as_str())),
);
let cmp = commands::diff::compare(identity, &old_text, &snapshot.text);
let body = if report.budget == 0 {
cmp.text.clone()
} else {
crate::truncate::truncate_str(
cmp.text.trim_end(),
report.budget,
"\n… truncated, run `inspect` for the rest",
)
.into_owned()
};
obj["changed"] = json!({
"added": cmp.added,
"removed": cmp.removed,
"changed": cmp.changed,
"unchanged": cmp.unchanged,
"moved": cmp.moved,
"anonymous": cmp.anonymous,
"document_changed": cmp.document_changed,
"identity_known": cmp.identity_known,
});
obj["delta"] = json!(body);
let values_lost = Box::pin(crate::pipe_report::attach_values_lost(
client,
&snapshot.uid_map,
&cmp.values_lost,
&mut obj,
))
.await;
observation = crate::verdict::Observation::Compared {
document_changed: cmp.document_changed,
identity_known: cmp.identity_known,
edits: cmp.added + cmp.removed + cmp.changed,
moved: cmp.moved,
focus_moved: cmp.focus_from.is_some() || cmp.focus_to.is_some(),
values_lost,
};
if cmp.focus_from.is_some() || cmp.focus_to.is_some() {
obj["focus"] = json!({"from": cmp.focus_from, "to": cmp.focus_to});
}
if let Some(hint) = cmp.hint {
obj["hint"] = json!(hint);
}
trailer = body;
}
}
if let Some(shown) = shown {
obj["snapshot"] = json!(&shown);
trailer = shown;
}
if let Some(browser_s) = store.browsers.get_mut(browser_name) {
let page = session::ensure_page(browser_s, page_name, target_id);
page.last_snapshot = Some(snapshot.text);
let (f, l) = snapshot.identity.map_or((None, None), |(f, l)| (Some(f), Some(l)));
page.last_snapshot_frame = f;
page.last_snapshot_loader = l;
page.uid_map = snapshot.uid_map;
}
}
let assessment = crate::pipe_report::attach_verdict_for(client, &mut obj, observation);
if json_mode {
json_output(&obj);
} else {
print_action(&msg, &trailer, &obj, assessment);
}
Ok(())
}
fn print_action(
msg: &str,
trailer: &str,
obj: &serde_json::Value,
assessment: crate::verdict::Assessment,
) {
println!("{msg}");
if !trailer.is_empty() {
println!("{}", trailer.trim_end());
}
for line in crate::render::action_lines(obj, assessment, crate::render::Paint::for_stdout()) {
println!("{line}");
}
}
pub fn attach_verdict(obj: &mut serde_json::Value, assessment: crate::verdict::Assessment) {
obj["verdict"] = json!(assessment.verdict.as_str());
obj["verdict_reason"] = json!(assessment.reason);
obj["next"] = json!(crate::verdict::next_for(assessment).as_str());
if let Some(hint) = crate::verdict::hint_for(assessment) {
if let Some(map) = obj.as_object_mut() {
map.entry("verdict_hint").or_insert_with(|| json!(hint));
}
}
}
pub async fn output_goto(
client: &CdpClient,
store: &mut SessionStore,
browser_name: &str,
page_name: &str,
target_id: &str,
url: &str,
title: &str,
landed: Option<&crate::landing::Landing>,
inspect: bool,
max_depth: Option<usize>,
json_mode: bool,
) -> Result<(), crate::BoxError> {
let browser_session = store.browsers.get_mut(browser_name)
.ok_or_else(|| format!("Browser session '{browser_name}' not found in session store"))?;
let page = session::ensure_page(
browser_session,
page_name,
target_id,
);
page.uid_map.clear();
if json_mode {
let mut obj = json!({"ok": true, "url": url, "title": title});
if let Some(landing) = landed {
landing.attach(&mut obj, browser_name);
}
if inspect {
let views = commands::inspect::views(client, false, max_depth, None, None).await?;
obj["snapshot"] = json!(views.shown());
let full = views.full;
page.last_snapshot = Some(full.text);
let (f, l) = full.identity.map_or((None, None), |(f, l)| (Some(f), Some(l)));
page.last_snapshot_frame = f;
page.last_snapshot_loader = l;
page.uid_map = full.uid_map;
}
json_output(&obj);
} else {
if title.is_empty() {
println!("{url}");
} else {
println!("{url} — {title}");
}
if let Some(line) = landed.and_then(|landing| landing.text_line(browser_name)) {
println!("{line}");
}
if inspect {
let views = commands::inspect::views(client, false, max_depth, None, None).await?;
println!("{}", views.shown());
let full = views.full;
page.last_snapshot = Some(full.text);
let (f, l) = full.identity.map_or((None, None), |(f, l)| (Some(f), Some(l)));
page.last_snapshot_frame = f;
page.last_snapshot_loader = l;
page.uid_map = full.uid_map;
}
}
Ok(())
}
pub fn json_output(value: &serde_json::Value) {
println!("{}", serde_json::to_string(value).unwrap_or_default());
}
pub use crate::hints::error_hint;
pub fn get_uid_map(store: &SessionStore, browser_name: &str, page_name: &str) -> HashMap<String, ElementRef> {
store
.browsers
.get(browser_name)
.and_then(|b| b.pages.get(page_name))
.map(|p| p.uid_map.clone())
.unwrap_or_default()
}
pub async fn resolve_page_target(
client: &CdpClient,
browser_session: &mut BrowserSession,
page_name: &str,
) -> Result<String, crate::BoxError> {
if let Some(page) = browser_session.pages.get(page_name) {
return Ok(page.target_id.clone());
}
if page_name == "default" {
let result: crate::cdp::types::GetTargetsResult = client
.call("Target.getTargets", serde_json::json!({}))
.await?;
let claimed_targets: std::collections::HashSet<&str> = browser_session
.pages
.values()
.map(|p| p.target_id.as_str())
.collect();
let available = result
.target_infos
.iter()
.find(|t| t.target_type == "page" && !claimed_targets.contains(t.target_id.as_str()));
if let Some(target) = available {
let target_id = target.target_id.clone();
session::ensure_page(browser_session, page_name, &target_id);
return Ok(target_id);
}
}
let create_result: crate::cdp::types::CreateTargetResult = client
.call(
"Target.createTarget",
crate::cdp::types::CreateTargetParams {
url: "about:blank".into(),
width: None,
height: None,
new_window: None,
background: None,
},
)
.await?;
let target_id = create_result.target_id;
session::ensure_page(browser_session, page_name, &target_id);
Ok(target_id)
}
pub fn cmd_status(json_mode: bool) -> Result<(), crate::BoxError> {
let store = session::load_session()?;
let daemon_alive = session::daemon_socket_exists();
let orphans = crate::orphans::scan(&store);
if json_mode {
let browsers: Vec<serde_json::Value> = store
.browsers
.iter()
.map(|(name, b)| {
json!({
"name": name,
"pid": b.pid,
"headless": b.headless,
"pages": b.pages.len(),
"ws": b.ws_endpoint,
})
})
.collect();
let orphan_json = orphans.as_ref().map(|found| {
found
.iter()
.map(|o| json!({"name": o.name, "pid": o.pid}))
.collect::<Vec<_>>()
});
json_output(&json!({
"ok": true,
"browsers": browsers,
"orphans": orphan_json,
"daemon": if daemon_alive { "running" } else { "stopped" },
}));
} else {
if store.browsers.is_empty() {
println!("No active browser sessions.");
} else {
for (name, browser) in &store.browsers {
let status = if let Some(pid) = browser.pid {
format!("pid={pid}")
} else {
"external".into()
};
let mode = if browser.headless { "headless" } else { "headed" };
println!(
"browser={name} {status} {mode} pages={} ws={}",
browser.pages.len(),
browser.ws_endpoint
);
}
}
for orphan in orphans.iter().flatten() {
println!(
"orphan={} pid={} no session entry — close with `chrome-agent close --orphans`",
orphan.name, orphan.pid
);
}
println!(
"daemon: {}",
if daemon_alive { "running" } else { "stopped" }
);
}
Ok(())
}
#[cfg(any(unix, test))]
const fn stop_message(reached_daemon: bool) -> &'static str {
if reached_daemon {
"Daemon stopped."
} else {
"Daemon is not running."
}
}
pub async fn cmd_stop(json_mode: bool) -> Result<(), crate::BoxError> {
#[cfg(not(unix))]
{
let msg = "Daemon is not supported on this platform.";
if json_mode { json_output(&json!({"ok": true, "message": msg})); }
else { println!("{msg}"); }
return Ok(());
}
#[cfg(unix)]
{
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
let socket_path = session::daemon_socket_path()?;
let stream = if socket_path.exists() {
match UnixStream::connect(&socket_path).await {
Ok(stream) => Some(stream),
Err(_) => {
let _ = std::fs::remove_file(&socket_path);
None
}
}
} else {
None
};
let Some(mut stream) = stream else {
let msg = stop_message(false);
if json_mode { json_output(&json!({"ok": true, "message": msg})); }
else { println!("{msg}"); }
return Ok(());
};
stream
.write_all(b"{\"command\":\"stop\"}\n")
.await?;
stream.shutdown().await?;
let mut buf = Vec::new();
let _ = stream.read_to_end(&mut buf).await;
let msg = stop_message(true);
if json_mode { json_output(&json!({"ok": true, "message": msg})); }
else { println!("{msg}"); }
Ok(())
} }
#[must_use]
pub const fn interrupt_owns_browser(command: &crate::cli::Command) -> bool {
use crate::cli::Command as C;
!matches!(
command,
C::Daemon { .. } | C::Status | C::Stop | C::Close { .. } | C::History { .. }
)
}
#[must_use]
pub fn interrupt_kill_target(store: &SessionStore, browser_name: &str) -> Option<u32> {
store.browsers.get(browser_name).and_then(|b| b.pid)
}
fn purge_profile(profile_dir: &std::path::Path) -> Result<(), String> {
let mut last_error = None;
for attempt in 0..8u32 {
if attempt > 0 {
std::thread::sleep(std::time::Duration::from_millis(250));
}
if !profile_dir.exists() {
return Ok(());
}
if let Err(e) = std::fs::remove_dir_all(profile_dir) {
last_error = Some(e.to_string());
}
}
Err(last_error.unwrap_or_else(|| {
"profile was recreated after every removal; the browser may still be shutting down"
.to_string()
}))
}
pub fn cmd_purge_orphans(json_mode: bool) -> Result<(), crate::BoxError> {
let store = session::load_session()?;
let referenced = store.browsers.keys().cloned().collect();
let browsers_dir = session::browsers_dir()?;
let grace = crate::profiles::Limits::default().grace;
let mut removed = 0usize;
let mut failed = Vec::new();
for path in crate::profiles::all_removable(&browsers_dir, &referenced, grace) {
match std::fs::remove_dir_all(&path) {
Ok(()) => removed += 1,
Err(e) => failed.push(format!("{}: {e}", path.display())),
}
}
let message = format!("Purged {removed} orphaned profile(s)");
if json_mode {
json_output(&json!({"ok": true, "message": message, "purged": removed, "failed": failed}));
} else {
println!("{message}");
for failure in &failed {
eprintln!("warning: {failure}");
}
}
Ok(())
}
pub fn cmd_close(browser_name: &str, purge: bool, json_mode: bool) -> Result<(), crate::BoxError> {
let mut store = session::load_session()?;
let browser = store.browsers.remove(browser_name);
let outcome = browser.as_ref().and_then(|b| b.pid).map(|pid| (pid, kill_pid(pid)));
let exited = outcome.map(|(pid, o)| match o {
crate::kill::KillOutcome::Signalled => {
let gone = crate::kill::wait_until_gone(pid, std::time::Duration::from_secs(5));
if gone && let Ok(dir) = session::browsers_dir() {
let _ = std::fs::remove_file(dir.join(browser_name).join("DevToolsActivePort"));
}
gone
}
crate::kill::KillOutcome::Gone => true,
crate::kill::KillOutcome::NotABrowser => false,
});
let message = match (&browser, outcome) {
(Some(_), Some((pid, outcome))) => {
let base = close_message(browser_name, pid, outcome);
if exited == Some(false) && outcome == KillOutcome::Signalled {
format!("{base} — signalled but still shutting down after 5s")
} else {
base
}
}
(Some(_), None) => format!("Removed external browser session: {browser_name}"),
(None, _) => format!("No browser session named '{browser_name}'."),
};
let purge_outcome = if purge {
session::browsers_dir().ok().map(|dir| purge_profile(&dir.join(browser_name)))
} else {
None
};
let message = match purge_outcome {
None => message,
Some(Ok(())) => format!("{message} (profile purged)"),
Some(Err(e)) => format!("{message} (profile NOT purged: {e})"),
};
if json_mode {
let mut response = json!({
"ok": true,
"message": message,
"signalled": outcome.is_some_and(|(_, o)| o == KillOutcome::Signalled),
});
if outcome.is_some_and(|(_, o)| o == KillOutcome::Signalled) {
response["exited"] = json!(exited == Some(true));
}
json_output(&response);
} else {
println!("{message}");
}
session::save_session(&mut store)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_command_that_never_opens_a_browser_has_none_to_interrupt() {
use crate::cli::Command as C;
for command in [
C::Daemon { action: crate::cli::DaemonAction::Start },
C::Status,
C::Stop,
C::Close { purge: false, purge_orphans: false, orphans: false },
C::History { filter: None, limit: 20 },
] {
assert!(
!interrupt_owns_browser(&command),
"this command never opens a browser, so it has none to kill"
);
}
assert!(interrupt_owns_browser(&C::Tabs));
assert!(interrupt_owns_browser(&C::Pipe));
}
#[test]
fn an_interrupt_only_targets_this_invocation_s_browser() {
let mut store = SessionStore::default();
session::ensure_browser(&mut store, "agent-1", "ws://a", Some(111), true, None, Vec::new());
session::ensure_browser(&mut store, "agent-2", "ws://b", Some(222), true, None, Vec::new());
assert_eq!(interrupt_kill_target(&store, "agent-1"), Some(111));
assert_eq!(
interrupt_kill_target(&store, "agent-2"),
Some(222),
"a sibling agent's browser is never this invocation's to kill"
);
assert_eq!(interrupt_kill_target(&store, "never-launched"), None);
}
#[test]
fn stop_message_reflects_daemon_reachability() {
assert_eq!(stop_message(true), "Daemon stopped.");
assert_eq!(stop_message(false), "Daemon is not running.");
}
}