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." }))
}