master
John Lauer v2.0.730 (insiders): pup_eval and pup_login work on strict pages again (pup-bridge#130): American Express's login page… 705edb1 21d ago
//! ONE seam between a pup window and the thing that drives it. A window is a Session either way; only
//! the driving differs: CDP over a DevTools port, or the extension channel with no port at all (the lane
//! vendor sites cannot fingerprint). Every verb reaches the page through here, so identity, overlay,
//! progress, park, recovery and listing never need to know which transport a window uses.
//!
//! 2.0.525 (John, 2026-09-13): "i honestly still don't get why you made the non-cdp lane be like a whole new
//! set of code". It was outside the session model; this module is what puts it back inside.
use crate::{browser_for, BrowserHandle, Ctx};
use pup_core::err;
use pup_session::{Session, Tab};
use serde_json::{json, Value};
use std::sync::Arc;
use std::time::Duration;

pub enum Lane { Cdp(Arc<BrowserHandle>), Ext }

pub fn is_ext(s: &Session) -> bool { s.transport == "ext" }

pub fn lane_for(ctx: &Ctx, s: &Session) -> Result<Lane, Value> {
    if is_ext(s) {
        crate::extdrive::enter_profile(&s.profile);   // 2.0.659: this thread now talks to the session's own lane
        if crate::extdrive::alive(ctx) { Ok(Lane::Ext) } else { Err(err("browser_gone", "the extension-driven browser behind this window is not running", Some("pup_close_window then pup_open_window."))) }
    } else {
        browser_for(ctx, &s.profile).map(Lane::Cdp).ok_or_else(|| err("browser_gone", "the browser behind this window is not connected", Some("pup_close_window then pup_open_window; Phase 2 reconnects automatically.")))
    }
}

/// An ext tab's `target_id` is Chrome's numeric tab id; its `cdp_session` is a synthetic unique key so
/// per-tab state maps (tabstate, focus emulation) keep working without a CDP session.
pub fn ext_tab(s: &mut Session, chrome_tab_id: i64, url: &str, title: &str) -> Tab {
    Tab { tab_id: s.next_tab_id(), target_id: chrome_tab_id.to_string(), cdp_session: format!("ext:{chrome_tab_id}"), url: url.into(), title: title.into(), error_count: 0, fav: None }
}
pub(crate) fn ext_id(t: &Tab) -> Value { t.target_id.parse::<i64>().map(Value::from).unwrap_or(Value::Null) }
fn run(cmd: Value, secs: u64) -> Result<Value, Value> { crate::extdrive::run(cmd, Duration::from_secs(secs)) }

pub fn navigate(lane: &Lane, t: &Tab, url: &str) -> Result<(String, String, bool), Value> {
    match lane {
        Lane::Cdp(b) => match b.cdp.navigate(&t.cdp_session, url, Duration::from_secs(25)) {
            Ok((u, loaded)) => { let mut tt = t.clone(); crate::refresh_tab(&b.cdp, &mut tt); Ok((if tt.url.is_empty() { u } else { tt.url }, tt.title, loaded)) }
            Err(e) => Err(err("navigate_failed", e.to_string(), Some("Check the URL; a container-local localhost is not reachable from the desktop (use the proxy URL)."))),
        },
        Lane::Ext => {
            let r = run(json!({ "action": "navigate", "url": url, "tabId": ext_id(t) }), 60)?;
            // 2.0.619: say WHICH path loaded the page. chrome.tabs.update surfaces the window (measured), so
            // Page.navigate over DevTools is preferred and a fallback to the tab API is worth seeing in the log.
            if r["navVia"].as_str() == Some("tabs") {
                pup_core::log(format!("[nav] fell back to the tab API (the window may surface): {}", r["navErr"].as_str().unwrap_or("no reason given")));
            }
            Ok((r["url"].as_str().unwrap_or(url).to_string(), r["title"].as_str().unwrap_or("").to_string(), true))
        }
    }
}

/// 2.0.648: one raw DevTools command on either lane (the extension lane routes it through the worker's "cdp"
/// action, nbe's chrome.debugger driver). Used for trusted input on the extension lane (pup-bridge#120).
pub fn cdp_raw(lane: &Lane, t: &Tab, method: &str, params: Value, timeout_s: u64) -> Result<Value, Value> {
    match lane {
        Lane::Cdp(b) => b.cdp.send(Some(&t.cdp_session), method, params, std::time::Duration::from_secs(timeout_s)).map_err(|e| err("cdp_failed", e.to_string(), None)),
        Lane::Ext => run(json!({ "action": "cdp", "method": method, "params": params, "tabId": ext_id(t), "timeoutMs": timeout_s * 1000 }), timeout_s + 5).map(|r| r["result"].clone()),
    }
}

/// 2.0.655: wait until the tab's document is the one we navigated to and is past `loading` (up to `max`). A
/// script evaluated straight after a navigate ran in the OLD document and was wiped by the load (the owner
/// chip on the extension lane never appeared; the smoke caught it). Returns true when ready.
pub fn wait_ready(lane: &Lane, t: &Tab, want_url: &str, max: std::time::Duration) -> bool {
    // a blank or extension page has nothing to wait for and forbids evals ("Cannot access contents of url
    // chrome-extension://..."): 2.0.657 measured 10 s burned twice per about:blank navigate
    if want_url.is_empty() || want_url.starts_with("about:") || want_url.starts_with("chrome") { return true; }
    let t0 = std::time::Instant::now();
    let want_host = want_url.split("//").nth(1).and_then(|r| r.split('/').next()).unwrap_or("").to_string();
    while t0.elapsed() < max {
        let t1 = std::time::Instant::now();
        let r = evaluate(lane, t, "document.readyState + '|' + location.href");
        if r.is_err() || t0.elapsed() > std::time::Duration::from_secs(3) { pup_core::log(format!("[ready] tab {} want {} -> {} in {} ms", t.tab_id, want_url.chars().take(60).collect::<String>(), match &r { Ok(v) => v.as_str().unwrap_or("?").chars().take(90).collect::<String>(), Err(e) => format!("ERR {}", e["error"].as_str().unwrap_or("?").chars().take(90).collect::<String>()) }, t1.elapsed().as_millis())); }
        if let Ok(v) = r {
            let sv = v.as_str().unwrap_or("").to_string();
            let (state, href) = sv.split_once('|').unwrap_or(("", ""));
            let blank_wanted = want_url.starts_with("about:") || want_url.is_empty();
            let host_ok = blank_wanted || want_host.is_empty() || href.contains(&want_host);
            if state != "loading" && host_ok && (blank_wanted || href != "about:blank") { return true; }
        }
        std::thread::sleep(std::time::Duration::from_millis(200));
    }
    false
}

pub fn evaluate(lane: &Lane, t: &Tab, expr: &str) -> Result<Value, Value> {
    match lane {
        Lane::Cdp(b) => b.cdp.evaluate(&t.cdp_session, expr, Duration::from_secs(20)).map_err(|e| err("eval_failed", e.to_string(), None)),
        Lane::Ext => {
            let r = run(json!({ "action": "eval", "expr": expr, "tabId": ext_id(t) }), 25)?;   // 25 s, not 45: identity's badge eval runs on every open and was the step that blew ab's 60 s when a worker restarted
            let res = &r["result"];
            if let Some(m) = res.as_str().and_then(|x| x.strip_prefix("ERR:")) {
                // 2.0.578: a strict Content-Security-Policy (wiki.adom.inc, claude.ai, Google Docs) refuses the
                // injected eval; Runtime.evaluate through the DevTools driver ignores page CSP, as the CDP lane does.
                // 2.0.591 (pup-bridge#116): Trusted Types refuse with "violates this document's Trusted Type assignment
                // requirements", which the CSP wording never matched, so Google Calendar and Admin got no fallback.
                // 2.0.730 (pup-bridge#130): American Express's login page refuses it as "eval is disabled", which none of the
                // phrases above matched, so pup_eval and pup_login failed there on the extension lane.
                if m.contains("Content Security Policy") || m.contains("unsafe-eval") || m.contains("Trusted Type") || m.contains("Evaluating a string as JavaScript")
                    || m.contains("eval is disabled") || m.contains("EvalError") || m.contains("Refused to evaluate") {
                    return crate::extdrive::ext_handle().cdp.evaluate(&t.cdp_session, expr, Duration::from_secs(20)).map_err(|e| err("eval_failed", e.to_string(), None));
                }
                return Err(err("eval_failed", format!("pup_eval could not run your code: {m}"), None));
            }
            // Same shape as the CDP lane's Runtime.evaluate: strings stay strings, objects come back as values.
            if let Some(sv) = res["s"].as_str() { return Ok(Value::String(sv.to_string())); }
            if res["u"] == json!(true) { return Ok(Value::Null); }
            if let Some(jv) = res["j"].as_str() { return Ok(serde_json::from_str::<Value>(jv).unwrap_or_else(|_| Value::String(jv.to_string()))); }
            // an older worker (untyped): keep the previous behaviour
            let raw = res.as_str().unwrap_or("");
            Ok(serde_json::from_str::<Value>(raw).unwrap_or_else(|_| Value::String(raw.to_string())))
        }
    }
}

/// Run a capture with pup's parked browsers producing frames (Host::capture_frames_begin/end; a no-op on
/// Windows, an unhide-behind-the-user on macOS). Every Page.captureScreenshot / captureVisibleTab goes through
/// here so a window parked for a while still answers.
pub fn with_frames<T>(f: impl FnOnce() -> T) -> T {
    let host = crate::CAPTURE_HOST.get().cloned();
    let unhid = host.as_ref().map(|h| h.capture_frames_begin()).unwrap_or(false);
    let r = f();
    if unhid { if let Some(h) = host { h.capture_frames_end(); } }
    r
}

pub fn screenshot_png(lane: &Lane, t: &Tab, full: bool) -> Result<Vec<u8>, Value> {
    with_frames(|| match lane {
        Lane::Cdp(b) => b.cdp.screenshot_png(&t.cdp_session, full, Duration::from_secs(20)).map_err(|e| err("screenshot_timeout", format!("page.screenshot: {e}"), Some("The page did not answer the capture. Reload it, or pup_eval document.readyState."))),
        Lane::Ext => {
            let r = run(json!({ "action": "screenshot", "tabId": ext_id(t) }), 60)?;
            if let Some(v) = r["via"].as_str() { if v.starts_with("debugger") { pup_core::log(format!("[screenshot] tab {} captured via {v}", t.tab_id)); } }
            let b64 = r["dataUrl"].as_str().and_then(|d| d.split(',').nth(1)).unwrap_or("");
            pup_cdp::base64_decode(b64).map_err(|e| err("screenshot_decode_failed", e.to_string(), None))
        }
    })
}

/// Open a tab in the window. Ext: in the SAME Chrome window (windowId) the session was born with.
pub fn open_tab(lane: &Lane, s: &mut Session, url: &str) -> Result<Tab, Value> {
    match lane {
        Lane::Cdp(b) => {
            let target = b.cdp.create_target(url, false, None, None).map_err(|e| err("cdp_create_target_failed", e.to_string(), None))?;
            let tab_id = crate::attach_tab(s, &b.cdp, &target)?;
            Ok(s.tab_by_id(&tab_id).cloned().expect("attached tab"))
        }
        Lane::Ext => {
            // 20 s: a slow site (nordicsemi behind Cloudflare) must return BEFORE ab's ~60 s relay gives up, or the
            // tab opens in Chrome while the caller sees "Request timed out" and pup never records it. The title
            // fills in on the next listing; refresh_all also adopts any tab pup missed.
            // 2.0.675: name the window twice over (a live tab in it, and its Chrome window id); the worker refuses
            // rather than guess, so a tab can never land in another thread's window again.
            let anchor = s.active().map(ext_id).filter(|v| !v.is_null()).or_else(|| s.tabs.iter().map(ext_id).find(|v| !v.is_null()));
            if anchor.is_none() && s.window_id.is_none() { return Err(err("window_not_found", "pup does not know which Chrome window this session is (no tab, no window id); refusing to open a tab in some other window", Some("pup_my_windows shows the window's state; pup_open_window with this window name opens it fresh."))); }
            let r = run(json!({ "action": "newtab", "url": url, "tabId": anchor, "windowId": s.window_id, "waitMs": 20000 }), 40)?;
            let id = r["tabId"].as_i64().ok_or_else(|| err("ext_no_tab", "the extension opened no tab", None))?;
            Ok(ext_tab(s, id, r["url"].as_str().unwrap_or(url), r["title"].as_str().unwrap_or("")))
        }
    }
}

pub fn activate(lane: &Lane, t: &Tab) -> Result<(), Value> {
    match lane {
        Lane::Cdp(b) => { let _ = b.cdp.send(None, "Target.activateTarget", json!({ "targetId": t.target_id }), Duration::from_secs(5)); Ok(()) }
        Lane::Ext => run(json!({ "action": "activate", "tabId": ext_id(t) }), 20).map(|_| ()),
    }
}

/// Bring a tab's url/title up to date. Ext: one `tabs` listing serves every tab of the window.
pub fn refresh_all(lane: &Lane, s: &mut Session) {
    match lane {
        Lane::Cdp(b) => { for t in s.tabs.iter_mut() { crate::refresh_tab(&b.cdp, t); } }
        Lane::Ext => {
            if let Ok(r) = run(json!({ "action": "tabs" }), 25) {
                let live = r["tabs"].as_array().cloned().unwrap_or_default();
                for t in s.tabs.iter_mut() {
                    if let Some(l) = live.iter().find(|l| l["tabId"].as_i64().map(|i| i.to_string()) == Some(t.target_id.clone())) {
                        if let Some(u) = l["url"].as_str() { crate::extdrive::keep_url(&mut t.url, u); }
                        if let Some(ti) = l["title"].as_str() { t.title = ti.into(); }
                    }
                }
                // adopt tabs Chrome has in THIS session's window that pup does not track (a tab whose open
                // outlived the CLI relay, or one the user opened by hand), in Chrome's order
                if let Some(wid) = s.window_id {
                    let known: Vec<String> = s.tabs.iter().map(|t| t.target_id.clone()).collect();
                    let mut adopted = 0;
                    for l in live.iter().filter(|l| l["windowId"].as_i64() == Some(wid)) {
                        if let Some(id) = l["tabId"].as_i64() {
                            if !known.contains(&id.to_string()) {
                                let t = ext_tab(s, id, l["url"].as_str().unwrap_or(""), l["title"].as_str().unwrap_or(""));
                                s.tabs.push(t); adopted += 1;
                            }
                        }
                    }
                    if adopted > 0 { pup_core::log(format!("[lane] \"{}\": adopted {adopted} tab(s) Chrome had in its window that pup did not track", s.id)); }
                }
                if let Some(i) = live.iter().find(|l| l["active"] == json!(true) && l["windowId"].as_i64() == s.window_id).and_then(|l| l["tabId"].as_i64()) {
                    if let Some(pos) = s.tabs.iter().position(|t| t.target_id == i.to_string()) { s.active_tab = pos; }
                }
            }
        }
    }
}

pub fn close_tab(lane: &Lane, t: &Tab) {
    match lane {
        Lane::Cdp(b) => { let _ = b.cdp.close_target(&t.target_id); }
        Lane::Ext => { let _ = run(json!({ "action": "close", "tabId": ext_id(t) }), 20); }
    }
}

/// Close the OS window behind a session. Ext: only that Chrome window; the lane browser stays up for
/// other sessions and is reaped when it has none (the same rule the CDP browsers follow).
/// true when the lane confirmed the close; false when it could not even be asked (no window id) or did not answer.
pub fn close_window(lane: &Lane, s: &Session) -> bool {
    match lane {
        Lane::Cdp(b) => { let mut any = false; for t in &s.tabs { if b.cdp.close_target(&t.target_id).is_ok() { any = true; } } any || s.tabs.is_empty() }
        Lane::Ext => match s.window_id { Some(w) => run(json!({ "action": "close", "windowId": w }), 20).is_ok(), None => false },
    }
}

/// Does the lane still hold this session's window? Ext: any tab whose windowId is the session's. CDP: any of
/// the session's targets still attached. The lane's word, for hosts whose native handle outlives a close.
pub fn lane_has_window(lane: &Lane, s: &Session) -> bool {
    match lane {
        Lane::Cdp(b) => { let live = b.cdp.targets().unwrap_or_default(); s.tabs.iter().any(|t| live.iter().any(|v| v["targetId"].as_str() == Some(t.target_id.as_str()))) }
        Lane::Ext => match s.window_id {
            Some(w) => match run(json!({ "action": "tabs" }), 10) { Ok(r) => r["tabs"].as_array().map(|ts| ts.iter().any(|t| t["windowId"].as_i64() == Some(w))).unwrap_or(false), Err(_) => true },
            None => false,
        },
    }
}

/// Download a URL through the browser. Ext: chrome.downloads in the control extension. CDP callers keep the
/// existing Browser.setDownloadBehavior path in extras.rs.
pub fn download(lane: &Lane, url: &str, save_as: Option<&str>, wait_ms: u64) -> Result<Value, Value> {
    match lane {
        Lane::Cdp(_) => Err(err("internal", "transport::download is the extension arm; CDP downloads run in extras::download", None)),
        Lane::Ext => run(json!({ "action": "download", "url": url, "filename": save_as, "waitMs": wait_ms }), wait_ms / 1000 + 15),
    }
}

/// Delete cookies for a domain (and its subdomains), HttpOnly included, on either lane. `names` narrows it.
pub fn clear_cookies(lane: &Lane, domain: &str, names: &[String]) -> Result<u64, Value> {
    let dom = domain.trim_start_matches('.').to_lowercase();
    match lane {
        Lane::Ext => run(json!({ "action": "clearCookies", "domain": dom, "names": names }), 20).map(|r| r["removed"].as_u64().unwrap_or(0)),
        Lane::Cdp(b) => {
            let all = b.cdp.send(None, "Network.getAllCookies", json!({}), Duration::from_secs(10)).map_err(|e| err("cookies_failed", e.to_string(), None))?;
            let mut n = 0u64;
            for c in all["cookies"].as_array().cloned().unwrap_or_default() {
                let cd = c["domain"].as_str().unwrap_or("").trim_start_matches('.').to_lowercase();
                let name = c["name"].as_str().unwrap_or("").to_string();
                if (dom.is_empty() || cd == dom || cd.ends_with(&format!(".{dom}"))) && (names.is_empty() || names.contains(&name)) {
                    if b.cdp.send(None, "Network.deleteCookies", json!({ "name": name, "domain": c["domain"], "path": c["path"] }), Duration::from_secs(5)).is_ok() { n += 1; }
                }
            }
            Ok(n)
        }
    }
}

/// Clear everything the jar keeps for these origins (cache, cookies, storage, service workers). Ext only; the CDP
/// lane keeps Network/Storage domain verbs.
pub fn clear_site_data(lane: &Lane, origins: &[String]) -> Result<(), Value> {
    match lane {
        Lane::Ext => run(json!({ "action": "clearSiteData", "origins": origins }), 20).map(|_| ()),
        Lane::Cdp(b) => { for o in origins { let _ = b.cdp.send(None, "Storage.clearDataForOrigin", json!({ "origin": o, "storageTypes": "all" }), Duration::from_secs(10)); } Ok(()) }
    }
}

/// Inject an extension-shipped script FILE into the page's MAIN world. Ext: chrome.scripting files
/// injection, which a page CSP cannot refuse. CDP: Runtime.evaluate of the same file's text (it ignores
/// CSP). `text` is that file's contents: the ONE copy lives in src/extension.
pub fn inject_script(lane: &Lane, t: &Tab, file: &str, text: &str, guard: &str) -> Result<(), Value> {
    match lane {
        Lane::Cdp(b) => b.cdp.evaluate(&t.cdp_session, &format!("(() => {{ if (!{guard}) {{ {text} }} return !!{guard}; }})()"), Duration::from_secs(10)).map(|_| ()).map_err(|e| err("inject_failed", e.to_string(), None)),
        Lane::Ext => run(json!({ "action": "inject", "file": file, "tabId": ext_id(t), "world": "MAIN" }), 20).map(|_| ()),
    }
}
/// window[name](...args) in the page.
pub fn call_window_fn(lane: &Lane, t: &Tab, name: &str, args: &[Value]) -> Result<Value, Value> {
    match lane {
        Lane::Cdp(b) => {
            let a = args.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(", ");
            b.cdp.evaluate(&t.cdp_session, &format!("(() => {{ const f = window[{}]; return (typeof f === 'function') ? (f({a}) ?? null) : null; }})()", serde_json::to_string(name).unwrap()), Duration::from_secs(10)).map_err(|e| err("call_failed", e.to_string(), None))
        }
        Lane::Ext => {
            let r = run(json!({ "action": "call", "fn": name, "args": args, "tabId": ext_id(t) }), 20)?;
            if let Some(e) = r["error"].as_str() { return Err(err("call_failed", e.to_string(), None)); }
            if r["missing"] == json!(true) { return Err(err("call_missing", format!("window.{name} is not defined in the page"), None)); }
            Ok(r["value"].clone())
        }
    }
}
/// window[name] (cleared when asked) and the page's device pixel ratio.
pub fn read_window(lane: &Lane, t: &Tab, name: &str, clear: bool) -> Result<(Value, f64), Value> {
    match lane {
        Lane::Cdp(b) => {
            let n = serde_json::to_string(name).unwrap();
            let v = b.cdp.evaluate(&t.cdp_session, &format!("(() => {{ const v = window[{n}]; {} return JSON.stringify({{ value: v === undefined ? null : v, dpr: window.devicePixelRatio || 1 }}); }})()", if clear { format!("window[{n}] = null;") } else { String::new() }), Duration::from_secs(5)).map_err(|e| err("read_failed", e.to_string(), None))?;
            let o: Value = v.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or(v);
            Ok((o["value"].clone(), o["dpr"].as_f64().unwrap_or(1.0)))
        }
        Lane::Ext => { let r = run(json!({ "action": "read", "name": name, "clear": clear, "tabId": ext_id(t) }), 8)?; Ok((r["value"].clone(), r["dpr"].as_f64().unwrap_or(1.0))) }
    }
}
/// A PNG of the viewport, optionally clipped to a CSS-pixel rect. CDP clips in the capture; the
/// extension lane captures the visible tab (device pixels) and the clip is applied in the binary.
pub fn capture_viewport_png(lane: &Lane, t: &Tab, clip_css: Option<(f64, f64, f64, f64)>, dpr: f64) -> Result<Vec<u8>, Value> {
    with_frames(|| match lane {
        Lane::Cdp(b) => {
            let mut p = json!({ "format": "png", "captureBeyondViewport": false });
            if let Some((x, y, w, h)) = clip_css { p["clip"] = json!({ "x": x.max(0.0), "y": y.max(0.0), "width": w.max(1.0), "height": h.max(1.0), "scale": 1 }); }
            let r = b.cdp.send(Some(&t.cdp_session), "Page.captureScreenshot", p, Duration::from_secs(15)).map_err(|e| err("capture_failed", e.to_string(), None))?;
            pup_cdp::base64_decode(r["data"].as_str().unwrap_or("")).map_err(|e| err("capture_failed", e.to_string(), None))
        }
        Lane::Ext => {
            let r = run(json!({ "action": "captureVisible", "tabId": ext_id(t) }), 20)?;
            let b64 = r["dataUrl"].as_str().and_then(|d| d.split("base64,").nth(1)).unwrap_or("");
            let bytes = pup_cdp::base64_decode(b64).map_err(|e| err("capture_failed", e.to_string(), None))?;
            match clip_css {
                None => Ok(bytes),
                Some((x, y, w, h)) => pup_identity::badge::crop_png(&bytes, (x.max(0.0) * dpr).round() as u32, (y.max(0.0) * dpr).round() as u32, ((w.max(1.0) * dpr).round() as u32).max(1), ((h.max(1.0) * dpr).round() as u32).max(1)).ok_or_else(|| err("capture_failed", "could not decode the captured viewport".to_string(), None)),
            }
        }
    })
}