master
John Lauer v2.0.727 (insiders): A pup window waiting on a slow page now explains itself instead of showing a blank PUPBIRTH tab: w… b75d102 22d ago
//! adom-pup-bridge: the pup exe (spawn.kind exe). The plumbing only: bind ADOM_BIND_HOST on the
//! port ab passes as --port, answer /health and /status at once (ab reaps a bridge whose port is
//! not up in ~3s), route POST /command into pup-verbs with the caller identity ab forwards, and
//! run the boot work (cache prune, Chrome for Testing warmup, stale session files) off the
//! request path. Every request runs on its own thread; a panic costs one request.

use pup_core::{Caller, Loopback, NoAb, Settings};
use pup_host::Host;
use pup_verbs::Ctx;
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tiny_http::{Header, Method, Response, Server};

#[cfg(windows)]
fn host() -> Arc<dyn Host> { Arc::new(pup_host_win::WinHost) }
#[cfg(target_os = "macos")]
fn host() -> Arc<dyn Host> { Arc::new(pup_host_mac::MacHost) }
#[cfg(not(any(windows, target_os = "macos")))]
fn host() -> Arc<dyn Host> { Arc::new(pup_host_win::WinHost) }   // dev box: the stubbed impl, so the exe links and /health answers

fn json_response(status: u16, v: &Value) -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_data(serde_json::to_vec(v).unwrap_or_default()).with_status_code(status)
        .with_header(Header::from_bytes("Content-Type", "application/json").unwrap())
        .with_header(Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap())
}

fn caller_from(req: &tiny_http::Request) -> Caller {
    Caller::from_headers(req.headers().iter().map(|h| (h.field.as_str().as_str(), h.value.as_str())))
}

/// `adom-bridge-puppeteer.exe --jumplist <verb> [<base64 json args>]`: the jump-list task / taskbar
/// relaunch callback. Reads the port file the running bridge wrote, POSTs `{command, args}` to its
/// loopback /command as caller user-taskbar-menu, and exits. If no bridge answers (asleep), hands the
/// verb to the ab CLI, which wakes pup. Replaces the Node-era pup-jumplist-call.ps1 (2.0.423).
fn jumplist_call(verb: &str, b64: &str) -> i32 {
    let mut args: serde_json::Value = if b64.is_empty() { serde_json::json!({}) } else { pup_cdp::base64_decode(b64).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_else(|| serde_json::json!({})) };
    args["caller"] = serde_json::json!({ "aiThread": "user-taskbar-menu" });
    let body = serde_json::json!({ "command": verb, "args": args }).to_string();
    let lad = std::env::var("LOCALAPPDATA").map(std::path::PathBuf::from).unwrap_or_default();
    let port = std::fs::read_to_string(lad.join("Adom Bridge").join("pup-bridge-port.json")).ok().and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok()).and_then(|v| v["port"].as_u64());
    if let Some(port) = port {
        let r = ureq::AgentBuilder::new().timeout(std::time::Duration::from_secs(60)).build().post(&format!("http://127.0.0.1:{port}/command"))
            .set("Content-Type", "application/json").set("x-adom-caller-thread", "user-taskbar-menu").set("x-adom-caller-reason", "jump-list click").send_string(&body);
        if r.is_ok() { return 0; }
    }
    // pup is not listening: the ab CLI spawns it and relays the verb (same fallback the .ps1 had).
    for name in ["adom-bridge.exe", "adom-bridge-cli.exe"] {
        let cli = lad.join("Adom Bridge").join(name);
        if cli.is_file() {
            let mut c = std::process::Command::new(cli); c.arg(verb).arg(args.to_string());
            #[cfg(windows)] { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); }   // CREATE_NO_WINDOW: no console flash
            let _ = c.status(); return 0;
        }
    }
    1
}

fn main() {
    let args: Vec<String> = std::env::args().collect();
    if args.get(1).map(|a| a == "--jumplist").unwrap_or(false) {
        std::process::exit(jumplist_call(args.get(2).map(String::as_str).unwrap_or(""), args.get(3).map(String::as_str).unwrap_or("")));
    }
    let port: u16 = args.iter().position(|a| a == "--port").and_then(|i| args.get(i + 1)).and_then(|p| p.parse().ok()).unwrap_or(8851);
    let bind = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".into());
    let h = host();
    let bridge_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
    let state_dir = h.state_dir();
    let _ = std::fs::create_dir_all(&state_dir);
    pup_core::set_log_file(pup_core::data_subdir("logs", state_dir.join("pup-logs")).join("pup.log"));
    pup_core::log(format!("[pup] {} starting; log file {}", pup_core::version(), pup_core::log_file().map(|p| p.display().to_string()).unwrap_or_default()));
    let ab: Arc<dyn pup_core::AbClient> = match Loopback::from_env() { Some(l) => Arc::new(l), None => { pup_core::log("[ab] ADOM_DIRECT_API_URL not set: desktop verbs degrade (hand-launched exe?)"); Arc::new(NoAb) } };
    let _ = pup_verbs::CAPTURE_HOST.set(h.clone());
    let ctx = Arc::new(Ctx {
        host: h.clone(), ab, settings: Arc::new(Settings::new(&state_dir)), installer: Arc::new(pup_browser::Installer::new()),
        sessions: Arc::new(pup_session::Sessions::new(&pup_browser::sessions_dir(&*h))), browsers: Mutex::new(Default::default()), focus_emul: Mutex::new(Default::default()), pending_press: Mutex::new(Default::default()), browser_stall: Mutex::new(Default::default()), browser_idle_since: Mutex::new(Default::default()), tabstate: Ctx::new_tabstate(),
        bridge_dir, port, started: Instant::now(), ad_version: Mutex::new(None), self_ref: Mutex::new(std::sync::Weak::new()), toasts: Mutex::new(Vec::new()),
        vault: pup_vault::Vault::new(&state_dir), cards: pup_vault::Cards::new(&state_dir), consent: pup_verbs::consent::Registry::new(), tab_recs: Default::default(), screen_rec: Default::default(), highfps: Default::default(), last_audit: Mutex::new(None),
    });
    pup_verbs::install_event_hook(ctx.clone());
    *ctx.self_ref.lock().unwrap() = Arc::downgrade(&ctx);

    // Evidence first: a panic anywhere (a verb thread, the boot thread, the event pump) lands in a
    // file ab never truncates, because ab's per-spawn stdout log is wiped on the next respawn and
    // "the exe was gone" is all that survives otherwise (measured 2026-09-05 on winvm).
    let panic_log = state_dir.join("bridge-logs").join("pup-rust-panic.log");
    std::panic::set_hook(Box::new(move |info| {
        let msg = format!("[{}] PANIC in thread {:?}: {info}\n", pup_core::iso_now(), std::thread::current().name().unwrap_or("?"));
        eprint!("{msg}"); pup_core::log(msg.trim_end());
        if let Some(d) = panic_log.parent() { let _ = std::fs::create_dir_all(d); }
        if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&panic_log) { use std::io::Write; let _ = f.write_all(msg.as_bytes()); }
    }));
    // The port can still be held for a few seconds by the instance ab just reaped (measured: an
    // instant exit 513 ms after spawn, "cannot bind"), so retry the bind before giving up.
    let server = {
        let mut last = String::new(); let mut got = None;
        for attempt in 0..20 {
            match Server::http(format!("{bind}:{port}")) { Ok(s) => { got = Some(s); break; } Err(e) => { last = e.to_string(); if attempt == 0 { pup_core::log(format!("[pup] port {port} busy ({last}) — retrying for up to 10 s")); } std::thread::sleep(std::time::Duration::from_millis(500)); } }
        }
        match got { Some(s) => s, None => { eprintln!("[pup] cannot bind {bind}:{port} after 10 s: {last}"); pup_core::log(format!("[pup] cannot bind {bind}:{port} after 10 s: {last} — exiting 2")); std::process::exit(2); } }
    };
    pup_core::log(format!("Puppeteer Bridge v{} (rust) running on http://{bind}:{port} (bind={bind}; no firewall prompt)", pup_core::version()));
    pup_core::log("Endpoints: /health, /status, /command");

    // Boot work off the request path: ab's spawn-ready probe must see the port first.
    {
        let c = ctx.clone();
        std::thread::spawn(move || {
            let pruned = pup_browser::prune_broken_cache_dirs(&*c.host, None);
            if !pruned.is_empty() { pup_core::log(format!("[warmup] pruned {} broken Chrome-for-Testing folder(s)", pruned.len())); }
            let cands = pup_browser::launch_candidates(&*c.host, c.prefer_cft());
            let cft = pup_browser::detect_cft(&*c.host).is_some();
            pup_core::log(format!("[warmup] browsers: {} | cft cached: {cft} | disk free: {}MB", cands.iter().map(|x| format!("{}({})", x.kind, x.source)).collect::<Vec<_>>().join(", "), pup_browser::disk_free_mb(&c.host.cft_cache_dir()).map(|m| m.to_string()).unwrap_or("?".into())));
            if !cft {
                if c.prefer_cft() { pup_core::log("[warmup] the toolbar policy wants Chrome for Testing — background-fetching so launches carry the toolbar"); }
                else if cands.is_empty() { pup_core::log("[warmup] no installed Chromium on this box — background-fetching Chrome for Testing as the last-resort browser"); }
                if c.prefer_cft() || cands.is_empty() { c.installer.ensure_background(c.host.clone()); }
            }
            pup_verbs::identity::boot(&c);
            pup_verbs::restore_extdrive(&c);
            pup_verbs::recovery::recover_at_boot(&c);
            pup_verbs::recovery::start_health_loop(c.clone());
            pup_verbs::highfps::start_loop(c.clone());
            pup_verbs::extras::start_identity_audit(c.clone());
            pup_verbs::extras::start_profile_janitor(c.clone());
            { let c2 = c.clone(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_secs(60)); pup_verbs::identity::sweep_dead_identities(&c2, "boot");
                // 2.0.680: a first-install download can still be unpacking at +60 s; wait for it (up to 20 min) so the
                // prune never deletes the build being written.
                let t0 = std::time::Instant::now();
                while c2.installer.installing() && t0.elapsed() < std::time::Duration::from_secs(1200) { std::thread::sleep(std::time::Duration::from_secs(10)); }
                let _ = pup_verbs::extras::cache_prune(&c2, false); }); }
            if let Ok(v) = c.ab.call("bridge_info", json!({}), &Caller::default(), Duration::from_secs(4)) { *c.ad_version.lock().unwrap() = v.get("version").and_then(|x| x.as_str()).map(|s| s.to_string()); }
        });
    }

    for req in server.incoming_requests() {
        let c = ctx.clone();
        std::thread::spawn(move || {
            let url = req.url().to_string();
            let path = url.split('?').next().unwrap_or("").to_string();
            match (req.method().clone(), path.as_str()) {
                (Method::Get, "/health") => { let _ = req.respond(json_response(200, &pup_dashboard::health(&c))); }
                (Method::Get, "/status") | (Method::Get, "/dashboard/status") | (Method::Get, "/dashboard/health") => { let _ = req.respond(json_response(200, &pup_dashboard::status(&c))); }
                (Method::Options, _) => { let _ = req.respond(Response::empty(204)); }
                // pup-bridge#109: the extension-driven control channel. The pup extension polls for one
                // command and posts the result; this is how pup drives a browser that carries NO CDP port.
                (Method::Get, "/extcmd") => {
                    let q = url.split('?').nth(1).unwrap_or("");
                    let lane = q.split('&').find_map(|kv| kv.strip_prefix("lane=")).unwrap_or("").to_string();
                    let removed: Vec<i64> = q.split('&').find_map(|kv| kv.strip_prefix("removed=")).unwrap_or("").split(',').filter_map(|x| x.parse().ok()).collect();
                    let wait: u64 = q.split('&').find_map(|kv| kv.strip_prefix("wait=")).and_then(|x| x.parse().ok()).unwrap_or(0);
                    let _ = req.respond(json_response(200, &pup_verbs::extdrive::take_command_for(&lane, &removed, wait)));
                }
                // 2.0.602: the extension lane's login-capture rail. The CDP lane got captures through a
                // DevTools binding; the default lane had none, so logins typed in normal pup windows were lost.
                (Method::Post, "/extcred") => {
                    let mut raw = String::new();
                    let mut req = req;
                    let _ = req.as_reader().read_to_string(&mut raw);
                    pup_verbs::vault::on_capture_ext(&c, &raw);
                    let _ = req.respond(json_response(200, &json!({ "ok": true })));
                }
                // 2.0.644 (pup-bridge#119): the extension asks for an HTTP auth credential (Basic/Digest) from the vault.
                (Method::Post, "/extauth") => {
                    let mut raw = String::new();
                    let mut req = req;
                    let _ = req.as_reader().read_to_string(&mut raw);
                    let out = pup_verbs::vault::on_auth_required(&c, &raw);
                    let _ = req.respond(json_response(200, &out));
                }
                (Method::Post, "/exthello") => {
                    let mut raw = String::new();
                    let mut req = req;
                    let _ = req.as_reader().read_to_string(&mut raw);
                    pup_verbs::extdrive::hello(serde_json::from_str(&raw).unwrap_or(json!({})));
                    let _ = req.respond(json_response(200, &json!({ "ok": true })));
                }
                (Method::Post, "/extresult") => {
                    let mut raw = String::new();
                    let mut req = req;
                    let _ = req.as_reader().read_to_string(&mut raw);
                    pup_verbs::extdrive::put_result(serde_json::from_str(&raw).unwrap_or(json!({})));
                    let _ = req.respond(json_response(200, &json!({ "ok": true })));
                }
                (Method::Post, "/command") => {
                    let caller = caller_from(&req);
                    let mut raw = String::new();
                    let mut req = req;
                    let _ = req.as_reader().read_to_string(&mut raw);
                    let parsed: Value = serde_json::from_str(&raw).unwrap_or(json!({}));
                    let verb = parsed.get("command").and_then(|x| x.as_str()).unwrap_or("").to_string();
                    let args = parsed.get("args").cloned().unwrap_or(json!({}));
                    pup_core::log(format!("[verb] {verb}{}", if caller.thread.is_empty() { String::new() } else { format!(" thread={}", caller.thread) }));
                    let reply = pup_verbs::dispatch(&c, &caller, &verb, &args);
                    let _ = req.respond(json_response(200, &reply));
                }
                (method, p) => {
                    let query = url.split('?').nth(1).unwrap_or("").to_string();
                    let mut req = req;
                    let mut body = String::new();
                    if method == Method::Post { let _ = req.as_reader().read_to_string(&mut body); }
                    let caller = caller_from(&req);
                    match pup_dashboard::route(&c, &caller, method.as_str(), p, &query, &body) {
                        Some(pup_dashboard::Reply::Json(status, v)) => { let _ = req.respond(json_response(status, &v)); }
                        Some(pup_dashboard::Reply::Bytes(status, ct, bytes, cache)) => {
                            let mut r = Response::from_data(bytes).with_status_code(status).with_header(Header::from_bytes("Content-Type", ct).unwrap());
                            if let Some(cc) = cache { r = r.with_header(Header::from_bytes("Cache-Control", cc).unwrap()); }
                            let _ = req.respond(r);
                        }
                        Some(pup_dashboard::Reply::Stream(reader)) => {
                            let r = Response::new(tiny_http::StatusCode(200), vec![Header::from_bytes("Content-Type", "text/event-stream").unwrap(), Header::from_bytes("Cache-Control", "no-cache").unwrap(), Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap()], reader, None, None);
                            let _ = req.respond(r);
                        }
                        None => { let _ = req.respond(json_response(404, &json!({ "success": false, "errorCode": "not_found", "path": p }))); }
                    }
                }
            }
        });
    }
}