app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
master
John Lauer
v2.0.735 (insiders): x.com and other sites that refuse a fresh sign-in from pup's browser (John, 2026-10-27, pup-bridge…
55f65e3
14d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
//! Input and page verbs: click, type, press_key, hover, scroll, fill, wait, reload, back/forward,
//! set_viewport, errors, dialog, input_dispatch, render_html, fetch_url, maximize. Trusted input
//! goes through Input.dispatch{Mouse,Key}Event; element location is one JS round-trip
//! (scrollIntoView + getBoundingClientRect), exactly the Node "coordinate" method.
use crate::{browser_for, keys, resolve, touch, Ctx};
use pup_cdp::Cdp;
use pup_core::{err, ok, Caller};
use pup_session::{Session, Tab};
use serde_json::{json, Value};
use std::sync::Arc;
use std::time::Duration;
const T: Duration = Duration::from_secs(10);
fn js_str(s: &str) -> String { serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into()) }
/// Session + the tab addressed by args.tabId (default: active) + its live browser.
pub fn resolve_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Result<(Session, Tab, Arc<crate::BrowserHandle>), Value> {
let s = resolve(ctx, caller, args)?;
// 2.0.535: every CDP-only verb comes through here. On an extension-lane window there is no CDP handle,
// and before this guard such verbs silently did nothing (ab-smoke on the default lane: login and download
// "succeeded" and changed nothing). Refuse loudly, and say what does work here.
// 2.0.578: the extension lane answers these verbs too, through nbe's DevTools driver (chrome.debugger,
// attached on demand, detached after idle). Recording and downloads still refuse below: they wait on
// DevTools EVENTS, which the extension channel does not stream.
let ext = crate::transport::is_ext(&s);
let by_url = args.get("_tabUrl").and_then(|u| u.as_str()).and_then(|u| crate::session_for_tab_url(ctx, u)).filter(|(w, _)| w.id == s.id).map(|(_, t)| t);
let t = args.get("tabId").and_then(|t| t.as_str()).map(|id| s.tab_by_id(id).cloned()).unwrap_or_else(|| by_url.or_else(|| s.active().cloned())).ok_or_else(|| err("no_tab", "the window has no tabs", None))?;
// 2.0.714: talk to the window's OWN browser. Without this every input verb on an isolated or named-profile window
// (click, type, keys, mouse) went down the MAIN browser's channel: typing "capacitor" into an isolated window came
// back "selector not found" because it searched the other browser's page (found by the shared-box jump-list gate,
// 2026-10-12). lane_for has done this since 2.0.659; resolve_tab never did.
if ext { crate::extdrive::enter_profile(&s.profile); }
let b = if ext { crate::extdrive::ext_handle() } else { browser_for(ctx, &s.profile).ok_or_else(|| err("browser_gone", "the browser behind this window is not connected", Some("pup_close_window then pup_open_window; recovery reconnects live Chromes at boot.")))? };
crate::focus_emulate(ctx, &s.id, Some(&t.tab_id)); // 2.0.458: this tab is the one pup is driving
Ok((s, t, b))
}
fn out(sess: &Session, tab: &Tab, mut v: Value) -> Value {
// Node returned these verbs as {success:true, output:"<json string>"}; keep that envelope.
v["sessionId"] = json!(sess.id); v["tabId"] = json!(tab.tab_id); v["ok"] = json!(true);
ok(json!({ "success": true, "output": v.to_string(), "window": sess.id, "aiThread": sess.owner }))
}
/// Locate an element by selector (or visible text): scrolls it into view, returns its center.
fn locate(cdp: &Cdp, sid: &str, selector: Option<&str>, text: Option<&str>) -> Result<Option<Value>, Value> {
let expr = format!(r#"(() => {{
const sel = {sel}, txt = {txt};
let el = null;
if (sel) {{ try {{ el = document.querySelector(sel); }} catch (e) {{ return {{ error: 'bad selector: ' + e.message }}; }} }}
if (!el && txt) {{
const t = txt.toLowerCase(); const all = document.querySelectorAll('a,button,input,label,span,div,li,td,th,h1,h2,h3,h4,p,summary,[role=button],[role=link],[role=tab],[role=menuitem]');
for (const c of all) {{ const own = (c.innerText || c.value || c.getAttribute('aria-label') || '').trim(); if (own && own.toLowerCase().indexOf(t) !== -1) {{ const r = c.getBoundingClientRect(); if (r.width > 0 && r.height > 0) {{ el = c; if (own.toLowerCase() === t) break; }} }} }}
}}
if (!el) return null;
el.scrollIntoView({{ block: 'center', inline: 'center' }});
const r = el.getBoundingClientRect();
return {{ x: r.x + r.width / 2, y: r.y + r.height / 2, w: r.width, h: r.height, tag: el.tagName, id: el.id || null }};
}})()"#, sel = selector.map(js_str).unwrap_or("null".into()), txt = text.map(js_str).unwrap_or("null".into()));
match cdp.evaluate(sid, &expr, T) {
Ok(v) if v.get("error").is_some() => Err(err("bad_selector", v["error"].as_str().unwrap_or("bad selector"), None)),
Ok(v) if v.is_null() => Ok(None),
Ok(v) => Ok(Some(v)),
Err(e) => Err(err("eval_failed", e.to_string(), None)),
}
}
fn mouse(cdp: &Cdp, sid: &str, kind: &str, x: f64, y: f64, button: &str, click_count: u32, modifiers: u32) -> Result<(), Value> {
cdp.send(Some(sid), "Input.dispatchMouseEvent", json!({ "type": kind, "x": x, "y": y, "button": button, "clickCount": click_count, "modifiers": modifiers }), T).map(|_| ()).map_err(|e| err("input_failed", e.to_string(), None))
}
/// A press whose release never landed leaves Chrome believing the button is STILL DOWN. Chrome then routes
/// every later mouse event to that widget as one ongoing drag: the user's touchpad scrolling and their clicks
/// on the tab strip stop working in that window, while dragging the scrollbar keeps working (because a drag is
/// exactly what Chrome thinks is happening). John hit this on 2026-09-10 after a release timed out mid-click:
/// "i still can't scroll with my touchpad on ANY of your pup windows... i also can't change tabs".
/// So: a click ALWAYS releases (retried once), and any press left outstanding by an earlier verb is released
/// before the next mouse event on that tab. Keyed by CDP session, cleared on a clean release.
fn press_down(ctx: &Ctx, sid: &str, x: f64, y: f64, button: &str) { ctx.pending_press.lock().unwrap().insert(sid.to_string(), (x, y, button.to_string())); }
fn press_up(ctx: &Ctx, sid: &str) { ctx.pending_press.lock().unwrap().remove(sid); }
/// Release a button an earlier verb left down on this tab, before doing anything else with the mouse.
pub fn clear_stuck_press(ctx: &Ctx, cdp: &Cdp, sid: &str) {
let held = ctx.pending_press.lock().unwrap().get(sid).cloned();
let Some((x, y, button)) = held else { return };
if mouse(cdp, sid, "mouseReleased", x, y, &button, 1, 0).is_ok() {
press_up(ctx, sid);
pup_core::log(format!("[input] released a {button} button an earlier click left down (Chrome was treating every mouse event as one drag)"));
}
}
/// press + release as one unit: the release runs even when it has to be retried, and the press is recorded
/// until a release lands, so the next verb on this tab can finish the job.
fn click_once(ctx: &Ctx, cdp: &Cdp, sid: &str, x: f64, y: f64, button: &str, clicks: u32, bits: u32) -> Result<(), Value> {
clear_stuck_press(ctx, cdp, sid);
mouse(cdp, sid, "mousePressed", x, y, button, clicks, bits)?;
press_down(ctx, sid, x, y, button);
let first = mouse(cdp, sid, "mouseReleased", x, y, button, clicks, bits);
if first.is_ok() { press_up(ctx, sid); return Ok(()); }
pup_core::log(format!("[input] mouseReleased failed after a press; retrying so the button is not left down: {}", first.as_ref().err().and_then(|e| e["error"].as_str()).unwrap_or("?")));
match mouse(cdp, sid, "mouseReleased", x, y, button, clicks, bits) {
Ok(()) => { press_up(ctx, sid); Ok(()) }
Err(e) => { pup_core::log("[input] the retry failed too: the press stays recorded and the next mouse verb on this tab releases it".to_string()); Err(e) }
}
}
fn modifier_bits(mods: &[String]) -> Result<u32, Value> {
let mut bits = 0u32;
for m in mods {
bits |= match m.to_ascii_lowercase().as_str() { "control" | "ctrl" => 2, "shift" => 8, "alt" | "option" => 1, "meta" | "cmd" | "command" => 4, other => return Err(err("bad_modifier", format!("Unknown modifier: {other}"), Some("modifiers must be any of [\"Control\",\"Shift\",\"Alt\",\"Meta\"] (aliases ctrl/cmd/command/option accepted)."))) };
}
Ok(bits)
}
fn key_event(cdp: &Cdp, sid: &str, kind: &str, key: &str, code: &str, vk: u32, text: Option<&str>, modifiers: u32) -> Result<(), Value> {
let mut p = json!({ "type": kind, "key": key, "code": code, "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk, "modifiers": modifiers });
if let Some(t) = text { if kind == "keyDown" { p["text"] = json!(t); p["unmodifiedText"] = json!(t); } }
cdp.send(Some(sid), "Input.dispatchKeyEvent", p, T).map(|_| ()).map_err(|e| err("input_failed", e.to_string(), None))
}
/// One key press (down + up) by name or single character, with the CDP modifier bits held.
pub fn press_pub(cdp: &Cdp, sid: &str, name: &str, modifiers: u32) -> Result<(), Value> { press(cdp, sid, name, modifiers) }
fn press(cdp: &Cdp, sid: &str, name: &str, modifiers: u32) -> Result<(), Value> {
let (key, code, vk, text): (String, String, u32, Option<String>) = match keys::lookup(name) {
Some(d) => (d.key.into(), d.code.into(), d.key_code, d.text.map(|t| t.to_string()).or_else(|| if d.key.chars().count() == 1 { Some(d.key.to_string()) } else { None })), // puppeteer: a 1-char key IS its text
None if name.chars().count() == 1 => (name.into(), String::new(), 0, Some(name.into())),
None => return Err(err("bad_key", format!("Unknown key \"{name}\""), Some("Use a DOM key name (Enter, Escape, Tab, ArrowDown, F5, a, A, 1 ...)."))),
};
key_event(cdp, sid, if text.is_some() { "keyDown" } else { "rawKeyDown" }, &key, &code, vk, text.as_deref(), modifiers)?;
key_event(cdp, sid, "keyUp", &key, &code, vk, None, modifiers)
}
fn modifier_keys(bits: u32) -> Vec<&'static str> { let mut v = Vec::new(); if bits & 2 != 0 { v.push("Control"); } if bits & 8 != 0 { v.push("Shift"); } if bits & 1 != 0 { v.push("Alt"); } if bits & 4 != 0 { v.push("Meta"); } v }
pub fn click(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let sel = args.get("selector").and_then(|x| x.as_str()); let txt = args.get("text").and_then(|x| x.as_str());
let clicks = args.get("clickCount").and_then(|c| c.as_u64()).unwrap_or(1) as u32;
let button = args.get("button").and_then(|x| x.as_str()).unwrap_or("left");
if sel.is_some() && args.get("domClick").and_then(|d| d.as_bool()) == Some(true) {
let expr = format!("(() => {{ const el = document.querySelector({}); if (!el) return {{ found: false }}; el.scrollIntoView({{ block: 'center', inline: 'center' }}); el.click(); return {{ found: true, tag: el.tagName }}; }})()", js_str(sel.unwrap()));
return match b.cdp.evaluate(&t.cdp_session, &expr, T) {
Ok(v) if v["found"] == json!(true) => { touch(ctx, &s.id, None); out(&s, &t, json!({ "clicked": sel, "method": "domClick", "_hint": "Synthetic element.click() dispatched (NOT trusted). Screenshot to confirm the effect." })) }
Ok(_) => err("selector_not_found", format!("pup_click(domClick): selector \"{}\" not found.", sel.unwrap()), Some("pup_eval document.querySelectorAll(...) to check the selector; the element may be inside an iframe or shadow root.")),
Err(e) => err("eval_failed", e.to_string(), None),
};
}
let (x, y, how) = if let (Some(x), Some(y)) = (args.get("x").and_then(|v| v.as_f64()), args.get("y").and_then(|v| v.as_f64())) { (x, y, json!("coordinate")) }
else if sel.is_some() || txt.is_some() {
match locate(&b.cdp, &t.cdp_session, sel, txt) { Ok(Some(v)) => (v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0), json!("coordinate")), Ok(None) => return err("selector_not_found", format!("pup_click: {} not found", sel.map(|s| format!("selector \"{s}\"")).unwrap_or_else(|| format!("text \"{}\"", txt.unwrap_or("")))), Some("Check the selector with pup_eval; if the control ignores trusted clicks pass domClick:true.")), Err(e) => return e }
} else { return err("click_target_required", "pup_click needs {selector} or {text} or {x,y}", None) };
let mods = args.get("modifiers").and_then(|m| m.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect::<Vec<_>>()).unwrap_or_default();
let bits = match modifier_bits(&mods) { Ok(b) => b, Err(e) => return e };
if let Err(e) = mouse(&b.cdp, &t.cdp_session, "mouseMoved", x, y, "none", 0, bits) { return e; }
if let Err(e) = click_once(ctx, &b.cdp, &t.cdp_session, x, y, button, clicks, bits) { return e; }
touch(ctx, &s.id, None);
// 2.0.735 (pup-bridge#136): a popup this click opened (an OAuth or "Sign in with Google" window) is adopted in the
// background within ~2 s as "<window>-2", so the AI drives it with pup verbs and never reaches for the real mouse.
if crate::transport::is_ext(&s) { if let Some(a) = ctx.arc() { let sid = s.id.clone(); std::thread::spawn(move || { let ctx2: &Ctx = &a; let _ = crate::extdrive::adopt_popups_after_click(ctx2, &sid); }); } }
out(&s, &t, json!({ "clicked": sel.or(txt).map(|v| v.to_string()).unwrap_or_else(|| format!("{x},{y}")), "method": how, "x": x, "y": y, "_popupHint": format!("If this click opened a popup (a sign-in or OAuth window), it becomes the pup window \"{}-2\" within about 2 s: check pup_my_windows {{}} and drive it with pup verbs, never the real mouse.", s.id), "_hint": "Click dispatched (isTrusted) at the element's center. Screenshot to confirm the effect; SPA routing may need pup_wait." }))
}
pub fn hover(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let sel = args.get("selector").and_then(|x| x.as_str()); let txt = args.get("text").and_then(|x| x.as_str());
let (x, y) = if let (Some(x), Some(y)) = (args.get("x").and_then(|v| v.as_f64()), args.get("y").and_then(|v| v.as_f64())) { (x, y) }
else { match locate(&b.cdp, &t.cdp_session, sel, txt) { Ok(Some(v)) => (v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0)), Ok(None) => return err("selector_not_found", "pup_hover: target not found", None), Err(e) => return e } };
let steps = args.get("steps").and_then(|v| v.as_u64()).unwrap_or(8).max(1) as usize;
for i in 1..=steps { let f = i as f64 / steps as f64; if let Err(e) = mouse(&b.cdp, &t.cdp_session, "mouseMoved", x * f, y * f, "none", 0, 0) { return e; } std::thread::sleep(Duration::from_millis(12)); }
out(&s, &t, json!({ "hovered": sel.or(txt).map(|v| v.to_string()).unwrap_or_else(|| format!("{x},{y}")), "x": x, "y": y, "_hint": "Pointer parked over the target; tooltips/hover menus should be visible in the next screenshot." }))
}
pub fn type_text(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let Some(text) = args.get("text").and_then(|x| x.as_str()) else { return err("text_required", "pup_type needs {text}", None) };
let sel = args.get("selector").and_then(|x| x.as_str());
if let Some(sel) = sel {
let expr = format!("(() => {{ const el = document.querySelector({}); if (!el) return false; el.scrollIntoView({{ block: 'center' }}); el.focus(); return true; }})()", js_str(sel));
match b.cdp.evaluate(&t.cdp_session, &expr, T) { Ok(v) if v == json!(true) => {}, Ok(_) => return err("selector_not_found", format!("pup_type: selector \"{sel}\" not found"), None), Err(e) => return err("eval_failed", e.to_string(), None) }
}
if args.get("clear").and_then(|c| c.as_bool()) == Some(true) {
if let Err(e) = press(&b.cdp, &t.cdp_session, "a", 2) { return e; }
if let Err(e) = press(&b.cdp, &t.cdp_session, "Delete", 0) { return e; }
}
// 2.0.579: on the extension lane every DevTools call is a poll-channel round trip, and per-key typing is
// two of them per character (a 20-char password overran the 30 s cap, measured). Insert the whole string
// in ONE trusted Input.insertText, as nbe itself types. The CDP lane keeps per-key realism.
if b.cdp.is_via() {
if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Input.insertText", json!({ "text": text }), T) { return err("type_failed", e.to_string(), None); }
} else { for ch in text.chars() {
let cs = ch.to_string();
let r = match keys::lookup(&cs) { Some(d) => { let shift = d.shift_key.is_none() && ch.is_uppercase(); press(&b.cdp, &t.cdp_session, &cs, if shift { 8 } else { 0 }) } None => b.cdp.send(Some(&t.cdp_session), "Input.insertText", json!({ "text": cs }), T).map(|_| ()).map_err(|e| err("input_failed", e.to_string(), None)) };
if let Err(e) = r { return e; }
} }
let read = b.cdp.evaluate(&t.cdp_session, "(() => { const a = document.activeElement; if (!a) return null; const v = a.value !== undefined ? a.value : (a.isContentEditable ? a.innerText : null); return v === null ? null : v.length; })()", T).ok().and_then(|v| v.as_u64());
let landed = read.map(|n| n >= text.chars().count().min(1) as u64);
touch(ctx, &s.id, None);
let mut o = json!({ "typed": format!("{} chars", text.chars().count()), "into": sel.unwrap_or("active element"), "valueLanded": landed, "fieldValueLength": read });
o["_hint"] = json!(if landed == Some(false) { "KEYSTROKES DISPATCHED BUT THE FIELD IS STILL EMPTY: this control ignores synthetic keystrokes while the window is in the background. Use pup_fill {selector,text} (native setter + input/change events) or foreground the window first." } else { "Typed via trusted keystrokes. valueLanded confirms the field took them (null when the focused element has no value)." });
out(&s, &t, o)
}
pub fn press_key(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let Some(key) = args.get("key").and_then(|x| x.as_str()) else { return err("key_required", "pup_press_key needs {key}", None) };
let mods = args.get("modifiers").and_then(|m| m.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect::<Vec<_>>()).unwrap_or_default();
let bits = match modifier_bits(&mods) { Ok(b) => b, Err(e) => return e };
for m in modifier_keys(bits) { if let Some(d) = keys::lookup(m) { if let Err(e) = key_event(&b.cdp, &t.cdp_session, "rawKeyDown", d.key, d.code, d.key_code, None, bits) { return e; } } }
let r = press(&b.cdp, &t.cdp_session, key, bits);
for m in modifier_keys(bits).into_iter().rev() { if let Some(d) = keys::lookup(m) { let _ = key_event(&b.cdp, &t.cdp_session, "keyUp", d.key, d.code, d.key_code, None, 0); } }
if let Err(e) = r { return e; }
touch(ctx, &s.id, None);
out(&s, &t, json!({ "pressed": key, "modifiers": mods, "_hint": "Key dispatched (trusted). Screenshot to confirm." }))
}
pub fn scroll(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let expr = format!(r#"(() => {{
const dy = {dy}, dx = {dx}, toSel = {ts}, toText = {tt}, block = {bl};
const scroller = () => {{ const cands = [document.scrollingElement || document.documentElement, ...document.querySelectorAll('*')]; let best = document.scrollingElement || document.documentElement, bestArea = 0;
for (const el of cands) {{ const cs = getComputedStyle(el); if (!/(auto|scroll)/.test(cs.overflowY) && el !== best) continue; if (el.scrollHeight > el.clientHeight + 4) {{ const a = el.clientWidth * el.clientHeight; if (a > bestArea) {{ bestArea = a; best = el; }} }} }}
return best; }};
let target = null;
if (toSel) {{ try {{ target = document.querySelector(toSel); }} catch (e) {{}} }}
if (!target && toText) {{ const t = toText.toLowerCase(); for (const c of document.querySelectorAll('*')) {{ const own = (c.innerText || '').trim().toLowerCase(); if (own && own.indexOf(t) !== -1 && c.children.length < 6) {{ target = c; break; }} }} }}
if (toSel || toText) {{ if (!target) return {{ found: false }}; target.scrollIntoView({{ block: block || 'center', behavior: 'instant' }}); const r = target.getBoundingClientRect(); return {{ found: true, scrolledTo: toSel || toText, top: r.top }}; }}
const el = scroller(); const before = el.scrollTop; el.scrollBy({{ top: dy, left: dx, behavior: 'instant' }});
return {{ found: true, scrollElement: el === document.scrollingElement || el === document.documentElement ? 'html' : (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '')), scrollTop: el.scrollTop, scrollLeft: el.scrollLeft, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight, atBottom: el.scrollTop + el.clientHeight >= el.scrollHeight - 2, moved: el.scrollTop - before }};
}})()"#, dy = args.get("dy").and_then(|v| v.as_f64()).unwrap_or(0.0), dx = args.get("dx").and_then(|v| v.as_f64()).unwrap_or(0.0), ts = args.get("toSelector").and_then(|v| v.as_str()).map(js_str).unwrap_or("null".into()), tt = args.get("toText").and_then(|v| v.as_str()).map(js_str).unwrap_or("null".into()), bl = args.get("block").and_then(|v| v.as_str()).map(js_str).unwrap_or("null".into()));
match b.cdp.evaluate(&t.cdp_session, &expr, T) {
Ok(v) if v["found"] == json!(false) => err("scroll_target_not_found", "pup_scroll: the selector/text was not found", None),
Ok(v) => { touch(ctx, &s.id, None); out(&s, &t, pup_core::merge(v, json!({ "_hint": "Scrolled the ACTUAL scrolling element (nested-scroll SPAs included). Screenshot to see the new viewport." }))) }
Err(e) => err("eval_failed", e.to_string(), None),
}
}
/// 2.0.592 (pup-bridge#116): choose a <select> option by value or by visible label, in the background, through
/// DevTools evaluate (so page CSP and Trusted Types cannot refuse it); fires input+change for framework selects.
pub fn select(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let Some(sel) = args.get("selector").and_then(|x| x.as_str()) else { return err("args_required", "pup_select needs {selector} plus {value} or {label}", Some("e.g. {\"selector\":\"select[name=tz]\",\"label\":\"Central Time\"}")) };
let (value, label) = (args.get("value").and_then(|x| x.as_str()), args.get("label").and_then(|x| x.as_str()));
if value.is_none() && label.is_none() { return err("args_required", "pup_select needs {value} or {label}", None); }
let expr = format!(r#"(() => {{ const el = document.querySelector({sel}); if (!el) return {{ found: false }};
if (el.tagName !== 'SELECT') return {{ found: true, notSelect: el.tagName }};
const want = {val}, wantLabel = {lab}; let idx = -1;
for (let i = 0; i < el.options.length; i++) {{ const o = el.options[i];
if ((want !== null && o.value === want) || (wantLabel !== null && o.text.trim().toLowerCase() === wantLabel.toLowerCase())) {{ idx = i; break; }} }}
if (idx < 0 && wantLabel !== null) {{ for (let i = 0; i < el.options.length; i++) {{ if (el.options[i].text.trim().toLowerCase().includes(wantLabel.toLowerCase())) {{ idx = i; break; }} }} }}
if (idx < 0) return {{ found: true, optionFound: false, options: [...el.options].slice(0, 30).map(o => ({{ value: o.value, label: o.text.trim() }})) }};
el.focus(); const d = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value'); if (d && d.set) d.set.call(el, el.options[idx].value); else el.value = el.options[idx].value;
el.selectedIndex = idx; el.dispatchEvent(new Event('input', {{ bubbles: true }})); el.dispatchEvent(new Event('change', {{ bubbles: true }}));
return {{ found: true, optionFound: true, value: el.value, label: el.options[idx].text.trim(), index: idx }}; }})()"#,
sel = js_str(sel), val = value.map(js_str).unwrap_or("null".into()), lab = label.map(js_str).unwrap_or("null".into()));
match b.cdp.evaluate(&t.cdp_session, &expr, T) {
Ok(v) if v["found"] == json!(false) => err("selector_not_found", format!("pup_select: selector \"{sel}\" not found"), None),
Ok(v) if v["notSelect"].is_string() => err("not_a_select", format!("pup_select: \"{sel}\" is a {} not a <select>", v["notSelect"]), Some("For custom dropdowns (Material, React Select) click the control with pup_click, then pup_click the option by text.")),
Ok(v) if v["optionFound"] == json!(false) => err("option_not_found", format!("pup_select: no option matched {}", value.or(label).unwrap_or("")), Some(&format!("Options: {}", v["options"]))),
Ok(v) => { touch(ctx, &s.id, None); out(&s, &t, json!({ "selected": v["value"], "label": v["label"], "index": v["index"], "selector": sel })) }
Err(e) => err("eval_failed", e.to_string(), None),
}
}
pub fn fill(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let (Some(sel), Some(text)) = (args.get("selector").and_then(|x| x.as_str()), args.get("text").and_then(|x| x.as_str())) else { return err("args_required", "pup_fill needs {selector, text}", None) };
let expr = format!(r#"(() => {{ const el = document.querySelector({sel}); if (!el) return {{ found: false }};
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : (el.tagName === 'SELECT' ? HTMLSelectElement.prototype : HTMLInputElement.prototype);
const d = Object.getOwnPropertyDescriptor(proto, 'value'); el.focus();
if (d && d.set) d.set.call(el, {val}); else if (el.isContentEditable) el.innerText = {val}; else el.value = {val};
el.dispatchEvent(new Event('input', {{ bubbles: true }})); el.dispatchEvent(new Event('change', {{ bubbles: true }}));
return {{ found: true, value: el.value !== undefined ? String(el.value).length : null, tag: el.tagName }}; }})()"#, sel = js_str(sel), val = js_str(text));
match b.cdp.evaluate(&t.cdp_session, &expr, T) {
Ok(v) if v["found"] == json!(false) => err("selector_not_found", format!("pup_fill: selector \"{sel}\" not found"), None),
Ok(v) => { touch(ctx, &s.id, None); out(&s, &t, json!({ "filled": sel, "valueLength": v["value"], "valueLanded": v["value"].as_u64().map(|n| n > 0), "fieldValueLength": v["value"], "tag": v["tag"], "_hint": "Value set through the native setter with input+change events (React/Vue/Angular see it). No keystrokes, no foreground needed." })) }
Err(e) => err("eval_failed", e.to_string(), None),
}
}
pub fn wait(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let t0 = std::time::Instant::now();
if let Some(sel) = args.get("selector").and_then(|x| x.as_str()) {
let max = Duration::from_millis(args.get("ms").and_then(|m| m.as_u64()).unwrap_or(30000).min(55000));
let expr = format!("!!document.querySelector({})", js_str(sel));
loop {
if b.cdp.evaluate(&t.cdp_session, &expr, T).ok() == Some(json!(true)) { return ok(json!({ "waited": t0.elapsed().as_millis() as u64, "found": sel, "sessionId": s.id, "window": s.id })); }
if t0.elapsed() > max { return err("wait_timeout", format!("selector \"{sel}\" did not appear within {}ms", max.as_millis()), Some("Screenshot to see what the page shows instead; the selector may be wrong or inside an iframe.")); }
std::thread::sleep(Duration::from_millis(250));
}
}
let ms = args.get("ms").and_then(|m| m.as_u64()).unwrap_or(1000).min(55000);
std::thread::sleep(Duration::from_millis(ms));
ok(json!({ "waited": ms, "sessionId": s.id, "window": s.id }))
}
fn nav_wait(cdp: &Cdp, sid: &str, f: impl FnOnce() -> Result<Value, pup_cdp::CdpError>, wait: Duration) -> Result<bool, Value> {
let rx = cdp.subscribe(Some(sid), Some("Page.loadEventFired"));
f().map_err(|e| err("navigate_failed", e.to_string(), None))?;
Ok(Cdp::wait_event(&rx, wait).is_some())
}
pub fn reload(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let loaded = match nav_wait(&b.cdp, &t.cdp_session, || b.cdp.send(Some(&t.cdp_session), "Page.reload", json!({ "ignoreCache": args.get("ignoreCache").and_then(|x| x.as_bool()).unwrap_or(false) }), T), Duration::from_secs(25)) { Ok(l) => l, Err(e) => return e };
let _ = crate::park::bottom_unless_foreground(ctx, &s.id, true);
touch(ctx, &s.id, None);
ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "loaded": loaded, "renderCheck": if loaded { crate::render_check(&b.cdp, &t.cdp_session, None) } else { Value::Null }, "_verifyRender": crate::VERIFY_RENDER, "_next": ["pup_screenshot"] }))
}
pub fn history(ctx: &Ctx, caller: &Caller, args: &Value, back: bool) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let h = match b.cdp.send(Some(&t.cdp_session), "Page.getNavigationHistory", json!({}), T) { Ok(h) => h, Err(e) => return err("navigate_failed", e.to_string(), None) };
let cur = h["currentIndex"].as_i64().unwrap_or(0); let entries = h["entries"].as_array().cloned().unwrap_or_default();
let idx = if back { cur - 1 } else { cur + 1 };
if idx < 0 || idx as usize >= entries.len() { return out(&s, &t, json!({ "moved": false, "direction": if back { "back" } else { "forward" }, "_hint": if back { "No history entry to go back to." } else { "No history entry to go forward to." } })); }
let id = entries[idx as usize]["id"].clone(); let url = entries[idx as usize]["url"].clone();
let loaded = match nav_wait(&b.cdp, &t.cdp_session, || b.cdp.send(Some(&t.cdp_session), "Page.navigateToHistoryEntry", json!({ "entryId": id }), T), Duration::from_secs(20)) { Ok(l) => l, Err(e) => return e };
touch(ctx, &s.id, None);
out(&s, &t, json!({ "moved": true, "direction": if back { "back" } else { "forward" }, "url": url, "loaded": loaded, "_hint": format!("Now at {}.", url.as_str().unwrap_or("?")) }))
}
pub fn set_viewport(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let dims = |cdp: &Cdp, sid: &str| -> Value { cdp.evaluate(sid, "({ innerWidth: innerWidth, innerHeight: innerHeight, outerWidth: outerWidth, outerHeight: outerHeight, devicePixelRatio: devicePixelRatio })", T).unwrap_or(json!({})) };
let outer_before = dims(&b.cdp, &t.cdp_session)["outerWidth"].clone();
if let Some(ow) = outer_before.as_u64() { ctx.sessions.with_mut(&s.id, |x| x.last_outer_width = Some(ow)); }
if args.get("reset").and_then(|r| r.as_bool()) == Some(true) {
if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Emulation.clearDeviceMetricsOverride", json!({}), T) { return err("viewport_failed", e.to_string(), None); }
ctx.sessions.with_mut(&s.id, |x| x.emulated = None);
return out(&s, &t, pup_core::merge(json!({ "emulated": false, "reset": true, "_hint": "Viewport override cleared; the page tracks the OS window size again." }), dims(&b.cdp, &t.cdp_session)));
}
let (Some(w), Some(h)) = (args.get("width").and_then(|v| v.as_u64()), args.get("height").and_then(|v| v.as_u64())) else { return err("args_required", "pup_set_viewport needs {width, height} (or reset:true)", None) };
let dsf = args.get("deviceScaleFactor").and_then(|v| v.as_f64()).unwrap_or(1.0);
if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Emulation.setDeviceMetricsOverride", json!({ "width": w, "height": h, "deviceScaleFactor": dsf, "mobile": false }), T) { return err("viewport_failed", e.to_string(), None); }
ctx.sessions.with_mut(&s.id, |x| x.emulated = Some(json!({ "width": w, "height": h, "deviceScaleFactor": dsf })));
out(&s, &t, pup_core::merge(json!({ "emulated": true, "realOuterWidth": outer_before, "applied": { "width": w, "height": h, "deviceScaleFactor": dsf }, "_hint": "Viewport pinned (CDP device metrics). It PERSISTS across navigations until pup_set_viewport {reset:true}." }), dims(&b.cdp, &t.cdp_session)))
}
pub fn errors(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, _b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let clear = args.get("clear").and_then(|c| c.as_bool()).unwrap_or(true);
let list = { let mut st = ctx.tabstate.lock().unwrap(); let e = st.entry(t.cdp_session.clone()).or_default(); let v = e.errors.clone(); if clear { e.errors.clear(); } v };
ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "count": list.len(), "errors": list, "cleared": clear }))
}
pub fn dialog(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let pending = ctx.tabstate.lock().unwrap().get(&t.cdp_session).and_then(|x| x.dialog.clone());
let Some(d) = pending else { return ok(json!({ "dialog": Value::Null, "sessionId": s.id, "window": s.id, "_hint": "No dialog is open on this window, so there was nothing to answer. If a verb is timing out, the cause is something else." })) };
let action = args.get("action").and_then(|a| a.as_str()).unwrap_or("");
if action != "accept" && action != "dismiss" {
return pup_core::merge(err("dialog_action_required", format!("a {} dialog is open; pass action:\"accept\" or action:\"dismiss\"", d["type"].as_str().unwrap_or("?")), Some("accept = press the confirming button (Reload / OK). dismiss = press Cancel. pup will not choose for you.")), json!({ "dialog": d }));
}
let mut p = json!({ "accept": action == "accept" });
if let Some(txt) = args.get("promptText").and_then(|x| x.as_str()) { p["promptText"] = json!(txt); }
if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Page.handleJavaScriptDialog", p, T) { return err("dialog_failed", e.to_string(), None); }
ctx.tabstate.lock().unwrap().entry(t.cdp_session.clone()).or_default().dialog = None;
ok(json!({ "handled": action, "dialog": d, "sessionId": s.id, "window": s.id }))
}
/// pup_devtools {method, params?, timeoutSec?}: one DevTools protocol command on the window's active tab (or tabId),
/// on either lane. On the extension lane it goes through pup's extension (chrome.debugger), so a pup window has the
/// whole protocol with no debug port (2.0.726, John: AIs gave up with "CDP is switched off on this box").
pub fn devtools(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let Some(method) = args.get("method").and_then(|m| m.as_str()).filter(|m| m.contains('.')) else {
return err("method_required", "pup_devtools needs {method:\"Domain.command\"}", Some("Example: pup_devtools {method:\"DOM.getDocument\", params:{depth:1}}. The protocol reference: https://chromedevtools.github.io/devtools-protocol/"));
};
// The shared browser and its other windows are not this window's to end.
const BLOCKED: [&str; 5] = ["Browser.close", "Browser.crash", "Browser.crashGpuProcess", "Target.closeTarget", "Target.disposeBrowserContext"];
if BLOCKED.contains(&method) { return err("devtools_method_blocked", format!("{method} would affect other pup windows in the shared browser"), Some("Close your own window with pup_close_window.")); }
let (s, t, _b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let lane = match crate::transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };
let params = args.get("params").cloned().filter(|p| p.is_object()).unwrap_or_else(|| json!({}));
let secs = args.get("timeoutSec").and_then(|v| v.as_u64()).unwrap_or(20).clamp(1, 40);
match crate::transport::cdp_raw(&lane, &t, method, params, secs) {
Ok(result) => ok(json!({ "success": true, "sessionId": s.id, "window": s.id, "tabId": t.tab_id, "method": method, "lane": if crate::transport::is_ext(&s) { "ext" } else { "cdp" }, "result": result,
"_hint": "Sent through pup's extension; no debug port is open. Events are not streamed back: poll with commands (for example Runtime.evaluate) instead of waiting on events." })),
Err(e) => pup_core::merge(e, json!({ "method": method, "_hint": "The browser answered this DevTools command with an error; read it and fix the params. Some domains need an enable first (DOM.enable, Page.enable)." })),
}
}
pub fn input_dispatch(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let kind = args.get("type").and_then(|x| x.as_str()).unwrap_or("");
let (mut x, mut y) = (args.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0), args.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0));
// 2.0.649 (pup-bridge#107): `selector` was advertised and silently ignored (a documented selector-only click
// landed at 0,0 with ok:true). Resolve it here, on either lane: exactly one match, scrolled into view, the
// click lands on its centre, and the response says what was matched.
let mut matched: Value = Value::Null;
if let Some(sel) = args.get("selector").and_then(|v| v.as_str()).filter(|v| !v.is_empty()) {
if args.get("x").is_some() || args.get("y").is_some() { return err("args_conflict", "pass selector OR x/y, not both", None); }
let lane = match crate::transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };
let expr = format!("(()=>{{const els=[...document.querySelectorAll({sel})];if(els.length!==1)return JSON.stringify({{count:els.length}});const el=els[0];el.scrollIntoView({{block:'center',inline:'center'}});const r=el.getBoundingClientRect();return JSON.stringify({{count:1,x:r.left+r.width/2,y:r.top+r.height/2,rect:{{left:r.left,top:r.top,width:r.width,height:r.height}},tag:el.tagName,text:(el.innerText||el.value||'').trim().slice(0,80)}})}})()", sel = js_str(sel));
let v = match crate::transport::evaluate(&lane, &t, &expr) { Ok(v) => v, Err(e) => return e };
let j: Value = v.as_str().and_then(|x| serde_json::from_str(x).ok()).unwrap_or(v.clone());
match j["count"].as_u64() {
Some(1) => { x = j["x"].as_f64().unwrap_or(0.0); y = j["y"].as_f64().unwrap_or(0.0); matched = json!({ "selector": sel, "tag": j["tag"], "text": j["text"], "clickedRect": j["rect"] }); std::thread::sleep(Duration::from_millis(120)); }
Some(0) | None => return err("selector_not_found", format!("no element matches {sel}"), Some("Check the selector with pup_eval (document.querySelectorAll(...).length) or pass x/y from a bounding rect.")),
Some(n) => return err("selector_ambiguous", format!("{n} elements match {sel}; a click needs exactly one"), Some("Narrow the selector (an id, an href suffix, :nth-of-type) or pass x/y.")),
}
}
// 2.0.648 (pup-bridge#120): TRUSTED input on the extension lane. The worker's "cdp" action drives the same
// Input.dispatchMouseEvent / dispatchKeyEvent through nbe's DevTools driver (chrome.debugger), so the event
// is as trusted as the CDP lane's: it lands in cross-origin frames (the reCAPTCHA iframe) that a page-side
// click() can never reach. Before this the lane answered lane_needs_cdp and the user's rule (never CDP for a
// captcha, never hand it to the human) left no verb at all.
if crate::transport::is_ext(&s) {
let lane = match crate::transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };
let send = |method: &str, p: Value| -> Result<(), Value> { crate::transport::cdp_raw(&lane, &t, method, p, 15).map(|_| ()) };
let btn = args.get("button").and_then(|v| v.as_str()).unwrap_or("left").to_string();
let sid = t.cdp_session.clone();
// 2.0.664: the same press bookkeeping as the CDP branch (the jump-list gate caught a press left down on this
// lane that the next click did not release): release an orphaned button first, record a press, clear it on release.
let clear_stuck = |ctx: &Ctx| {
let held = ctx.pending_press.lock().unwrap().get(&sid).cloned();
if let Some((hx, hy, hb)) = held {
if send("Input.dispatchMouseEvent", json!({ "type": "mouseReleased", "x": hx, "y": hy, "button": hb, "clickCount": 1 })).is_ok() { press_up(ctx, &sid); pup_core::log(format!("[input] released a {hb} button an earlier click left down (extension lane)")); }
}
};
let r = match kind {
"click" => { clear_stuck(ctx); send("Input.dispatchMouseEvent", json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none" }))
.and_then(|_| { let r = send("Input.dispatchMouseEvent", json!({ "type": "mousePressed", "x": x, "y": y, "button": btn, "clickCount": 1 })); if r.is_ok() { press_down(ctx, &sid, x, y, &btn); } r })
.and_then(|_| { std::thread::sleep(Duration::from_millis(40 + (x as u64 % 50))); let r = send("Input.dispatchMouseEvent", json!({ "type": "mouseReleased", "x": x, "y": y, "button": btn, "clickCount": 1 })); if r.is_ok() { press_up(ctx, &sid); } r }) }
"mouseMoved" | "mousePressed" | "mouseReleased" | "mouseWheel" => {
if kind != "mousePressed" { clear_stuck(ctx); }
let mut p = json!({ "type": kind, "x": x, "y": y, "button": if kind == "mouseMoved" { "none".to_string() } else { btn.clone() }, "clickCount": 1 });
if kind == "mouseWheel" { p["deltaX"] = json!(args.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0)); p["deltaY"] = json!(args.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0)); }
let r = send("Input.dispatchMouseEvent", p);
if r.is_ok() { if kind == "mousePressed" { press_down(ctx, &sid, x, y, &btn); } else if kind == "mouseReleased" { press_up(ctx, &sid); } }
r
}
"keyDown" | "keyUp" | "rawKeyDown" | "char" => send("Input.dispatchKeyEvent", json!({ "type": kind, "key": args.get("key"), "text": args.get("text"), "code": args.get("code") })),
_ => Err(err("bad_type", "type must be click | mouseMoved | mousePressed | mouseReleased | mouseWheel | keyDown | keyUp | rawKeyDown | char", None)),
};
if let Err(e) = r { return e; }
return out(&s, &t, json!({ "dispatched": kind, "x": x, "y": y, "trusted": true, "lane": "ext", "matched": matched }));
}
let r = match kind {
"click" => mouse(&b.cdp, &t.cdp_session, "mouseMoved", x, y, "none", 0, 0).and_then(|_| click_once(ctx, &b.cdp, &t.cdp_session, x, y, "left", 1, 0)),
"mouseMoved" | "mousePressed" | "mouseReleased" | "mouseWheel" => {
let btn = args.get("button").and_then(|v| v.as_str()).unwrap_or("left").to_string();
if kind != "mousePressed" { clear_stuck_press(ctx, &b.cdp, &t.cdp_session); } // never wheel or move through someone else's held button
if kind == "mousePressed" { press_down(ctx, &t.cdp_session, x, y, &btn); } else if kind == "mouseReleased" { press_up(ctx, &t.cdp_session); }
let mut p = json!({ "type": kind, "x": x, "y": y, "button": btn, "clickCount": 1 });
if kind == "mouseWheel" { p["deltaX"] = json!(args.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0)); p["deltaY"] = json!(args.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0)); }
b.cdp.send(Some(&t.cdp_session), "Input.dispatchMouseEvent", p, T).map(|_| ()).map_err(|e| err("input_failed", e.to_string(), None))
}
"keyDown" | "keyUp" | "rawKeyDown" | "char" => b.cdp.send(Some(&t.cdp_session), "Input.dispatchKeyEvent", json!({ "type": kind, "key": args.get("key"), "text": args.get("text"), "code": args.get("code") }), T).map(|_| ()).map_err(|e| err("input_failed", e.to_string(), None)),
_ => Err(err("bad_type", "type must be click | mouseMoved | mousePressed | mouseReleased | mouseWheel | keyDown | keyUp | rawKeyDown | char", None)),
};
if let Err(e) = r { return e; }
out(&s, &t, json!({ "dispatched": kind, "x": x, "y": y, "matched": matched }))
}
pub fn render_html(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let Some(html) = args.get("html").and_then(|x| x.as_str()) else { return err("html_required", "pup_render_html needs {html}", None) };
let frame = match b.cdp.send(Some(&t.cdp_session), "Page.getFrameTree", json!({}), T) { Ok(v) => v["frameTree"]["frame"]["id"].as_str().unwrap_or("").to_string(), Err(e) => return err("render_failed", e.to_string(), None) };
if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Page.setDocumentContent", json!({ "frameId": frame, "html": html }), T) { return err("render_failed", e.to_string(), None); }
let title = b.cdp.evaluate(&t.cdp_session, "document.title", T).ok().and_then(|v| v.as_str().map(|x| x.to_string())).unwrap_or_default();
touch(ctx, &s.id, args.get("updateNote").and_then(|n| n.as_str()));
out(&s, &t, json!({ "title": title, "bytes": html.len(), "_hint": if t.url.starts_with("http") { "Rendered your HTML in the tab (UTF-8 correct, Page.setDocumentContent). The document KEEPS the tab's origin and that origin's Content-Security-Policy: on a strict site (script-src 'self', e.g. wiki.adom.inc) your inline <script> and <style> are blocked. For a clean origin pup_navigate {url:\"about:blank\"} first. Screenshot to verify." } else { "Rendered your HTML in the tab (UTF-8 correct, Page.setDocumentContent). Screenshot to verify." } }))
}
pub fn fetch_url(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let Some(url) = args.get("url").and_then(|x| x.as_str()) else { return err("url_required", "pup_fetch_url requires {url}", None) };
let method = args.get("method").and_then(|x| x.as_str()).unwrap_or("GET");
// 2.0.648 (pup-bridge#104): two defects, both measured on ConfRoomROG against assets.nexperia.com.
// 1. credentials:'include' on a cross-origin fetch is refused by the browser when the server answers
// Access-Control-Allow-Origin: * ("Failed to fetch"), while the reporter's plain in-page fetch worked.
// Try with credentials first (a gated file needs the cookie), and on a TypeError retry without.
// 2. r.text() destroyed binary bodies (a PDF came back as mangled text). Read an ArrayBuffer, hand it
// back base64, and let pup write the bytes for saveTo and report the SHA-256 the caller can verify.
let expr = format!(r#"(async () => {{ const one = async (cred) => {{ const r = await fetch({u}, {{ method: {m}, credentials: cred, headers: {h} }}); const ct = r.headers.get('content-type') || ''; const buf = await r.arrayBuffer(); const bytes = new Uint8Array(buf); let bin = ''; for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return {{ ok: r.ok, status: r.status, contentType: ct, length: bytes.length, base64: btoa(bin), credentials: cred, finalUrl: r.url }}; }}; try {{ try {{ return await one('include'); }} catch (e1) {{ if (!(e1 instanceof TypeError)) throw e1; try {{ return await one('omit'); }} catch (e2) {{ return {{ error: e2.message, firstError: e1.message, stage: 'browser-fetch', pageOrigin: location.origin }}; }} }} }} catch (e) {{ return {{ error: e.message, stage: 'browser-fetch', pageOrigin: location.origin }}; }} }})()"#, u = js_str(url), m = js_str(method), h = args.get("headers").cloned().unwrap_or(json!({})));
let ext = crate::transport::is_ext(&s);
let tab_num = crate::transport::ext_id(&t).as_i64();
if ext { if let Some((host, false)) = tab_num.and_then(crate::vault::unanswered_challenge) { return crate::vault::no_credential_error(&host, false); } }
let res: Result<Value, String> = if ext {
match crate::transport::lane_for(ctx, &s) { Ok(lane) => crate::transport::evaluate(&lane, &t, &expr).map_err(|e| e["error"].as_str().unwrap_or("eval failed").to_string()), Err(e) => Err(e["error"].as_str().unwrap_or("no lane").to_string()) }
} else { b.cdp.evaluate(&t.cdp_session, &expr, Duration::from_secs(60)).map_err(|e| e.to_string()) };
if ext { if let Some((host, answered)) = tab_num.and_then(crate::vault::unanswered_challenge) { if !answered { return crate::vault::no_credential_error(&host, false); } } }
let v = match res {
Ok(v) => v,
Err(e) => return pup_core::merge(err("fetch_failed", e, None), json!({ "stage": "evaluate", "tabId": t.tab_id, "pageUrl": t.url })),
};
if let Some(e) = v.get("error").and_then(|e| e.as_str()) {
return pup_core::merge(err("fetch_failed", format!("pup_fetch_url failed in the page: {e}"), Some("The fetch ran inside the named tab, with credentials and then without. A TypeError from both means the page's origin may not read that URL (CORS) or the network refused; navigate the tab to the URL instead, or fetch from a tab on the file's own origin.")),
json!({ "stage": v["stage"].clone(), "pageOrigin": v["pageOrigin"].clone(), "firstError": v["firstError"].clone(), "tabId": t.tab_id, "pageUrl": t.url }));
}
let Some(status) = v.get("status").and_then(|x| x.as_u64()) else { return err("fetch_failed", format!("pup_fetch_url: unexpected evaluate result {}", v.to_string().chars().take(200).collect::<String>()), None) };
let bytes = v["base64"].as_str().map(b64_decode).unwrap_or_default();
let ct = v["contentType"].as_str().unwrap_or("").to_string();
let textual = ct.starts_with("text/") || ct.contains("json") || ct.contains("xml") || ct.contains("javascript");
let sha = sha256_hex(&bytes);
let mut out_v = json!({ "success": true, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "url": url, "finalUrl": v["finalUrl"], "httpStatus": status, "contentType": ct, "bytes": bytes.len(), "sha256": sha, "credentials": v["credentials"], "tabId": t.tab_id });
if let Some(path) = args.get("saveTo").and_then(|x| x.as_str()) {
// the caller's path is on the CONTAINER, not the desktop: hand the bytes back and let the CLI write them
out_v["saveTo"] = json!(path);
out_v["bodyBase64"] = v["base64"].clone();
out_v["_hint"] = json!("bodyBase64 holds the file; pup runs on the desktop and cannot write your container path. Decode it to saveTo on your side and compare sha256.");
} else if textual { out_v["text"] = json!(String::from_utf8_lossy(&bytes).chars().take(1_000_000).collect::<String>()); out_v["output"] = out_v["text"].clone(); }
else { out_v["bodyBase64"] = v["base64"].clone(); out_v["_hint"] = json!("Binary body: bodyBase64 holds it (sha256 above)."); }
ok(out_v)
}
fn b64_decode(s: &str) -> Vec<u8> {
let t = |c: u8| -> Option<u32> { match c { b'A'..=b'Z' => Some((c - b'A') as u32), b'a'..=b'z' => Some((c - b'a' + 26) as u32), b'0'..=b'9' => Some((c - b'0' + 52) as u32), b'+' => Some(62), b'/' => Some(63), _ => None } };
let mut out = Vec::with_capacity(s.len() * 3 / 4); let mut acc = 0u32; let mut n = 0;
for &c in s.as_bytes() { if c == b'=' { break; } let Some(v) = t(c) else { continue }; acc = (acc << 6) | v; n += 6; if n >= 8 { n -= 8; out.push(((acc >> n) & 0xff) as u8); } }
out
}
fn sha256_hex(data: &[u8]) -> String {
// minimal SHA-256 (no crate): a fetch verb must be able to say what it fetched
const K: [u32; 64] = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
let mut h: [u32; 8] = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
let mut msg = data.to_vec(); let bitlen = (data.len() as u64) * 8; msg.push(0x80); while msg.len() % 64 != 56 { msg.push(0); } msg.extend_from_slice(&bitlen.to_be_bytes());
for chunk in msg.chunks(64) {
let mut w = [0u32; 64];
for i in 0..16 { w[i] = u32::from_be_bytes([chunk[4*i], chunk[4*i+1], chunk[4*i+2], chunk[4*i+3]]); }
for i in 16..64 { let s0 = w[i-15].rotate_right(7) ^ w[i-15].rotate_right(18) ^ (w[i-15] >> 3); let s1 = w[i-2].rotate_right(17) ^ w[i-2].rotate_right(19) ^ (w[i-2] >> 10); w[i] = w[i-16].wrapping_add(s0).wrapping_add(w[i-7]).wrapping_add(s1); }
let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) = (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]);
for i in 0..64 { let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); let ch = (e & f) ^ (!e & g); let t1 = hh.wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]); let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); let maj = (a & b) ^ (a & c) ^ (b & c); let t2 = s0.wrapping_add(maj); hh = g; g = f; f = e; e = d.wrapping_add(t1); d = c; c = b; b = a; a = t1.wrapping_add(t2); }
h[0] = h[0].wrapping_add(a); h[1] = h[1].wrapping_add(b); h[2] = h[2].wrapping_add(c); h[3] = h[3].wrapping_add(d); h[4] = h[4].wrapping_add(e); h[5] = h[5].wrapping_add(f); h[6] = h[6].wrapping_add(g); h[7] = h[7].wrapping_add(hh);
}
h.iter().map(|x| format!("{x:08x}")).collect()
}
pub fn maximize(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let state = args.get("state").and_then(|x| x.as_str()).unwrap_or("maximized");
// 2.0.726: on the extension lane (every window by default) this answered window_failed, unseen for weeks because
// parity's window was refused before it reached here. The browser's own maximize RAISES the window over the user's
// work (pup-bridge-dev geometry rule), so "maximized" and "normal" fill the work area in place, as every pup window
// already is, without touching the z-order.
if let Ok(s0) = crate::resolve(ctx, caller, args) {
if crate::transport::is_ext(&s0) {
if state == "maximized" || state == "normal" {
crate::extdrive::fit_now();
return ok(json!({ "success": true, "sessionId": s0.id, "window": s0.id, "aiThread": s0.owner, "state": state, "fitted": true,
"_hint": "Sized to fill the screen's work area without raising it (a true maximize would pull the window over the user's work). The window stays behind their work." }));
}
return err("state_unsupported_ext", format!("state \"{state}\" is not done on pup windows"), Some("Minimizing or full-screening would change what the user sees. To move a window behind their work use pup_lower_os_window; to show it, pup_raise_os_window {foregroundReason}."));
}
}
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let (wid, _) = match b.cdp.window_for_target(&t.target_id) { Ok(w) => w, Err(e) => return err("window_failed", e.to_string(), None) };
if let Err(e) = b.cdp.set_window_bounds(wid, json!({ "windowState": "normal" })) { return err("window_failed", e.to_string(), None); }
if state != "normal" { if let Err(e) = b.cdp.set_window_bounds(wid, json!({ "windowState": state })) { return err("window_failed", e.to_string(), None); } }
if state != "minimized" { let _ = crate::park::bottom_unless_foreground(ctx, &s.id, true); }
let bounds = b.cdp.send(None, "Browser.getWindowBounds", json!({ "windowId": wid }), T).map(|v| v["bounds"].clone()).unwrap_or(Value::Null);
out(&s, &t, json!({ "state": state, "windowId": wid, "bounds": bounds, "_hint": "Window state changed via CDP without foregrounding it." }))
}