app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
← Commit history
rust: Phase 0 (contract fixtures, ab asks, workspace) + Phase 1 skeleton (core, host, host-win, host-mac stubs, cdp, browser, session, verbs, dashboard, bridge); measured on winvm: CfT up in 769ms, background window in 1.75s, eval/navigate/screenshot/close in the Node shapes
34 files changed
+2259−1
docs/PLAN-rust-port.md+16−1rust/Cargo.toml+29rust/annotate/Cargo.toml+10rust/annotate/src/lib.rs+3rust/bridge/Cargo.toml+30rust/bridge/src/main.rs+101rust/browser/Cargo.toml+14rust/browser/src/lib.rs+470rust/cdp/Cargo.toml+12rust/cdp/src/lib.rs+285rust/core/Cargo.toml+12rust/core/src/lib.rs+221rust/dashboard/Cargo.toml+11rust/dashboard/src/lib.rs+50rust/host-mac/Cargo.toml+9rust/host-mac/src/lib.rs+41rust/host-win/Cargo.toml+19rust/host-win/src/lib.rs+152rust/host/Cargo.toml+11rust/host/src/lib.rs+74rust/identity/Cargo.toml+10rust/identity/src/lib.rs+3rust/record/Cargo.toml+9rust/record/src/lib.rs+3rust/session/Cargo.toml+12rust/session/src/lib.rs+116rust/vault/Cargo.toml+11rust/vault/src/lib.rs+3rust/verbs/Cargo.toml+16rust/verbs/src/image.rs+71rust/verbs/src/lib.rs+401rust/wiki/Cargo.toml+10rust/wiki/src/lib.rs+3tools/check-borders.sh+21docs/PLAN-rust-port.md+16−1@@ -1,6 +1,21 @@ # pup in Rust: the port plan -**Status: PLAN ONLY. Nothing is built. Written 2026-09-05 against pup 2.0.396 (public and insiders).**+**Status (2026-09-05, evening): Phase 0 DONE, Phase 1 skeleton BUILT and measured on winvm.** Written+2026-09-05 against pup 2.0.396; John approved the plan the same day ("lets get back to the plan").++- Phase 0: fixtures recorded from the Node build into `docs/contract/*.json`; the five ab asks filed+ (adom-bridge#144 send-to-bottom, #145 clipboard_set, #146 IconResource, #147 appId on window rows,+ #148 prewarm question); workspace laid down; `tools/check-borders.sh` enforces the host border.+- Phase 1: `rust/` builds for `x86_64-pc-windows-gnu` (2.4 MB exe, 55 s release build); crates+ core, host, host-win, host-mac (stubs), cdp, browser, session, verbs, dashboard, bridge carry+ real code; every test passes. Measured on winvm with the exe hand-launched on a spare port and+ its own profile: /health, /status, pup_readiness native; `pup_open_window` launched Chrome for+ Testing in 769 ms and had a background window with a resolved handle, parked at the bottom and+ loaded, in 1,750 ms; pup_eval, pup_navigate, pup_screenshot (2012x1117 resized to 1568x871,+ 228 KB, a correct render) and pup_close_window (Chrome gone) all answered in the Node shapes.+ The Node build on the same box was untouched throughout. Not yet: identity stamping, tabs,+ input, recovery, dashboard page, everything in Phases 2 and 3.+ Commissioned by John: "make a version of pup next that is a rust binary like we've recently done for fusion and for altium ... make sure that you did not reimplement anything in ab already does for you ... make lots of crates for the appropriate topic ... all windows stuff in its own area so Kyle can
rust/Cargo.tomladded+29@@ -0,0 +1,29 @@+[workspace]+resolver = "2"+members = ["core", "host", "host-win", "host-mac", "cdp", "browser", "session", "identity", "vault", "wiki", "annotate", "record", "verbs", "dashboard", "bridge"]++# One version for the whole bridge, in lockstep with src/BRIDGE_VERSION (tools/ship.sh bumps both).+[workspace.package]+version = "2.0.399"+edition = "2021"+license = "MIT"++[workspace.dependencies]+serde = { version = "1", features = ["derive"] }+serde_json = { version = "1", features = ["preserve_order"] }+thiserror = "1"+tiny_http = "0.12"+ureq = { version = "2", features = ["json"] }+tungstenite = "0.24"+base64 = "0.22"+png = "0.17"++[profile.release]+opt-level = "z"+lto = true+strip = true+codegen-units = 1++# Dev builds: keep our own crates unoptimized (fast incremental), dependencies lightly optimized.+[profile.dev.package."*"]+opt-level = 1
rust/annotate/Cargo.tomladded+10@@ -0,0 +1,10 @@+[package]+name = "pup-annotate"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "Capture processing for annotate and snip: tight crop, caption bar, iTXt sidecar; the overlay JS ships as an asset."++[dependencies]+serde_json.workspace = true+png.workspace = true
rust/annotate/src/lib.rsadded+3@@ -0,0 +1,3 @@+//! pup-annotate: Capture processing for annotate and snip: tight crop, caption bar, iTXt sidecar; the overlay JS ships as an asset.+//!+//! Phase status: SKELETON (pup Rust port plan, docs/PLAN-rust-port.md).
rust/bridge/Cargo.tomladded+30@@ -0,0 +1,30 @@+[package]+name = "adom-pup-bridge"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "The pup bridge exe (spawn.kind exe): argv --port, bind ADOM_BIND_HOST, tiny_http loop, /command dispatch into pup-verbs."++[[bin]]+name = "adom-pup-bridge"+path = "src/main.rs"++[dependencies]+serde_json.workspace = true+tiny_http.workspace = true+pup-core = { path = "../core" }+pup-host = { path = "../host" }+pup-cdp = { path = "../cdp" }+pup-browser = { path = "../browser" }+pup-session = { path = "../session" }+pup-verbs = { path = "../verbs" }+pup-dashboard = { path = "../dashboard" }++[target.'cfg(windows)'.dependencies]+pup-host-win = { path = "../host-win" }++[target.'cfg(target_os = "macos")'.dependencies]+pup-host-mac = { path = "../host-mac" }++[target.'cfg(not(any(windows, target_os = "macos")))'.dependencies]+pup-host-win = { path = "../host-win" }
rust/bridge/src/main.rsadded+101@@ -0,0 +1,101 @@+//! 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())))+}++fn main() {+ let args: Vec<String> = std::env::args().collect();+ 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);+ 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 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(&state_dir.join("pup-sessions"))), browsers: Mutex::new(Default::default()),+ bridge_dir, port, started: Instant::now(), ad_version: Mutex::new(None),+ });++ let server = match Server::http(format!("{bind}:{port}")) { Ok(s) => s, Err(e) => { eprintln!("[pup] cannot bind {bind}:{port}: {e}"); 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()); }+ }+ // Stale session files (Phase 1: drop files whose browser pid is dead; Phase 2 recovers live ones).+ for f in c.sessions.read_files() {+ let id = f["sessionId"].as_str().unwrap_or("").to_string();+ let pid = f["pid"].as_u64().unwrap_or(0) as u32;+ if !id.is_empty() && pid > 0 && !c.host.alive(pid) { pup_core::log(format!("Session \"{id}\" (PID {pid}) is dead — removing stale file")); c.sessions.delete_file(&id); }+ }+ 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") => { let _ = req.respond(json_response(200, &pup_dashboard::status(&c))); }+ (Method::Options, _) => { let _ = req.respond(Response::empty(204)); }+ (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::Get, "/") | (Method::Get, "/dashboard") => { let _ = req.respond(json_response(200, &json!({ "bridge": "puppeteer", "shell": "rust", "version": pup_core::version(), "_hint": "Phase 1 of the Rust port: the dashboard page lands in Phase 2. /health and /status are live; POST /command drives the verbs." }))); }+ _ => { let _ = req.respond(json_response(404, &json!({ "success": false, "errorCode": "not_found", "path": path }))); }+ }+ });+ }+}
rust/browser/Cargo.tomladded+14@@ -0,0 +1,14 @@+[package]+name = "pup-browser"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "Find installed Chrome/Edge, resolve and download Chrome for Testing (supervised unpack, atomic rename), profiles, launch flags, spawn and the DevTools port handshake."++[dependencies]+serde.workspace = true+serde_json.workspace = true+thiserror.workspace = true+ureq.workspace = true+pup-core = { path = "../core" }+pup-host = { path = "../host" }
rust/browser/src/lib.rsadded+470@@ -0,0 +1,470 @@+//! pup-browser: which browser to drive, how to get Chrome for Testing onto the box, and how to+//! launch it. Ports src/chrome.js (detect/install/readiness/candidates) and the launch half of+//! src/server.js getOrLaunchBrowser. The first-run contract (docs/PLAN-rust-port.md 6b) lives+//! here: download to a temp file, unpack in a supervised child into a `.partial` folder, verify the+//! executable, rename into place atomically, prune every folder without an executable at start.++use pup_host::Host;+use serde_json::{json, Value};+use std::fs;+use std::path::{Path, PathBuf};+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::{Arc, Mutex};+use std::time::{Duration, Instant, SystemTime};++/// The Chrome for Testing build pup drives. Same build id the Node build pinned through+/// puppeteer 24.43, so both builds share one cache folder. Bump deliberately, with a ship note.+pub const CFT_BUILD_ID: &str = "148.0.7778.97";+pub const CFT_MIN_FREE_MB: u64 = 1200;+const UNPACK_STALL: Duration = Duration::from_secs(90);+const UNPACK_MAX: Duration = Duration::from_secs(15 * 60);++#[derive(Debug, Clone, PartialEq)]+pub struct Candidate { pub kind: String, pub source: String, pub executable_path: PathBuf, pub build_id: Option<String> }++impl Candidate {+ pub fn to_json(&self) -> Value { json!({ "kind": self.kind, "source": self.source, "executablePath": self.executable_path.to_string_lossy().replace('\\', "/"), "buildId": self.build_id }) }+}++// ── detection (Node: listSystemBrowsers / detectChrome / launchCandidates) ─────────────++pub fn list_system_browsers(host: &dyn Host) -> Vec<Candidate> {+ let mut out = Vec::new();+ let roots = host.browser_roots();+ let chrome: Vec<PathBuf> = roots.iter().map(|r| r.join("Google").join("Chrome").join("Application").join("chrome.exe")).collect();+ let edge: Vec<PathBuf> = roots.iter().map(|r| r.join("Microsoft").join("Edge").join("Application").join("msedge.exe")).collect();+ if let Some(p) = chrome.into_iter().find(|p| p.is_file()) { out.push(Candidate { kind: "chrome".into(), source: "system".into(), executable_path: p, build_id: None }); }+ if let Some(p) = edge.into_iter().find(|p| p.is_file()) { out.push(Candidate { kind: "edge".into(), source: "system".into(), executable_path: p, build_id: None }); }+ out+}++pub fn cft_versioned_dir(host: &dyn Host, build_id: &str) -> PathBuf { host.cft_cache_dir().join("chrome").join(format!("{}-{}", host.cft_platform(), build_id)) }+pub fn cft_exe_path(host: &dyn Host, build_id: &str) -> PathBuf { cft_versioned_dir(host, build_id).join(host.cft_exe_relative()) }++pub fn detect_cft(host: &dyn Host) -> Option<Candidate> {+ let exe = cft_exe_path(host, CFT_BUILD_ID);+ if exe.is_file() { return Some(Candidate { kind: "chrome-for-testing".into(), source: "cache".into(), executable_path: exe, build_id: Some(CFT_BUILD_ID.into()) }); }+ // any other complete build in the cache (newest wins), so an older cache still drives+ let root = host.cft_cache_dir().join("chrome");+ let mut builds: Vec<(String, PathBuf)> = fs::read_dir(&root).ok()?.flatten()+ .filter_map(|e| { let n = e.file_name().to_string_lossy().to_string(); let prefix = format!("{}-", host.cft_platform()); n.strip_prefix(&prefix).map(|b| (b.to_string(), e.path().join(host.cft_exe_relative()))) })+ .filter(|(_, p)| p.is_file()).collect();+ builds.sort_by(|a, b| version_key(&b.0).cmp(&version_key(&a.0)));+ builds.into_iter().next().map(|(b, p)| Candidate { kind: "chrome-for-testing".into(), source: "cache".into(), executable_path: p, build_id: Some(b) })+}++fn version_key(v: &str) -> Vec<u64> { v.split('.').map(|p| p.parse::<u64>().unwrap_or(0)).collect() }++/// The persisted default (~/.cache/puppeteer/pup-browser-default.json, same file as Node).+pub fn read_default(host: &dyn Host) -> Option<Value> {+ let p = host.cft_cache_dir().join("pup-browser-default.json");+ let v: Value = serde_json::from_str(&fs::read_to_string(p).ok()?).ok()?;+ if v.get("temporary").and_then(|t| t.as_bool()) == Some(true) {+ // Node: expireTemporaryDefault — a temporary pin lasts one bridge lifetime+ if let Some(at) = v.get("verifiedAt").and_then(|t| t.as_u64()) { if pup_core::now_ms().saturating_sub(at) > 6 * 3600 * 1000 { return None; } }+ }+ Some(v)+}++pub fn write_default(host: &dyn Host, cand: &Candidate, forced: bool) {+ let p = host.cft_cache_dir().join("pup-browser-default.json");+ let _ = fs::create_dir_all(p.parent().unwrap());+ let v = json!({ "kind": cand.kind, "executablePath": cand.executable_path.to_string_lossy().replace('\\', "/"), "source": cand.source, "buildId": cand.build_id, "forced": forced, "verifiedAt": pup_core::now_ms() });+ let _ = fs::write(p, serde_json::to_string_pretty(&v).unwrap_or_default());+}++/// Ordered fallback list the launcher spawn-verifies: pinned default first, then (CfT-first+/// policy when the toolbar is on) cached CfT, Chrome, Edge.+pub fn launch_candidates(host: &dyn Host, prefer_cft: bool) -> Vec<Candidate> {+ let mut all = list_system_browsers(host);+ if let Some(c) = detect_cft(host) { if prefer_cft { all.insert(0, c); } else { all.push(c); } }+ if let Some(d) = read_default(host) {+ if d.get("forced").and_then(|f| f.as_bool()) == Some(true) {+ let kind = d.get("kind").and_then(|k| k.as_str()).unwrap_or("");+ if let Some(i) = all.iter().position(|c| c.kind == kind) { let c = all.remove(i); all.insert(0, c); }+ }+ }+ all+}++// ── install state + readiness (Node: chrome.js state / readiness()) ────────────────────++#[derive(Debug, Clone, Default)]+pub struct InstallState {+ pub phase: String, // idle | installing | unpacking | ready | failed+ pub progress_pct: u32,+ pub bytes_downloaded: u64,+ pub bytes_total: u64,+ pub started_at: Option<Instant>,+ pub finished_at: Option<Instant>,+ pub unpack_bytes: u64,+ pub unpack_started_at: Option<Instant>,+ pub unpack_method: Option<String>,+ pub error: Option<String>,+ pub error_code: Option<String>,+ pub build_id: Option<String>,+ pub executable_path: Option<PathBuf>,+}++pub struct Installer { pub state: Arc<Mutex<InstallState>>, inflight: Arc<AtomicBool> }++impl Default for Installer { fn default() -> Self { Installer { state: Arc::new(Mutex::new(InstallState { phase: "idle".into(), ..Default::default() })), inflight: Arc::new(AtomicBool::new(false)) } } }++impl Installer {+ pub fn new() -> Installer { Installer::default() }+ pub fn installing(&self) -> bool { self.inflight.load(Ordering::Relaxed) }++ /// Kick the download off in the background (idempotent: one in flight at a time).+ pub fn ensure_background(&self, host: Arc<dyn Host>) {+ if detect_cft(&*host).is_some() { let mut s = self.state.lock().unwrap(); s.phase = "ready".into(); s.executable_path = Some(cft_exe_path(&*host, CFT_BUILD_ID)); return; }+ if self.inflight.swap(true, Ordering::SeqCst) { return; }+ let state = self.state.clone(); let inflight = self.inflight.clone();+ std::thread::Builder::new().name("cft-install".into()).spawn(move || {+ let r = install_cft(&*host, &state);+ if let Err((code, msg)) = r { let mut s = state.lock().unwrap(); s.phase = "failed".into(); s.error_code = Some(code); s.error = Some(msg.clone()); s.finished_at = Some(Instant::now()); pup_core::log(format!("[chrome] install failed ({}): {}", s.error_code.clone().unwrap_or_default(), msg)); }+ inflight.store(false, Ordering::SeqCst);+ }).ok();+ }++ /// Block until installed (pup_prewarm {wait:true}).+ pub fn ensure_blocking(&self, host: Arc<dyn Host>, max: Duration) -> bool {+ self.ensure_background(host.clone());+ let t0 = Instant::now();+ while t0.elapsed() < max { if detect_cft(&*host).is_some() { return true; } if !self.installing() { return detect_cft(&*host).is_some(); } std::thread::sleep(Duration::from_millis(500)); }+ false+ }++ /// The readiness fields the Node build's chrome.readiness() returned (pup_readiness + chips).+ pub fn readiness(&self, host: &dyn Host, prefer_cft: bool) -> Value {+ let s = self.state.lock().unwrap().clone();+ let installing = self.installing() || s.phase == "installing" || s.phase == "unpacking";+ let det = detect_cft(host);+ let cands = launch_candidates(host, prefer_cft);+ let best = cands.first().cloned();+ let free = disk_free_mb(&host.cft_cache_dir());+ let elapsed = if installing { s.started_at.map(|t| t.elapsed().as_secs()) } else { None };+ let eta = if installing && s.bytes_total > 0 && s.bytes_downloaded > 0 && elapsed.unwrap_or(0) >= 2 { let rate = s.bytes_downloaded as f64 / elapsed.unwrap_or(1) as f64; if rate > 0.0 { Some(((s.bytes_total - s.bytes_downloaded) as f64 / rate).round() as u64) } else { None } } else { None };+ json!({+ "ready": best.is_some(),+ "browserKind": best.as_ref().map(|c| c.kind.clone()),+ "browserSource": best.as_ref().map(|c| c.source.clone()),+ "browserExecutablePath": best.as_ref().map(|c| c.executable_path.to_string_lossy().replace('\\', "/")),+ "candidates": cands.iter().map(|c| c.to_json()).collect::<Vec<_>>(),+ "defaultBrowser": read_default(host).map(|d| json!({ "kind": d.get("kind"), "source": d.get("source"), "forced": d.get("forced").and_then(|f| f.as_bool()).unwrap_or(false), "pendingInstall": d.get("pendingInstall").and_then(|f| f.as_bool()).unwrap_or(false) })),+ "nodeInstalled": true, "nodeVersion": format!("rust/{}", pup_core::version()),+ "chromeForTestingInstalled": det.is_some(),+ "chromeExecutablePath": det.as_ref().map(|c| c.executable_path.to_string_lossy().replace('\\', "/")).or_else(|| s.executable_path.as_ref().map(|p| p.to_string_lossy().replace('\\', "/"))),+ "chromeBuildId": det.as_ref().and_then(|c| c.build_id.clone()).or(s.build_id.clone()).or(Some(CFT_BUILD_ID.into())),+ "installing": installing, "installingChrome": installing,+ "installPhase": if det.is_some() && !installing { "ready" } else { s.phase.as_str() },+ "installProgressPct": if det.is_some() && !installing { 100 } else { s.progress_pct },+ "bytesDownloaded": s.bytes_downloaded, "bytesTotal": s.bytes_total,+ "downloadElapsedSec": elapsed, "downloadEtaSec": eta,+ "unpackBytes": s.unpack_bytes, "unpackElapsedSec": if s.phase == "unpacking" { s.unpack_started_at.map(|t| t.elapsed().as_secs()) } else { None }, "unpackMethod": s.unpack_method,+ "lastError": s.error, "lastErrorCode": s.error_code,+ "cacheDir": host.cft_cache_dir().to_string_lossy().replace('\\', "/"),+ "diskFreeMb": free, "lowDisk": free.map(|f| f < CFT_MIN_FREE_MB).unwrap_or(false),+ "puppeteerLoadError": Value::Null,+ })+ }+}++pub fn disk_free_mb(path: &Path) -> Option<u64> {+ // Portable enough: statvfs on unix, GetDiskFreeSpaceExW on Windows (through std::fs is not+ // available); use a tiny platform split without pulling a crate.+ #[cfg(windows)]+ {+ use std::os::windows::ffi::OsStrExt;+ extern "system" { fn GetDiskFreeSpaceExW(lpDirectoryName: *const u16, lpFreeBytesAvailableToCaller: *mut u64, lpTotalNumberOfBytes: *mut u64, lpTotalNumberOfFreeBytes: *mut u64) -> i32; }+ let mut p = path.to_path_buf(); while !p.exists() { if !p.pop() { break; } }+ let w: Vec<u16> = p.as_os_str().encode_wide().chain(std::iter::once(0)).collect();+ let (mut a, mut b, mut c) = (0u64, 0u64, 0u64);+ let ok = unsafe { GetDiskFreeSpaceExW(w.as_ptr(), &mut a, &mut b, &mut c) } != 0;+ return if ok { Some(a / (1024 * 1024)) } else { None };+ }+ #[cfg(not(windows))]+ { let _ = path; None }+}++// ── the supervised install (Node: installChrome + unpackSupervised, 2.0.398) ───────────++fn set(state: &Arc<Mutex<InstallState>>, f: impl FnOnce(&mut InstallState)) { if let Ok(mut s) = state.lock() { f(&mut s); } }++pub fn install_cft(host: &dyn Host, state: &Arc<Mutex<InstallState>>) -> Result<PathBuf, (String, String)> {+ let build = CFT_BUILD_ID;+ set(state, |s| { *s = InstallState { phase: "installing".into(), started_at: Some(Instant::now()), build_id: Some(build.into()), ..Default::default() }; });+ let cache = host.cft_cache_dir();+ let root = cache.join("chrome");+ fs::create_dir_all(&root).map_err(|e| ("chrome_install_permission".to_string(), format!("cannot create {}: {e}", root.display())))?;+ if let Some(free) = disk_free_mb(&cache) { if free < CFT_MIN_FREE_MB { return Err(("chrome_install_no_disk".into(), format!("Not enough free disk to install Chrome for Testing: {free} MB free on the drive holding {}, need ~{CFT_MIN_FREE_MB} MB (150 MB download unpacks to ~600 MB).", cache.display()))); } }+ prune_broken_cache_dirs(host, Some(build));+ let platform = host.cft_platform();+ let archive = root.join(format!("{build}-chrome-{platform}.zip"));+ if !archive.is_file() {+ let url = format!("https://storage.googleapis.com/chrome-for-testing-public/{build}/{platform}/chrome-{platform}.zip");+ pup_core::log(format!("[chrome] installing Chrome for Testing {build} ({platform}) into {} ...", cache.display()));+ download(&url, &archive, state)?;+ } else {+ let size = fs::metadata(&archive).map(|m| m.len()).unwrap_or(0);+ set(state, |s| { s.bytes_total = size; s.bytes_downloaded = size; s.progress_pct = 100; });+ }+ let versioned = cft_versioned_dir(host, build);+ let partial = PathBuf::from(format!("{}.partial", versioned.display()));+ set(state, |s| { s.phase = "unpacking".into(); s.unpack_started_at = Some(Instant::now()); s.unpack_bytes = 0; });+ let size = fs::metadata(&archive).map(|m| m.len()).unwrap_or(0);+ pup_core::log(format!("[chrome] archive {} ({size} bytes) on disk — unpacking into {}", archive.display(), partial.display()));+ let method = unpack_supervised(&archive, &partial, state)?;+ let exe_in_partial = partial.join(host.cft_exe_relative());+ if !exe_in_partial.is_file() {+ let _ = fs::remove_dir_all(&partial);+ return Err(("chrome_unpack_incomplete".into(), format!("unpack via {method} finished but {} is missing", exe_in_partial.display())));+ }+ if versioned.exists() { let _ = fs::remove_dir_all(&versioned); }+ fs::rename(&partial, &versioned).map_err(|e| ("chrome_unpack_failed".to_string(), format!("rename {} -> {}: {e}", partial.display(), versioned.display())))?;+ let _ = fs::remove_file(&archive);+ let exe = cft_exe_path(host, build);+ let secs = state.lock().ok().and_then(|s| s.unpack_started_at.map(|t| t.elapsed().as_secs())).unwrap_or(0);+ let ub = state.lock().map(|s| s.unpack_bytes).unwrap_or(0);+ pup_core::log(format!("[chrome] unpacked via {method} in {secs}s ({ub} bytes) -> {}", versioned.display()));+ pup_core::log(format!("[chrome] Chrome for Testing {build} ready at {}", exe.display()));+ set(state, |s| { s.phase = "ready".into(); s.executable_path = Some(exe.clone()); s.progress_pct = 100; s.finished_at = Some(Instant::now()); s.error = None; s.error_code = None; });+ write_default(host, &Candidate { kind: "chrome-for-testing".into(), source: "cache".into(), executable_path: exe.clone(), build_id: Some(build.into()) }, false);+ Ok(exe)+}++fn download(url: &str, dest: &Path, state: &Arc<Mutex<InstallState>>) -> Result<(), (String, String)> {+ let part = PathBuf::from(format!("{}.part", dest.display()));+ let resp = ureq::AgentBuilder::new().timeout_connect(Duration::from_secs(20)).build().get(url).call().map_err(|e| (classify_net(&e.to_string()), format!("download {url}: {e}")))?;+ let total: u64 = resp.header("Content-Length").and_then(|h| h.parse().ok()).unwrap_or(0);+ set(state, |s| { s.bytes_total = total; });+ let mut reader = resp.into_reader();+ let mut out = fs::File::create(&part).map_err(|e| ("chrome_install_permission".to_string(), e.to_string()))?;+ let mut buf = vec![0u8; 256 * 1024];+ let mut got: u64 = 0;+ let mut last = Instant::now();+ loop {+ let n = std::io::Read::read(&mut reader, &mut buf).map_err(|e| (classify_net(&e.to_string()), format!("download read: {e}")))?;+ if n == 0 { break; }+ std::io::Write::write_all(&mut out, &buf[..n]).map_err(|e| ("chrome_install_no_disk".to_string(), e.to_string()))?;+ got += n as u64;+ if last.elapsed() > Duration::from_millis(250) { last = Instant::now(); set(state, |s| { s.bytes_downloaded = got; s.progress_pct = if total > 0 { ((got * 100) / total) as u32 } else { 0 }; }); }+ }+ drop(out);+ if total > 0 && got != total { let _ = fs::remove_file(&part); return Err(("chrome_download_failed".into(), format!("download ended at {got} of {total} bytes"))); }+ fs::rename(&part, dest).map_err(|e| ("chrome_install_permission".to_string(), e.to_string()))?;+ set(state, |s| { s.bytes_downloaded = got; s.bytes_total = if total > 0 { total } else { got }; s.progress_pct = 100; });+ Ok(())+}++fn classify_net(msg: &str) -> String {+ let m = msg.to_ascii_lowercase();+ if m.contains("dns") || m.contains("resolve") || m.contains("timed out") || m.contains("connection") || m.contains("proxy") || m.contains("network") { "chrome_download_network".into() } else { "chrome_download_failed".into() }+}++pub fn dir_bytes(dir: &Path, depth: u32) -> u64 {+ let mut total = 0;+ if let Ok(rd) = fs::read_dir(dir) { for e in rd.flatten() { let p = e.path(); if p.is_dir() { if depth < 6 { total += dir_bytes(&p, depth + 1); } } else if let Ok(m) = p.metadata() { total += m.len(); } } }+ total+}++fn has_cft_executable(dir: &Path) -> bool {+ if let Ok(rd) = fs::read_dir(dir) {+ for e in rd.flatten() {+ let n = e.file_name().to_string_lossy().to_string();+ if !n.starts_with("chrome-") { continue; }+ let b = e.path();+ if b.join("chrome.exe").is_file() || b.join("chrome").is_file() || b.join("Google Chrome for Testing.app").exists() { return true; }+ }+ }+ false+}++/// Every folder under <cache>/chrome/ without an executable is junk (interrupted download or+/// unzip) and would make the next install a deterministic failure. Never touches a young+/// `.partial` (an unpack in progress elsewhere).+pub fn prune_broken_cache_dirs(host: &dyn Host, keep_build: Option<&str>) -> Vec<String> {+ let root = host.cft_cache_dir().join("chrome");+ let mut removed = Vec::new();+ let Ok(rd) = fs::read_dir(&root) else { return removed };+ for e in rd.flatten() {+ let p = e.path(); if !p.is_dir() { continue; }+ let name = e.file_name().to_string_lossy().to_string();+ if let Some(k) = keep_build { if name.contains(k) && !name.ends_with(".partial") { continue; } }+ if name.ends_with(".partial") { if let Ok(m) = p.metadata() { if let Ok(t) = m.modified() { if SystemTime::now().duration_since(t).unwrap_or_default() < Duration::from_secs(30 * 60) { continue; } } } }+ if has_cft_executable(&p) { continue; }+ match fs::remove_dir_all(&p) {+ Ok(_) => { pup_core::log(format!("[chrome] pruned a Chrome-for-Testing cache folder with no executable (interrupted download or unzip): {}", p.display())); removed.push(name); }+ Err(err) => pup_core::log(format!("[chrome] could not prune broken cache folder {}: {err}", p.display())),+ }+ }+ removed+}++/// Child-process unpack with a stall watchdog: OS tar first (bsdtar reads zip natively on+/// Windows 10+ and macOS), then `unzip` on unix. Each failed method is cleaned up before the next.+pub fn unpack_supervised(archive: &Path, dest: &Path, state: &Arc<Mutex<InstallState>>) -> Result<String, (String, String)> {+ let mut methods: Vec<(&str, PathBuf, Vec<String>)> = vec![("tar", PathBuf::from("tar"), vec!["-xf".into(), archive.to_string_lossy().into(), "-C".into(), dest.to_string_lossy().into()])];+ if !cfg!(windows) { methods.push(("unzip", PathBuf::from("unzip"), vec!["-q".into(), "-o".into(), archive.to_string_lossy().into(), "-d".into(), dest.to_string_lossy().into()])); }+ let mut last = String::new();+ for (name, cmd, args) in methods {+ let _ = fs::remove_dir_all(dest);+ fs::create_dir_all(dest).map_err(|e| ("chrome_install_permission".to_string(), e.to_string()))?;+ set(state, |s| { s.unpack_method = Some(name.into()); });+ match run_unpack(&cmd, &args, dest, state) {+ Ok(()) => return Ok(name.into()),+ Err(e) => { last = format!("unpack via {name} {e}"); pup_core::log(format!("[chrome] {last} — trying the next unpack method")); }+ }+ }+ let _ = fs::remove_dir_all(dest);+ Err(("chrome_unpack_failed".into(), last))+}++fn run_unpack(cmd: &Path, args: &[String], dest: &Path, state: &Arc<Mutex<InstallState>>) -> Result<(), String> {+ let mut c = std::process::Command::new(cmd);+ c.args(args).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::piped());+ #[cfg(windows)]+ { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); } // CREATE_NO_WINDOW+ let mut child = c.spawn().map_err(|e| format!("could not start: {e}"))?;+ let started = Instant::now();+ let (mut last_bytes, mut last_growth) = (u64::MAX, Instant::now());+ loop {+ if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {+ let b = dir_bytes(dest, 0); set(state, |s| { s.unpack_bytes = b; });+ if status.success() { return Ok(()); }+ let mut err = String::new(); if let Some(mut se) = child.stderr.take() { let _ = std::io::Read::read_to_string(&mut se, &mut err); }+ return Err(format!("exited {status}: {}", err.trim().chars().rev().take(300).collect::<String>().chars().rev().collect::<String>()));+ }+ std::thread::sleep(Duration::from_millis(1000));+ let b = dir_bytes(dest, 0); set(state, |s| { s.unpack_bytes = b; });+ if b != last_bytes { last_bytes = b; last_growth = Instant::now(); }+ if last_growth.elapsed() > UNPACK_STALL { let _ = child.kill(); return Err(format!("STALLED: no bytes written for {}s ({b} bytes so far); killed it", UNPACK_STALL.as_secs())); }+ if started.elapsed() > UNPACK_MAX { let _ = child.kill(); return Err(format!("exceeded {} min; killed it", UNPACK_MAX.as_secs() / 60)); }+ }+}++// ── profile + launch (Node: prepareCleanProfile + getOrLaunchBrowser) ──────────────────++pub fn profiles_dir(host: &dyn Host) -> PathBuf { host.state_dir().join("pup-profiles") }++/// The switches every pup launch carries (server.js v2.0.293 merged form: ONE --disable-features).+pub fn chrome_switches(profile_dir: &Path, high_fps: bool, insecure_dev: bool) -> Vec<String> {+ let mut v: Vec<String> = vec![+ format!("--user-data-dir={}", profile_dir.display()),+ "--remote-debugging-port=0".into(),+ "--no-first-run".into(), "--no-default-browser-check".into(), "--disable-default-apps".into(),+ "--disable-sync".into(), "--disable-background-networking".into(), "--disable-infobars".into(),+ "--disable-session-crashed-bubble".into(), "--hide-crash-restore-bubble".into(), "--disable-field-trial-config".into(),+ "--ash-no-nudges".into(), "--test-type".into(), "--no-sandbox".into(), "--media-router=0".into(),+ "--install-autogenerated-theme=13,17,23".into(), "--start-maximized".into(),+ "--disable-quic".into(), "--disable-async-dns".into(),+ "--force-webrtc-ip-handling-policy=disable_non_proxied_udp".into(), "--webrtc-ip-handling-policy=disable_non_proxied_udp".into(),+ "--disable-features=CalculateNativeWinOcclusion,WebRtcHideLocalIpsWithMdns,WebRtcAllowInputVolumeAdjustment,AsyncDns,DnsOverHttps,DnsOverHttpsUpgrade,UseDnsHttpsSvcb,UseDnsHttpsSvcbAlpn,EncryptedClientHello,WinLaunchOnLogon,LaunchChromeOnWindowsLogon,StartupBoost,BeginBrowsingInstantly,DesktopStartupPromo,IPH_LaunchOnStartup,MediaRouter,DialMediaRouteProvider,CastMediaRouteProvider".into(),+ ];+ if high_fps { v.extend(["--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding"].map(String::from)); }+ if insecure_dev { v.push("--disable-web-security".into()); }+ v.push("about:blank".into());+ v+}++/// Node: prepareCleanProfile (the Preferences the profile must carry before Chrome opens it).+pub fn prepare_profile(profile_dir: &Path) {+ let default = profile_dir.join("Default");+ let _ = fs::create_dir_all(&default);+ let prefs_path = default.join("Preferences");+ let mut prefs: Value = fs::read_to_string(&prefs_path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}));+ if !prefs.is_object() { prefs = json!({}); }+ prefs["profile"]["exit_type"] = json!("Normal");+ prefs["profile"]["exited_cleanly"] = json!(true);+ prefs["webrtc"]["ip_handling_policy"] = json!("disable_non_proxied_udp");+ prefs["webrtc"]["nonproxied_udp_enabled"] = json!(false);+ prefs["webrtc"]["multiple_routes_enabled"] = json!(false);+ prefs["credentials_enable_service"] = json!(false); // pup runs its own vault; no Chrome save bubbles+ prefs["profile"]["password_manager_enabled"] = json!(false);+ let _ = fs::write(&prefs_path, serde_json::to_string(&prefs).unwrap_or_default());+ // Chrome refuses to start on a profile whose lock names a dead process; Node cleaned these too.+ for lock in ["SingletonLock", "SingletonCookie", "SingletonSocket", "lockfile"] { let _ = fs::remove_file(profile_dir.join(lock)); }+}++#[derive(Debug, Clone)]+pub struct Launched { pub pid: u32, pub cdp_port: u16, pub candidate: Candidate, pub profile_dir: PathBuf, pub reused: bool }++/// If the profile's DevToolsActivePort names a live Chrome, reuse it (Node: attemptReconnectProfile).+pub fn existing_port(profile_dir: &Path) -> Option<u16> {+ let f = profile_dir.join("DevToolsActivePort");+ let port: u16 = fs::read_to_string(&f).ok()?.lines().next()?.trim().parse().ok()?;+ ureq::AgentBuilder::new().timeout(Duration::from_millis(1500)).build().get(&format!("http://127.0.0.1:{port}/json/version")).call().ok()?;+ Some(port)+}++/// Spawn-verified launch: the browser is started detached, the DevTools port is read from the+/// profile's DevToolsActivePort (written by Chrome), and /json/version must answer.+pub fn launch(host: &dyn Host, cand: &Candidate, profile_dir: &Path, high_fps: bool, insecure_dev: bool) -> Result<Launched, String> {+ let _ = fs::create_dir_all(profile_dir);+ let port_file = profile_dir.join("DevToolsActivePort");+ let _ = fs::remove_file(&port_file);+ prepare_profile(profile_dir);+ let args = chrome_switches(profile_dir, high_fps, insecure_dev);+ let t0 = Instant::now();+ let pid = host.spawn_detached(&cand.executable_path, &args, None).map_err(|e| format!("spawn {}: {e}", cand.executable_path.display()))?;+ while t0.elapsed() < Duration::from_secs(30) {+ if let Ok(s) = fs::read_to_string(&port_file) {+ if let Some(p) = s.lines().next().and_then(|l| l.trim().parse::<u16>().ok()) {+ if ureq::AgentBuilder::new().timeout(Duration::from_millis(1500)).build().get(&format!("http://127.0.0.1:{p}/json/version")).call().is_ok() {+ return Ok(Launched { pid, cdp_port: p, candidate: cand.clone(), profile_dir: profile_dir.to_path_buf(), reused: false });+ }+ }+ }+ if !host.alive(pid) && t0.elapsed() > Duration::from_secs(3) { return Err(format!("{} exited during launch (pid {pid})", cand.kind)); }+ std::thread::sleep(Duration::from_millis(200));+ }+ Err(format!("{} did not publish a DevTools port within 30s", cand.kind))+}++#[cfg(test)]+mod tests {+ use super::*;+ use pup_host::HostPaths;+ struct H(PathBuf);+ impl pup_host::HostWindows for H { fn windows_for_pids(&self, _: &[u32], _: Option<&str>) -> Vec<pup_host::HostWindow> { vec![] } fn is_alive(&self, _: i64) -> bool { false } fn tag(&self, _: i64, _: &str) -> bool { false } fn has_tag(&self, _: i64, _: &str) -> bool { false } fn send_to_bottom_no_activate(&self, _: i64) -> bool { false } fn set_bounds_no_activate(&self, _: i64, _: i32, _: i32, _: i32, _: i32) -> bool { false } fn foreground(&self) -> i64 { 0 } fn work_area(&self) -> Option<(i32, i32, i32, i32)> { None } }+ impl pup_host::HostProcess for H { fn alive(&self, _: u32) -> bool { false } fn spawn_detached(&self, _: &Path, _: &[String], _: Option<&Path>) -> std::io::Result<u32> { Ok(1) } fn kill(&self, _: u32) -> bool { false } }+ impl pup_host::HostPaths for H { fn home(&self) -> PathBuf { self.0.clone() } fn browser_roots(&self) -> Vec<PathBuf> { vec![] } fn cft_platform(&self) -> &'static str { "linux64" } fn cft_exe_relative(&self) -> PathBuf { PathBuf::from("chrome-linux64").join("chrome") } }++ #[test] fn prune_and_detect() {+ let tmp = std::env::temp_dir().join(format!("pup-browser-test-{}", std::process::id()));+ let _ = fs::remove_dir_all(&tmp);+ let h = H(tmp.clone());+ let root = h.cft_cache_dir().join("chrome");+ fs::create_dir_all(root.join("linux64-1.0.0.0").join("chrome-linux64")).unwrap();+ fs::write(root.join("linux64-1.0.0.0").join("chrome-linux64").join("chrome"), b"x").unwrap();+ fs::create_dir_all(root.join("linux64-9.9.9.9").join("chrome-linux64")).unwrap(); // no exe: junk+ fs::create_dir_all(root.join("chrome")).unwrap(); // stray+ let removed = prune_broken_cache_dirs(&h, None);+ assert_eq!(removed.len(), 2, "{removed:?}");+ assert!(root.join("linux64-1.0.0.0").exists());+ let d = detect_cft(&h).unwrap(); assert_eq!(d.build_id.as_deref(), Some("1.0.0.0")); assert_eq!(d.kind, "chrome-for-testing");+ let _ = fs::remove_dir_all(&tmp);+ }+ #[test] fn switches_have_one_disable_features() {+ let s = chrome_switches(Path::new("/tmp/p"), false, false);+ assert_eq!(s.iter().filter(|x| x.starts_with("--disable-features=")).count(), 1);+ assert!(s.iter().any(|x| x == "--remote-debugging-port=0"));+ assert!(!s.iter().any(|x| x == "--disable-web-security"));+ assert!(chrome_switches(Path::new("/tmp/p"), true, true).iter().any(|x| x == "--disable-web-security"));+ }+ #[test] fn unpack_real_zip_and_reject_junk() {+ let tmp = std::env::temp_dir().join(format!("pup-unpack-test-{}", std::process::id()));+ let _ = fs::remove_dir_all(&tmp); fs::create_dir_all(tmp.join("src").join("chrome-linux64")).unwrap();+ fs::write(tmp.join("src").join("chrome-linux64").join("chrome"), vec![7u8; 100000]).unwrap();+ let zip = tmp.join("a.zip");+ let ok = std::process::Command::new("python3").args(["-c", "import shutil,sys; shutil.make_archive(sys.argv[1][:-4],'zip',sys.argv[2])", zip.to_str().unwrap(), tmp.join("src").to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+ if !ok { return; } // no python on this box: skip+ let st = Arc::new(Mutex::new(InstallState::default()));+ let dest = tmp.join("linux64-1.2.3.partial");+ let m = unpack_supervised(&zip, &dest, &st).expect("unpack");+ assert!(dest.join("chrome-linux64").join("chrome").is_file(), "via {m}");+ fs::write(tmp.join("bad.zip"), b"not a zip").unwrap();+ assert!(unpack_supervised(&tmp.join("bad.zip"), &tmp.join("x.partial"), &st).is_err());+ assert!(!tmp.join("x.partial").exists());+ let _ = fs::remove_dir_all(&tmp);+ }+}
rust/cdp/Cargo.tomladded+12@@ -0,0 +1,12 @@+[package]+name = "pup-cdp"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "The Chrome DevTools Protocol client: one websocket per browser, session multiplexing, a per-session command queue, typed wrappers for the methods pup uses."++[dependencies]+serde_json.workspace = true+thiserror.workspace = true+tungstenite.workspace = true+ureq.workspace = true
rust/cdp/src/lib.rsadded+285@@ -0,0 +1,285 @@+//! pup-cdp: the Chrome DevTools Protocol client. One websocket per browser, flattened sessions+//! multiplexed over it, a reader thread that routes replies by id and events to subscribers, and+//! typed wrappers for the methods pup uses. This is the queue and lifecycle the Node build never+//! had (28 ad-hoc CDPSessions, no pipeline); every call site goes through `send` with a timeout.+//!+//! Sync by design (like the rest of the bridge): the reader thread owns the socket with a short+//! read timeout so senders can take the lock between reads; a command's reply arrives on a+//! channel the sender waits on. No async runtime.++use serde_json::{json, Value};+use std::collections::HashMap;+use std::net::TcpStream;+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};+use std::sync::mpsc::{channel, Receiver, Sender};+use std::sync::{Arc, Mutex};+use std::time::{Duration, Instant};+use tungstenite::protocol::WebSocketConfig;+use tungstenite::stream::MaybeTlsStream;+use tungstenite::{Message, WebSocket};++#[derive(Debug, thiserror::Error)]+pub enum CdpError {+ #[error("connect failed: {0}")]+ Connect(String),+ #[error("browser connection closed")]+ Closed,+ #[error("{method} timed out after {ms}ms")]+ Timeout { method: String, ms: u64 },+ #[error("{method} failed: {code} {message}")]+ Protocol { method: String, code: i64, message: String },+ #[error("bad reply: {0}")]+ Json(String),+}++#[derive(Debug, Clone)]+pub struct Event { pub method: String, pub params: Value, pub session_id: Option<String> }++struct Sub { session: Option<String>, method: Option<String>, tx: Sender<Event> }++struct Inner {+ sock: Mutex<WebSocket<MaybeTlsStream<TcpStream>>>,+ next_id: AtomicU64,+ pending: Mutex<HashMap<u64, Sender<Value>>>,+ subs: Mutex<Vec<Sub>>,+ alive: AtomicBool,+ ws_url: String,+ port: u16,+}++#[derive(Clone)]+pub struct Cdp { inner: Arc<Inner> }++impl Cdp {+ /// GET http://127.0.0.1:<port>/json/version then connect the browser websocket.+ pub fn connect_port(port: u16, timeout: Duration) -> Result<Cdp, CdpError> {+ let v: Value = ureq::AgentBuilder::new().timeout(timeout).build()+ .get(&format!("http://127.0.0.1:{port}/json/version")).call()+ .map_err(|e| CdpError::Connect(format!("/json/version on :{port}: {e}")))?+ .into_json().map_err(|e| CdpError::Connect(e.to_string()))?;+ let ws = v.get("webSocketDebuggerUrl").and_then(|s| s.as_str()).ok_or_else(|| CdpError::Connect("no webSocketDebuggerUrl".into()))?.to_string();+ Cdp::connect(&ws, port)+ }++ pub fn connect(ws_url: &str, port: u16) -> Result<Cdp, CdpError> {+ let mut cfg = WebSocketConfig::default();+ cfg.max_message_size = None; // screenshots come back as one large text frame+ cfg.max_frame_size = None;+ let (sock, _resp) = tungstenite::client::connect_with_config(ws_url, Some(cfg), 3).map_err(|e| CdpError::Connect(e.to_string()))?;+ if let MaybeTlsStream::Plain(tcp) = sock.get_ref() {+ let _ = tcp.set_read_timeout(Some(Duration::from_millis(25)));+ let _ = tcp.set_nodelay(true);+ }+ let inner = Arc::new(Inner {+ sock: Mutex::new(sock), next_id: AtomicU64::new(1), pending: Mutex::new(HashMap::new()), subs: Mutex::new(Vec::new()),+ alive: AtomicBool::new(true), ws_url: ws_url.to_string(), port,+ });+ let reader = inner.clone();+ std::thread::Builder::new().name("cdp-reader".into()).spawn(move || reader_loop(reader)).map_err(|e| CdpError::Connect(e.to_string()))?;+ Ok(Cdp { inner })+ }++ pub fn is_alive(&self) -> bool { self.inner.alive.load(Ordering::Relaxed) }+ pub fn ws_url(&self) -> &str { &self.inner.ws_url }+ pub fn port(&self) -> u16 { self.inner.port }++ /// Send one command (browser-level when `session` is None) and wait for its reply.+ pub fn send(&self, session: Option<&str>, method: &str, params: Value, timeout: Duration) -> Result<Value, CdpError> {+ if !self.is_alive() { return Err(CdpError::Closed); }+ let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);+ let (tx, rx) = channel::<Value>();+ self.inner.pending.lock().unwrap().insert(id, tx);+ let mut msg = json!({ "id": id, "method": method, "params": params });+ if let Some(s) = session { msg["sessionId"] = json!(s); }+ {+ let mut sock = self.inner.sock.lock().unwrap();+ if let Err(e) = sock.send(Message::Text(msg.to_string().into())) {+ self.inner.pending.lock().unwrap().remove(&id);+ self.inner.alive.store(false, Ordering::Relaxed);+ return Err(CdpError::Connect(format!("send {method}: {e}")));+ }+ }+ match rx.recv_timeout(timeout) {+ Ok(v) => {+ if let Some(e) = v.get("error") {+ return Err(CdpError::Protocol { method: method.into(), code: e.get("code").and_then(|c| c.as_i64()).unwrap_or(0), message: e.get("message").and_then(|m| m.as_str()).unwrap_or("").into() });+ }+ Ok(v.get("result").cloned().unwrap_or(Value::Null))+ }+ Err(_) => {+ self.inner.pending.lock().unwrap().remove(&id);+ if !self.is_alive() { Err(CdpError::Closed) } else { Err(CdpError::Timeout { method: method.into(), ms: timeout.as_millis() as u64 }) }+ }+ }+ }++ /// Subscribe to events (optionally one session and/or one method). Drop the receiver to+ /// unsubscribe; the reader prunes dead subscribers on the next event.+ pub fn subscribe(&self, session: Option<&str>, method: Option<&str>) -> Receiver<Event> {+ let (tx, rx) = channel::<Event>();+ self.inner.subs.lock().unwrap().push(Sub { session: session.map(|s| s.to_string()), method: method.map(|m| m.to_string()), tx });+ rx+ }++ pub fn wait_event(rx: &Receiver<Event>, timeout: Duration) -> Option<Event> { rx.recv_timeout(timeout).ok() }++ // ── typed wrappers (the CDP surface pup actually uses) ──────────────────────────────++ pub fn version(&self) -> Result<Value, CdpError> { self.send(None, "Browser.getVersion", json!({}), Duration::from_secs(5)) }++ pub fn targets(&self) -> Result<Vec<Value>, CdpError> {+ let r = self.send(None, "Target.getTargets", json!({}), Duration::from_secs(10))?;+ Ok(r.get("targetInfos").and_then(|t| t.as_array()).cloned().unwrap_or_default())+ }++ /// Flattened attach; returns the sessionId every page-level command needs.+ pub fn attach(&self, target_id: &str) -> Result<String, CdpError> {+ let r = self.send(None, "Target.attachToTarget", json!({ "targetId": target_id, "flatten": true }), Duration::from_secs(10))?;+ r.get("sessionId").and_then(|s| s.as_str()).map(|s| s.to_string()).ok_or_else(|| CdpError::Json("attachToTarget: no sessionId".into()))+ }++ pub fn detach(&self, session_id: &str) { let _ = self.send(None, "Target.detachFromTarget", json!({ "sessionId": session_id }), Duration::from_secs(5)); }++ /// A NEW OS WINDOW (only Target.createTarget accepts newWindow; Node used exactly this).+ pub fn create_target(&self, url: &str, new_window: bool, bounds: Option<(i32, i32, i32, i32)>) -> Result<String, CdpError> {+ let mut p = json!({ "url": url, "newWindow": new_window });+ if let Some((l, t, w, h)) = bounds { p["left"] = json!(l); p["top"] = json!(t); p["width"] = json!(w); p["height"] = json!(h); }+ let r = self.send(None, "Target.createTarget", p, Duration::from_secs(20))?;+ r.get("targetId").and_then(|s| s.as_str()).map(|s| s.to_string()).ok_or_else(|| CdpError::Json("createTarget: no targetId".into()))+ }++ pub fn close_target(&self, target_id: &str) -> Result<bool, CdpError> {+ let r = self.send(None, "Target.closeTarget", json!({ "targetId": target_id }), Duration::from_secs(10))?;+ Ok(r.get("success").and_then(|b| b.as_bool()).unwrap_or(true))+ }++ pub fn window_for_target(&self, target_id: &str) -> Result<(i64, Value), CdpError> {+ let r = self.send(None, "Browser.getWindowForTarget", json!({ "targetId": target_id }), Duration::from_secs(10))?;+ Ok((r.get("windowId").and_then(|w| w.as_i64()).unwrap_or(0), r.get("bounds").cloned().unwrap_or(Value::Null)))+ }++ pub fn set_window_bounds(&self, window_id: i64, bounds: Value) -> Result<(), CdpError> {+ self.send(None, "Browser.setWindowBounds", json!({ "windowId": window_id, "bounds": bounds }), Duration::from_secs(10)).map(|_| ())+ }++ pub fn page_enable(&self, session: &str) -> Result<(), CdpError> {+ self.send(Some(session), "Page.enable", json!({}), Duration::from_secs(10)).map(|_| ())?;+ self.send(Some(session), "Runtime.enable", json!({}), Duration::from_secs(10)).map(|_| ())+ }++ /// Navigate and wait for the load event (or the timeout, which is NOT an error: the page is+ /// still moving; callers report `loaded:false`).+ pub fn navigate(&self, session: &str, url: &str, wait: Duration) -> Result<(String, bool), CdpError> {+ let rx = self.subscribe(Some(session), Some("Page.loadEventFired"));+ let r = self.send(Some(session), "Page.navigate", json!({ "url": url }), Duration::from_secs(30))?;+ if let Some(e) = r.get("errorText").and_then(|e| e.as_str()) { if !e.is_empty() { return Err(CdpError::Protocol { method: "Page.navigate".into(), code: 0, message: e.into() }); } }+ let frame = r.get("frameId").and_then(|f| f.as_str()).unwrap_or("").to_string();+ let loaded = Cdp::wait_event(&rx, wait).is_some();+ Ok((frame, loaded))+ }++ /// Runtime.evaluate with the devtools-console semantics the Node build used: expression+ /// first (returnByValue, awaitPromise), the error surfaced as `__error` like pup_eval did.+ pub fn evaluate(&self, session: &str, expression: &str, timeout: Duration) -> Result<Value, CdpError> {+ let r = self.send(Some(session), "Runtime.evaluate", json!({ "expression": expression, "returnByValue": true, "awaitPromise": true, "userGesture": true, "replMode": true }), timeout)?;+ if let Some(ex) = r.get("exceptionDetails") {+ let desc = ex.get("exception").and_then(|e| e.get("description")).and_then(|d| d.as_str()).or_else(|| ex.get("text").and_then(|t| t.as_str())).unwrap_or("evaluation threw");+ return Ok(json!({ "__error": desc }));+ }+ Ok(r.get("result").and_then(|x| x.get("value")).cloned().unwrap_or(Value::Null))+ }++ /// PNG bytes (base64-decoded) of the viewport or the full page.+ pub fn screenshot_png(&self, session: &str, full_page: bool, timeout: Duration) -> Result<Vec<u8>, CdpError> {+ let mut p = json!({ "format": "png", "captureBeyondViewport": full_page });+ if full_page {+ if let Ok(m) = self.send(Some(session), "Page.getLayoutMetrics", json!({}), Duration::from_secs(10)) {+ let cs = m.get("cssContentSize").or_else(|| m.get("contentSize")).cloned().unwrap_or(Value::Null);+ let (w, h) = (cs.get("width").and_then(|x| x.as_f64()).unwrap_or(0.0), cs.get("height").and_then(|x| x.as_f64()).unwrap_or(0.0));+ if w > 0.0 && h > 0.0 { p["clip"] = json!({ "x": 0, "y": 0, "width": w, "height": h, "scale": 1 }); }+ }+ }+ let r = self.send(Some(session), "Page.captureScreenshot", p, timeout)?;+ let b64 = r.get("data").and_then(|d| d.as_str()).ok_or_else(|| CdpError::Json("captureScreenshot: no data".into()))?;+ base64_decode(b64).map_err(|e| CdpError::Json(format!("screenshot base64: {e}")))+ }++ pub fn target_info(&self, session: &str) -> Result<Value, CdpError> {+ let r = self.send(Some(session), "Target.getTargetInfo", json!({}), Duration::from_secs(5))?;+ Ok(r.get("targetInfo").cloned().unwrap_or(Value::Null))+ }++ pub fn close(&self) {+ self.inner.alive.store(false, Ordering::Relaxed);+ if let Ok(mut s) = self.inner.sock.lock() { let _ = s.close(None); }+ }+}++fn reader_loop(inner: Arc<Inner>) {+ let mut idle = 0u32;+ while inner.alive.load(Ordering::Relaxed) {+ let msg = {+ let mut sock = match inner.sock.lock() { Ok(s) => s, Err(_) => break };+ match sock.read() {+ Ok(m) => Some(m),+ Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => None,+ Err(_) => { inner.alive.store(false, Ordering::Relaxed); break; }+ }+ };+ match msg {+ Some(Message::Text(t)) => {+ idle = 0;+ if let Ok(v) = serde_json::from_str::<Value>(&t) {+ if let Some(id) = v.get("id").and_then(|i| i.as_u64()) {+ if let Some(tx) = inner.pending.lock().unwrap().remove(&id) { let _ = tx.send(v); }+ } else if let Some(method) = v.get("method").and_then(|m| m.as_str()) {+ let ev = Event { method: method.to_string(), params: v.get("params").cloned().unwrap_or(Value::Null), session_id: v.get("sessionId").and_then(|s| s.as_str()).map(|s| s.to_string()) };+ let mut subs = inner.subs.lock().unwrap();+ subs.retain(|s| {+ let want = s.session.as_deref().map_or(true, |x| Some(x) == ev.session_id.as_deref()) && s.method.as_deref().map_or(true, |m| m == ev.method);+ if !want { return true; }+ s.tx.send(ev.clone()).is_ok()+ });+ }+ }+ }+ Some(Message::Close(_)) => { inner.alive.store(false, Ordering::Relaxed); break; }+ Some(_) => {}+ None => { idle = idle.saturating_add(1); if idle > 4 { std::thread::sleep(Duration::from_millis(3)); } }+ }+ }+ // wake every waiter so nobody blocks on a dead connection+ inner.pending.lock().unwrap().clear();+}++/// Minimal base64 (standard alphabet) decoder, so the crate needs no extra dependency.+pub fn base64_decode(s: &str) -> Result<Vec<u8>, String> {+ fn val(c: u8) -> Result<u32, String> {+ match c { b'A'..=b'Z' => Ok((c - b'A') as u32), b'a'..=b'z' => Ok((c - b'a' + 26) as u32), b'0'..=b'9' => Ok((c - b'0' + 52) as u32), b'+' => Ok(62), b'/' => Ok(63), _ => Err(format!("bad base64 byte {c}")) }+ }+ let bytes: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();+ let mut out = Vec::with_capacity(bytes.len() * 3 / 4);+ let mut chunk = [0u32; 4];+ let mut n = 0;+ let mut pad = 0;+ for &b in &bytes {+ if b == b'=' { pad += 1; chunk[n] = 0; n += 1; } else { chunk[n] = val(b)?; n += 1; }+ if n == 4 {+ let v = (chunk[0] << 18) | (chunk[1] << 12) | (chunk[2] << 6) | chunk[3];+ out.push((v >> 16) as u8);+ if pad < 2 { out.push((v >> 8) as u8); }+ if pad < 1 { out.push(v as u8); }+ n = 0;+ }+ }+ Ok(out)+}++#[allow(dead_code)]+fn _unused(_: Instant) {}++#[cfg(test)]+mod tests {+ use super::*;+ #[test] fn b64() { assert_eq!(base64_decode("aGVsbG8=").unwrap(), b"hello"); assert_eq!(base64_decode("aGk=").unwrap(), b"hi"); assert_eq!(base64_decode("aGVsbG8gd29ybGQ=").unwrap(), b"hello world"); }+}
rust/core/Cargo.tomladded+12@@ -0,0 +1,12 @@+[package]+name = "pup-core"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "Types, settings, the response envelope, caller identity and the AbClient trait. No OS calls."++[dependencies]+serde.workspace = true+serde_json.workspace = true+thiserror.workspace = true+ureq.workspace = true
rust/core/src/lib.rsadded+221@@ -0,0 +1,221 @@+//! pup-core: types, settings, the response envelope, caller identity and the AbClient trait.+//! No OS calls live here; everything desktop-shaped goes through `AbClient` (ab's verbs) or+//! the `pup-host` traits. This crate is the root of the dependency tree and changes rarely.++use serde_json::{json, Map, Value};+use std::path::{Path, PathBuf};+use std::time::Duration;++/// The bridge version, in lockstep with src/BRIDGE_VERSION (tools/ship.sh bumps both).+pub const VERSION_RAW: &str = include_str!("../../../src/BRIDGE_VERSION");+pub fn version() -> &'static str { VERSION_RAW.trim() }++// ─── Caller identity (ab stamps it on every inbound verb) ───────────────────────────────++/// Who this verb is acting FOR. ab forwards the caller's identity as X-Adom-Caller-* headers and+/// stamps the transport it verified in X-Adom-Verified-Origin (relay | local | bridge).+#[derive(Debug, Clone, Default)]+pub struct Caller {+ pub thread: String,+ pub container: String,+ pub reason: String,+ pub delegate: String,+ pub verified_origin: String,+}++impl Caller {+ pub fn from_headers<'a>(headers: impl Iterator<Item = (&'a str, &'a str)>) -> Caller {+ let mut c = Caller::default();+ for (k, v) in headers {+ match k.to_ascii_lowercase().as_str() {+ "x-adom-caller-thread" => c.thread = v.to_string(),+ "x-adom-caller-container" => c.container = v.to_string(),+ "x-adom-caller-reason" => c.reason = v.to_string(),+ "x-adom-caller-delegate" => c.delegate = v.to_string(),+ "x-adom-verified-origin" => c.verified_origin = v.to_string(),+ _ => {}+ }+ }+ c+ }+ pub fn is_relayed(&self) -> bool { self.verified_origin == "relay" || !self.container.is_empty() }+}++// ─── The response envelope (the contract the Node build established) ────────────────────++/// Success: `{ok:true, status:"ok", ...fields}`.+pub fn ok(fields: Value) -> Value {+ let mut m = Map::new();+ m.insert("ok".into(), json!(true));+ m.insert("status".into(), json!("ok"));+ if let Value::Object(o) = fields { for (k, v) in o { m.insert(k, v); } }+ Value::Object(m)+}++/// Failure: `{success:false, status:"error", errorCode, error, _hint?}`.+pub fn err(code: &str, error: impl Into<String>, hint: Option<&str>) -> Value {+ let mut v = json!({ "success": false, "status": "error", "errorCode": code, "error": error.into() });+ if let Some(h) = hint { v["_hint"] = json!(h); }+ v+}++/// The blocked-on-a-human shape (ab BRIDGE_SDK 2.1.30 rule, pup-bridge#92): never a bare timeout.+pub fn blocked_on_user(still_running: bool, blocked_by: &str, hint: &str, extra: Value) -> Value {+ let mut v = json!({ "success": false, "status": "error", "errorCode": "blocked_on_user", "stillRunning": still_running, "blockedBy": blocked_by, "_hint": hint });+ if let Value::Object(o) = extra { for (k, x) in o { v[k] = x; } }+ v+}++pub fn merge(mut base: Value, extra: Value) -> Value {+ if let (Some(b), Value::Object(e)) = (base.as_object_mut(), extra) { for (k, v) in e { b.insert(k, v); } }+ base+}++// ─── AbClient: the only way pup touches the desktop ─────────────────────────────────────++#[derive(Debug, thiserror::Error)]+pub enum AbError {+ #[error("ab loopback API unreachable: {0}")]+ Unreachable(String),+ #[error("verb {verb} refused: {code}: {message}")]+ Refused { verb: String, code: String, message: String },+ #[error("verb {0} failed: {1}")]+ Failed(String, String),+}++pub trait AbClient: Send + Sync {+ fn call(&self, verb: &str, args: Value, caller: &Caller, timeout: Duration) -> Result<Value, AbError>;+ /// The ab version this bridge is talking to, if known (bridge_info).+ fn version(&self) -> Option<String> { None }+}++/// The loopback direct-API client (identical wire contract to the Node build's abCommand).+pub struct Loopback { pub base: String, pub token: String }++impl Loopback {+ pub fn from_env() -> Option<Loopback> {+ let mut base = std::env::var("ADOM_DIRECT_API_URL").unwrap_or_default().trim().trim_end_matches('/').to_string();+ if base.is_empty() {+ let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default();+ if let Ok(port) = std::fs::read_to_string(Path::new(&home).join(".adom").join("direct-api-port")) {+ let port = port.trim();+ if !port.is_empty() { base = format!("http://127.0.0.1:{port}"); }+ }+ }+ if base.is_empty() { return None; }+ Some(Loopback { base, token: std::env::var("ADOM_BRIDGE_TOKEN").unwrap_or_default() })+ }+}++/// ab wraps a bridge verb's JSON in an envelope whose `output` is a JSON STRING; unwrap it, keeping+/// the envelope's own keys (inner wins on a clash).+pub fn unwrap_output(mut v: Value) -> Value {+ if let Some(inner) = v.get("output").and_then(|o| o.as_str()) {+ if inner.trim_start().starts_with('{') {+ if let Ok(mut parsed) = serde_json::from_str::<Value>(inner) {+ if let (Some(po), Some(eo)) = (parsed.as_object_mut(), v.as_object()) {+ for (k, val) in eo { if k != "output" { po.entry(k.clone()).or_insert(val.clone()); } }+ }+ v = parsed;+ }+ }+ }+ v+}++impl AbClient for Loopback {+ fn call(&self, verb: &str, args: Value, caller: &Caller, timeout: Duration) -> Result<Value, AbError> {+ let agent = ureq::AgentBuilder::new().timeout(timeout.max(Duration::from_secs(1))).build();+ let mut req = agent.post(&format!("{}/command", self.base))+ .set("Content-Type", "application/json")+ .set("X-Adom-Caller-Delegate", "puppeteer");+ if !self.token.is_empty() { req = req.set("X-Adom-Bridge-Token", &self.token); }+ if !caller.thread.is_empty() { req = req.set("X-Adom-Caller-Thread", &caller.thread); }+ if !caller.container.is_empty() { req = req.set("X-Adom-Caller-Container", &caller.container); }+ if !caller.reason.is_empty() { req = req.set("X-Adom-Caller-Reason", &caller.reason); }+ let resp = req.send_json(json!({"command": verb, "args": args})).map_err(|e| AbError::Unreachable(e.to_string()))?;+ let v: Value = resp.into_json().map_err(|e| AbError::Failed(verb.into(), e.to_string()))?;+ let v = unwrap_output(v);+ if v.get("success") == Some(&Value::Bool(false)) || v.get("status") == Some(&Value::String("error".into())) {+ let code = v.get("errorCode").and_then(|c| c.as_str()).unwrap_or("error").to_string();+ let message = v.get("error").and_then(|c| c.as_str()).unwrap_or("").to_string();+ return Err(AbError::Refused { verb: verb.into(), code, message });+ }+ Ok(v)+ }+}++/// No ab at all (hand-launched exe): every desktop verb reports unreachable, nothing panics.+pub struct NoAb;+impl AbClient for NoAb {+ fn call(&self, _verb: &str, _args: Value, _caller: &Caller, _timeout: Duration) -> Result<Value, AbError> {+ Err(AbError::Unreachable("ADOM_DIRECT_API_URL not set (bridge launched outside ab)".into()))+ }+}++// ─── Settings (~/.adom/pup-settings.json, same file and keys as the Node build) ─────────++pub struct Settings { path: PathBuf, cache: std::sync::Mutex<Option<Value>> }++impl Settings {+ pub fn new(state_dir: &Path) -> Settings { Settings { path: state_dir.join("pup-settings.json"), cache: std::sync::Mutex::new(None) } }+ pub fn all(&self) -> Value {+ let mut c = self.cache.lock().unwrap();+ if c.is_none() {+ let v = std::fs::read_to_string(&self.path).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).unwrap_or(json!({}));+ *c = Some(if v.is_object() { v } else { json!({}) });+ }+ c.clone().unwrap()+ }+ /// `"on"` / `"off"` style keys; a missing key returns the default the Node build uses.+ pub fn get(&self, key: &str, default: &str) -> String {+ self.all().get(key).and_then(|v| v.as_str().map(|s| s.to_string()).or_else(|| v.as_bool().map(|b| if b { "on".into() } else { "off".into() }))).unwrap_or_else(|| default.to_string())+ }+ pub fn set(&self, key: &str, value: Value) -> std::io::Result<()> {+ let mut all = self.all();+ all[key] = value;+ std::fs::create_dir_all(self.path.parent().unwrap_or(Path::new(".")))?;+ std::fs::write(&self.path, serde_json::to_string_pretty(&all).unwrap_or_default())?;+ *self.cache.lock().unwrap() = Some(all);+ Ok(())+ }+}++// ─── Logging (stdout, ab tails it into ~/.adom/bridge-logs/puppeteer.log) ───────────────++pub fn log(line: impl AsRef<str>) { println!("{}", line.as_ref()); }++pub fn now_ms() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) }++pub fn iso_now() -> String {+ // No chrono dependency: ISO-8601 UTC from the unix clock.+ let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);+ let days = secs / 86400; let rem = secs % 86400;+ let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);+ // civil-from-days (Howard Hinnant)+ let z = days as i64 + 719468; let era = z.div_euclid(146097); let doe = z.rem_euclid(146097);+ let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153;+ let d = doy - (153 * mp + 2) / 5 + 1; let mo = if mp < 10 { mp + 3 } else { mp - 9 };+ let y = if mo <= 2 { y + 1 } else { y };+ format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}.000Z")+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test] fn envelope_shapes() {+ let o = ok(json!({"a": 1})); assert_eq!(o["ok"], json!(true)); assert_eq!(o["status"], json!("ok")); assert_eq!(o["a"], json!(1));+ let e = err("x", "boom", Some("h")); assert_eq!(e["success"], json!(false)); assert_eq!(e["errorCode"], json!("x")); assert_eq!(e["_hint"], json!("h"));+ let b = blocked_on_user(true, "click", "wait", json!({"consentId": "c1"})); assert_eq!(b["errorCode"], json!("blocked_on_user")); assert_eq!(b["stillRunning"], json!(true)); assert_eq!(b["consentId"], json!("c1"));+ }+ #[test] fn unwraps_output() {+ let v = unwrap_output(json!({"output": "{\"ok\":true,\"x\":1}", "hwnd": 5, "x": 9}));+ assert_eq!(v["x"], json!(1)); assert_eq!(v["hwnd"], json!(5));+ }+ #[test] fn caller_headers() {+ let c = Caller::from_headers([("X-Adom-Caller-Thread", "t1"), ("x-adom-verified-origin", "local")].into_iter());+ assert_eq!(c.thread, "t1"); assert_eq!(c.verified_origin, "local"); assert!(!c.is_relayed());+ }+ #[test] fn iso() { assert!(iso_now().ends_with("Z")); assert_eq!(iso_now().len(), 24); }+}
rust/dashboard/Cargo.tomladded+11@@ -0,0 +1,11 @@+[package]+name = "pup-dashboard"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "The HTTP routes under /dashboard, /health, /status, /identity, the Hydrogen proxy routes, static assets via include_str!."++[dependencies]+serde_json.workspace = true+pup-core = { path = "../core" }+pup-verbs = { path = "../verbs" }
rust/dashboard/src/lib.rsadded+50@@ -0,0 +1,50 @@+//! pup-dashboard: the HTTP routes that are not verbs. Phase 1: /health and /status (the chip+//! contract ab renders verbatim: led / summary / tooltip), built from what the verbs crate knows.+//! The dashboard page, its SSE feed and the Hydrogen proxy routes land in Phase 2 as embedded+//! assets (include_str!) behind the same function-per-route shape.++use pup_verbs::Ctx;+use serde_json::{json, Value};++fn session_rows(ctx: &Ctx) -> Vec<Value> {+ let active = ctx.sessions.active_id();+ ctx.sessions.all().iter().map(|s| json!({ "sessionId": s.id, "url": s.active().map(|t| t.url.clone()), "active": active.as_deref() == Some(&s.id), "ownerThread": s.owner, "view": Value::Null, "authed": Value::Null })).collect()+}++/// GET /health (Node: the /health branch): what the toolbar extension and ab's probes read.+pub fn health(ctx: &Ctx) -> Value {+ let st = &ctx.settings;+ json!({+ "status": "ok", "bridge": "puppeteer", "version": pup_core::version(), "shell": "rust", "port": ctx.port,+ "toolbar": { "enabled": st.get("toolbarExtension", "on") != "off", "ownerBadge": st.get("tbOwnerBadge", "on") != "off", "snipHotkey": st.get("tbSnipHotkey", "on") != "off", "floaty": st.get("tbFloatyPencil", "off") == "on" },+ "browserCount": ctx.browsers.lock().unwrap().len(),+ "sessionCount": ctx.sessions.count(), "activeSession": ctx.sessions.active_id(), "sessions": session_rows(ctx),+ "chrome": ctx.installer.readiness(&*ctx.host, ctx.prefer_cft()),+ })+}++/// GET /status: the status-chip contract (led/summary/tooltip verbatim in ab's UI).+pub fn status(ctx: &Ctx) -> Value {+ let rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());+ let v = pup_core::version();+ let n = ctx.sessions.count();+ let (led, summary, tooltip) = if rd["installPhase"] == json!("failed") {+ ("red", "Chrome install failed".to_string(), format!("Puppeteer bridge v{v} (rust)\nChrome for Testing install FAILED: {}\nRetry pup_prewarm (usually a network/proxy or disk issue).", rd["lastError"].as_str().unwrap_or("?")))+ } else if rd["installing"] == json!(true) {+ let pct = rd["installProgressPct"].as_u64().unwrap_or(0);+ let phase = if rd["installPhase"] == json!("unpacking") { "Unpacking".to_string() } else { format!("Installing Chrome {pct}%") };+ ("yellow", phase.clone(), format!("Puppeteer bridge v{v} (rust)\n{} — pup not ready yet. Poll pup_readiness until ready.", if rd["installPhase"] == json!("unpacking") { "Unpacking Chrome for Testing".to_string() } else { format!("Downloading Chrome for Testing ({pct}%)") }))+ } else if rd["ready"] != json!(true) {+ ("yellow", "No browser yet".to_string(), format!("Puppeteer bridge v{v} (rust)\nNo Chrome/Edge detected and Chrome for Testing not cached — pup will fetch CfT on first use (or call pup_prewarm)."))+ } else {+ let kind = rd["browserKind"].as_str().unwrap_or("?"); let src = rd["browserSource"].as_str().unwrap_or("?");+ ("green", format!("{n} window{} · {kind}", if n == 1 { "" } else { "s" }), format!("Puppeteer bridge v{v} (rust) — ready\nDrives {kind} ({src}) · {n} session(s){}", if rd["chromeForTestingInstalled"] == json!(true) { format!("\nCfT {} cached", rd["chromeBuildId"].as_str().unwrap_or("")) } else { String::new() }))+ };+ let list: Vec<Value> = ctx.sessions.all().iter().map(|s| json!({ "sessionId": s.id, "url": s.active().map(|t| t.url.clone()), "active": ctx.sessions.active_id().as_deref() == Some(&s.id) })).collect();+ json!({+ "ok": true, "bridge": "puppeteer", "version": v, "shell": "rust", "ready": rd["ready"], "warming": false,+ "led": led, "summary": summary, "tooltip": tooltip,+ "sessionCount": n, "activeSession": ctx.sessions.active_id(), "sessions": list, "chrome": rd,+ "adVersion": ctx.ad_version.lock().unwrap().clone(), "uptimeSec": ctx.started.elapsed().as_secs(),+ })+}
rust/host-mac/Cargo.tomladded+9@@ -0,0 +1,9 @@+[package]+name = "pup-host-mac"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "The macOS implementation of pup-host. Kyle's crate: every method is a todo!() naming the Windows counterpart."++[dependencies]+pup-host = { path = "../host" }
rust/host-mac/src/lib.rsadded+41@@ -0,0 +1,41 @@+//! pup-host-mac: the macOS host, for Kyle to implement. Same traits as pup-host-win; every+//! `todo!()` names what the Windows crate does for that method, so the port is a translation,+//! not a rediscovery. `cargo check --target aarch64-apple-darwin -p pup-host-mac` runs every ship.+//!+//! What macOS does NOT need to implement: anything taskbar-shaped (Dock identity, badges, jump+//! lists) — that is ab's job through `AbClient`. pup only needs windows found, kept out of the+//! user's way (the Windows "bottom of the z-order, never activated" is `NSApp hide` / ordering+//! the window back on macOS), and the browser spawned detached.++use pup_host::{HostPaths, HostProcess, HostWindow, HostWindows};+use std::path::{Path, PathBuf};++pub struct MacHost;++impl HostWindows for MacHost {+ fn windows_for_pids(&self, _pids: &[u32], _class_filter: Option<&str>) -> Vec<HostWindow> {+ todo!("CGWindowListCopyWindowInfo filtered by kCGWindowOwnerPID (Windows: EnumWindows + GetWindowThreadProcessId, class Chrome_WidgetWin_1)")+ }+ fn is_alive(&self, _hwnd: i64) -> bool { todo!("window id still present in CGWindowListCopyWindowInfo (Windows: IsWindow)") }+ fn tag(&self, _hwnd: i64, _tag: &str) -> bool { todo!("no per-window property store on macOS: keep a pid->session map in the state dir instead (Windows: SetProp \"AdomPupSid=<sid>\")") }+ fn has_tag(&self, _hwnd: i64, _tag: &str) -> bool { todo!("see tag() (Windows: GetProp)") }+ fn send_to_bottom_no_activate(&self, _hwnd: i64) -> bool { todo!("order the window below without key/main (NSWindow orderBack: via the app's accessibility API, or hide the browser app: the Cmd+H state) (Windows: SetWindowPos HWND_BOTTOM | SWP_NOACTIVATE)") }+ fn set_bounds_no_activate(&self, _hwnd: i64, _l: i32, _t: i32, _w: i32, _h: i32) -> bool { todo!("AXUIElementSetAttributeValue kAXPosition/kAXSize (Windows: SetWindowPos SWP_NOACTIVATE | SWP_NOZORDER)") }+ fn foreground(&self) -> i64 { todo!("frontmost app's key window via NSWorkspace + AX (Windows: GetForegroundWindow)") }+ fn work_area(&self) -> Option<(i32, i32, i32, i32)> { todo!("NSScreen.main.visibleFrame (Windows: SystemParametersInfo SPI_GETWORKAREA)") }+}++impl HostProcess for MacHost {+ fn alive(&self, _pid: u32) -> bool { todo!("kill(pid, 0) == 0 (Windows: OpenProcess + GetExitCodeProcess == STILL_ACTIVE)") }+ fn spawn_detached(&self, _exe: &Path, _args: &[String], _cwd: Option<&Path>) -> std::io::Result<u32> { todo!("posix_spawn with setsid so the browser survives a bridge restart (Windows: DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)") }+ fn kill(&self, _pid: u32) -> bool { todo!("kill(pid, SIGKILL) (Windows: TerminateProcess)") }+}++impl HostPaths for MacHost {+ fn home(&self) -> PathBuf { PathBuf::from(std::env::var("HOME").unwrap_or_default()) }+ fn browser_roots(&self) -> Vec<PathBuf> { vec![PathBuf::from("/Applications")] }+ fn cft_platform(&self) -> &'static str { if cfg!(target_arch = "aarch64") { "mac-arm64" } else { "mac-x64" } }+ fn cft_exe_relative(&self) -> PathBuf {+ PathBuf::from(format!("chrome-{}", self.cft_platform())).join("Google Chrome for Testing.app").join("Contents").join("MacOS").join("Google Chrome for Testing")+ }+}
rust/host-win/Cargo.tomladded+19@@ -0,0 +1,19 @@+[package]+name = "pup-host-win"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "The Windows implementation of pup-host (windows-sys). The ONLY crate with Win32 code."++[dependencies]+pup-host = { path = "../host" }++[target.'cfg(windows)'.dependencies]+windows-sys = { version = "0.59", features = [+ "Win32_Foundation",+ "Win32_UI_WindowsAndMessaging",+ "Win32_UI_HiDpi",+ "Win32_System_Threading",+ "Win32_System_ProcessStatus",+ "Win32_Graphics_Gdi",+] }
rust/host-win/src/lib.rsadded+152@@ -0,0 +1,152 @@+//! pup-host-win: the Windows host. The ONLY crate in the workspace that may name windows_sys.+//! Everything here mirrors what src/server.js + src/win32.js did with koffi and PowerShell; the+//! matching Node function is named on each method so the behaviour can be cross-checked.++use pup_host::{HostPaths, HostProcess, HostWindow, HostWindows};+use std::path::{Path, PathBuf};++pub struct WinHost;++#[cfg(windows)]+mod imp {+ use super::*;+ use std::ffi::c_void;+ use windows_sys::Win32::Foundation::{BOOL, HWND, LPARAM, RECT, STILL_ACTIVE};+ use windows_sys::Win32::System::Threading::{GetExitCodeProcess, OpenProcess, TerminateProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE};+ use windows_sys::Win32::UI::HiDpi::GetDpiForWindow;+ use windows_sys::Win32::UI::WindowsAndMessaging::{+ EnumWindows, GetClassNameW, GetForegroundWindow, GetPropW, GetWindowRect, GetWindowTextW, GetWindowThreadProcessId, IsWindow, IsWindowVisible,+ SetPropW, SetWindowPos, SystemParametersInfoW, HWND_BOTTOM, SPI_GETWORKAREA, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER,+ };++ fn wide(s: &str) -> Vec<u16> { s.encode_utf16().chain(std::iter::once(0)).collect() }++ struct EnumCtx { pids: Vec<u32>, class: Option<String>, out: Vec<HostWindow> }++ unsafe extern "system" fn enum_cb(hwnd: HWND, lp: LPARAM) -> BOOL {+ let ctx = &mut *(lp as *mut EnumCtx);+ let mut pid: u32 = 0;+ GetWindowThreadProcessId(hwnd, &mut pid);+ if !ctx.pids.is_empty() && !ctx.pids.contains(&pid) { return 1; }+ let mut cls = [0u16; 128];+ let n = GetClassNameW(hwnd, cls.as_mut_ptr(), cls.len() as i32);+ let class = String::from_utf16_lossy(&cls[..n.max(0) as usize]);+ if let Some(want) = &ctx.class { if &class != want { return 1; } }+ let visible = IsWindowVisible(hwnd) != 0;+ if !visible { return 1; }+ let mut t = [0u16; 512];+ let tn = GetWindowTextW(hwnd, t.as_mut_ptr(), t.len() as i32);+ let title = String::from_utf16_lossy(&t[..tn.max(0) as usize]);+ let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 };+ GetWindowRect(hwnd, &mut r);+ ctx.out.push(HostWindow { hwnd: hwnd as i64, pid, title, class, rect: (r.left, r.top, r.right, r.bottom), visible });+ 1+ }++ impl HostWindows for WinHost {+ // Node: enumChromeWidgetWindows (via ab desktop_enum_windows) / win32.findChromeWindowByTitle+ fn windows_for_pids(&self, pids: &[u32], class_filter: Option<&str>) -> Vec<HostWindow> {+ let mut ctx = EnumCtx { pids: pids.to_vec(), class: class_filter.map(|s| s.to_string()), out: Vec::new() };+ unsafe { EnumWindows(Some(enum_cb), &mut ctx as *mut EnumCtx as LPARAM); }+ ctx.out+ }+ // Node: win32.isWindow / osHwndAlive+ fn is_alive(&self, hwnd: i64) -> bool { unsafe { IsWindow(hwnd as HWND) != 0 } }+ // Node: tagWindowSession (SetProp "AdomPupSid=<sid>" = 1)+ fn tag(&self, hwnd: i64, tag: &str) -> bool { unsafe { SetPropW(hwnd as HWND, wide(tag).as_ptr(), 1 as *mut c_void) != 0 } }+ // Node: win32.hasProp+ fn has_tag(&self, hwnd: i64, tag: &str) -> bool { unsafe { !GetPropW(hwnd as HWND, wide(tag).as_ptr()).is_null() } }+ // Node: osBottomUnlessForeground's in-process leg (SetWindowPos HWND_BOTTOM, NOACTIVATE)+ fn send_to_bottom_no_activate(&self, hwnd: i64) -> bool {+ unsafe { SetWindowPos(hwnd as HWND, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE) != 0 }+ }+ // Node: healOffscreenWindow / setSessionWindowBounds (SetWindowPos 0x14 = NOACTIVATE|NOZORDER)+ fn set_bounds_no_activate(&self, hwnd: i64, left: i32, top: i32, width: i32, height: i32) -> bool {+ unsafe { SetWindowPos(hwnd as HWND, std::ptr::null_mut(), left, top, width, height, SWP_NOACTIVATE | SWP_NOZORDER) != 0 }+ }+ // Node: osGetForegroundWindow+ fn foreground(&self) -> i64 { unsafe { GetForegroundWindow() as i64 } }+ // Node: primaryWorkArea (SystemParametersInfo SPI_GETWORKAREA)+ fn work_area(&self) -> Option<(i32, i32, i32, i32)> {+ let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 };+ let ok = unsafe { SystemParametersInfoW(SPI_GETWORKAREA, 0, &mut r as *mut RECT as *mut c_void, 0) } != 0;+ if !ok { return None; }+ let (w, h) = (r.right - r.left, r.bottom - r.top);+ if w > 400 && h > 300 { Some((r.left, r.top, w, h)) } else { None }+ }+ fn dpi_scale(&self, hwnd: i64) -> f64 { let d = unsafe { GetDpiForWindow(hwnd as HWND) }; if d == 0 { 1.0 } else { d as f64 / 96.0 } }+ }++ impl HostProcess for WinHost {+ // Node: isProcessAlive+ fn alive(&self, pid: u32) -> bool {+ unsafe {+ let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);+ if h.is_null() { return false; }+ let mut code: u32 = 0;+ let ok = GetExitCodeProcess(h, &mut code) != 0;+ windows_sys::Win32::Foundation::CloseHandle(h);+ ok && code == STILL_ACTIVE as u32+ }+ }+ // Node: the detached puppeteer.launch path (Chrome must outlive the bridge for recovery)+ fn spawn_detached(&self, exe: &Path, args: &[String], cwd: Option<&Path>) -> std::io::Result<u32> {+ use std::os::windows::process::CommandExt;+ const DETACHED_PROCESS: u32 = 0x0000_0008;+ const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;+ let mut c = std::process::Command::new(exe);+ c.args(args).creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)+ .stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());+ if let Some(d) = cwd { c.current_dir(d); }+ let child = c.spawn()?;+ Ok(child.id())+ }+ // Node: killBrowserProcess (taskkill /F)+ fn kill(&self, pid: u32) -> bool {+ unsafe {+ let h = OpenProcess(PROCESS_TERMINATE, 0, pid);+ if h.is_null() { return false; }+ let ok = TerminateProcess(h, 1) != 0;+ windows_sys::Win32::Foundation::CloseHandle(h);+ ok+ }+ }+ }+}++#[cfg(not(windows))]+mod imp {+ // Cross-compiled checks on Linux: the Windows impl is cfg'd out; these stubs let the crate+ // build on a dev box. The release binary is always the cfg(windows) side above.+ use super::*;+ impl HostWindows for WinHost {+ fn windows_for_pids(&self, _p: &[u32], _c: Option<&str>) -> Vec<HostWindow> { Vec::new() }+ fn is_alive(&self, _h: i64) -> bool { false }+ fn tag(&self, _h: i64, _t: &str) -> bool { false }+ fn has_tag(&self, _h: i64, _t: &str) -> bool { false }+ fn send_to_bottom_no_activate(&self, _h: i64) -> bool { false }+ fn set_bounds_no_activate(&self, _h: i64, _l: i32, _t: i32, _w: i32, _hh: i32) -> bool { false }+ fn foreground(&self) -> i64 { 0 }+ fn work_area(&self) -> Option<(i32, i32, i32, i32)> { None }+ }+ impl HostProcess for WinHost {+ fn alive(&self, pid: u32) -> bool { Path::new(&format!("/proc/{pid}")).exists() }+ fn spawn_detached(&self, exe: &Path, args: &[String], cwd: Option<&Path>) -> std::io::Result<u32> {+ let mut c = std::process::Command::new(exe); c.args(args); if let Some(d) = cwd { c.current_dir(d); }+ Ok(c.spawn()?.id())+ }+ fn kill(&self, _pid: u32) -> bool { false }+ }+}++impl HostPaths for WinHost {+ fn home(&self) -> PathBuf { PathBuf::from(std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default()) }+ fn browser_roots(&self) -> Vec<PathBuf> {+ let mut v = Vec::new();+ for k in ["PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"] { if let Ok(p) = std::env::var(k) { if !p.is_empty() { v.push(PathBuf::from(p)); } } }+ if v.is_empty() { v.push(PathBuf::from("C:\\Program Files")); v.push(PathBuf::from("C:\\Program Files (x86)")); }+ v+ }+ fn cft_platform(&self) -> &'static str { "win64" }+ fn cft_exe_relative(&self) -> PathBuf { PathBuf::from("chrome-win64").join("chrome.exe") }+}
rust/host/Cargo.tomladded+11@@ -0,0 +1,11 @@+[package]+name = "pup-host"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "The OS traits ONLY (HostWindows, HostProcess, HostPaths, HostClipboard). No implementation, no cfg(windows)."++[dependencies]+serde.workspace = true+serde_json.workspace = true+thiserror.workspace = true
rust/host/src/lib.rsadded+74@@ -0,0 +1,74 @@+//! pup-host: the OS traits and NOTHING else. This crate is the border between pup and the+//! operating system, and the border between the Windows build and Kyle's macOS build.+//!+//! Rules (tools/check-borders.sh enforces them at ship time):+//! - no `cfg(windows)`, no `windows_sys`, no `osascript`, no PowerShell in this crate;+//! - `pup-verbs`, `pup-session`, `pup-identity` depend on THESE traits, never on an impl;+//! - only the binary picks the implementation, per target (like fusion's os-win / os-mac).+//!+//! Everything in the taskbar family (AUMID, jump lists, overlay badges, progress, flash, toasts)+//! is deliberately NOT here: it is ab's job (`AbClient` in pup-core), so a macOS host does not+//! have to invent Dock equivalents.++use std::path::{Path, PathBuf};++#[derive(Debug, Clone, Default, PartialEq)]+pub struct HostWindow {+ pub hwnd: i64,+ pub pid: u32,+ pub title: String,+ pub class: String,+ /// left, top, right, bottom in screen pixels+ pub rect: (i32, i32, i32, i32),+ pub visible: bool,+}++/// Top-level OS windows: find, tag, and z-order them WITHOUT ever activating one.+pub trait HostWindows: Send + Sync {+ /// Every visible top-level window owned by one of `pids` (Windows: class Chrome_WidgetWin_1+ /// when `class_filter` is given). Empty on platforms without native windows support yet.+ fn windows_for_pids(&self, pids: &[u32], class_filter: Option<&str>) -> Vec<HostWindow>;+ fn is_alive(&self, hwnd: i64) -> bool;+ /// Tag a window with a pup-owned marker (Windows: SetProp "AdomPupSid=<sid>") so it can be+ /// recognised after a bridge restart. Returns false when tagging is unsupported.+ fn tag(&self, hwnd: i64, tag: &str) -> bool;+ fn has_tag(&self, hwnd: i64, tag: &str) -> bool;+ /// Bottom of the z-order, never activated (Windows: SetWindowPos HWND_BOTTOM | SWP_NOACTIVATE).+ /// ab exposes the same as desktop_set_window_state {state:"bottom"}; this is the in-process+ /// fallback for the birth moment, when a round-trip would let the window flash on top.+ fn send_to_bottom_no_activate(&self, hwnd: i64) -> bool;+ /// Move/resize without activation or z-order change.+ fn set_bounds_no_activate(&self, hwnd: i64, left: i32, top: i32, width: i32, height: i32) -> bool;+ fn foreground(&self) -> i64;+ /// The primary monitor's work area (x, y, w, h), excluding the taskbar.+ fn work_area(&self) -> Option<(i32, i32, i32, i32)>;+ fn dpi_scale(&self, hwnd: i64) -> f64 { let _ = hwnd; 1.0 }+}++pub trait HostProcess: Send + Sync {+ fn alive(&self, pid: u32) -> bool;+ /// Spawn a browser DETACHED from this bridge (it must survive a bridge restart). Returns the pid.+ fn spawn_detached(&self, exe: &Path, args: &[String], cwd: Option<&Path>) -> std::io::Result<u32>;+ fn kill(&self, pid: u32) -> bool;+}++pub trait HostPaths: Send + Sync {+ fn home(&self) -> PathBuf;+ /// ~/.adom+ fn state_dir(&self) -> PathBuf { self.home().join(".adom") }+ /// Where Chrome for Testing lives. Same layout as puppeteer's cache so the two builds share it:+ /// <cache>/chrome/<platform>-<build>/chrome-<platform>/chrome(.exe)+ fn cft_cache_dir(&self) -> PathBuf { self.home().join(".cache").join("puppeteer") }+ fn temp_dir(&self) -> PathBuf { std::env::temp_dir() }+ /// Candidate install roots for system browsers (Program Files, Program Files (x86), LocalAppData+ /// on Windows; /Applications on macOS).+ fn browser_roots(&self) -> Vec<PathBuf>;+ /// The platform tag used in Chrome for Testing download URLs: win64 | mac-arm64 | mac-x64 | linux64.+ fn cft_platform(&self) -> &'static str;+ /// The executable inside an unpacked Chrome for Testing folder, relative to the versioned dir.+ fn cft_exe_relative(&self) -> PathBuf;+}++/// The whole host, as the binary hands it to every crate.+pub trait Host: HostWindows + HostProcess + HostPaths {}+impl<T: HostWindows + HostProcess + HostPaths> Host for T {}
rust/identity/Cargo.tomladded+10@@ -0,0 +1,10 @@+[package]+name = "pup-identity"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "Thread tiles, AUMID naming, badge composition and the taskbar-mode policy. Pure image code plus AbClient calls; no OS calls."++[dependencies]+serde_json.workspace = true+png.workspace = true
rust/identity/src/lib.rsadded+3@@ -0,0 +1,3 @@+//! pup-identity: Thread tiles, AUMID naming, badge composition and the taskbar-mode policy. Pure image code plus AbClient calls; no OS calls.+//!+//! Phase status: SKELETON (pup Rust port plan, docs/PLAN-rust-port.md).
rust/record/Cargo.tomladded+9@@ -0,0 +1,9 @@+[package]+name = "pup-record"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "Tab recording over CDP screencast and the Chrome screen-share recorder (pup_screen_record_*)."++[dependencies]+serde_json.workspace = true
rust/record/src/lib.rsadded+3@@ -0,0 +1,3 @@+//! pup-record: Tab recording over CDP screencast and the Chrome screen-share recorder (pup_screen_record_*).+//!+//! Phase status: SKELETON (pup Rust port plan, docs/PLAN-rust-port.md).
rust/session/Cargo.tomladded+12@@ -0,0 +1,12 @@+[package]+name = "pup-session"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "The window/tab model, ownership and takeover, session files, reconnect, rescan, recovery and the health loop. One implementation of each."++[dependencies]+serde.workspace = true+serde_json.workspace = true+thiserror.workspace = true+pup-core = { path = "../core" }
rust/session/src/lib.rsadded+116@@ -0,0 +1,116 @@+//! pup-session: the window/tab model and its on-disk session files. ONE place that knows what a+//! pup window is (Node had the truth spread over the sessions Map, session files, _hwnd caches+//! and six recovery paths). Phase 1 carries the model + files; reconnect/rescan/recovery and the+//! health loop land in Phase 2.++use serde_json::{json, Value};+use std::fs;+use std::path::{Path, PathBuf};+use std::sync::Mutex;++pub const WIN_PROP: &str = "AdomPupSid";++#[derive(Debug, Clone)]+pub struct Tab {+ pub tab_id: String,+ pub target_id: String,+ pub cdp_session: String,+ pub url: String,+ pub title: String,+ pub error_count: u32,+}++#[derive(Debug, Clone)]+pub struct Session {+ pub id: String,+ pub profile: String,+ pub owner: Option<String>,+ pub owner_container: Option<String>,+ pub created_ms: u64,+ pub created_iso: String,+ pub hwnd: Option<i64>,+ pub pid: Option<u32>,+ pub cdp_port: u16,+ pub tabs: Vec<Tab>,+ pub active_tab: usize,+ pub foreground: bool,+ pub app_id: Option<String>,+ pub last_agent_update_ms: u64,+ pub next_tab_seq: u32,+}++impl Session {+ pub fn new(id: &str, profile: &str, owner: Option<&str>, owner_container: Option<&str>, cdp_port: u16, pid: Option<u32>) -> Session {+ Session { id: id.into(), profile: profile.into(), owner: owner.map(|s| s.into()), owner_container: owner_container.filter(|s| !s.is_empty()).map(|s| s.into()), created_ms: pup_core::now_ms(), created_iso: pup_core::iso_now(), hwnd: None, pid, cdp_port, tabs: Vec::new(), active_tab: 0, foreground: false, app_id: None, last_agent_update_ms: pup_core::now_ms(), next_tab_seq: 1 }+ }+ pub fn next_tab_id(&mut self) -> String { let id = format!("tab-{}", self.next_tab_seq); self.next_tab_seq += 1; id }+ pub fn active(&self) -> Option<&Tab> { self.tabs.get(self.active_tab) }+ pub fn tab_by_id(&self, id: &str) -> Option<&Tab> { self.tabs.iter().find(|t| t.tab_id == id) }+ pub fn age_minutes(&self) -> u64 { pup_core::now_ms().saturating_sub(self.created_ms) / 60000 }+ pub fn tag_string(&self) -> String { format!("{}={}", WIN_PROP, sanitize_sid(&self.id)) }++ /// The on-disk shape (identical to the Node build's saveSessionFile, so either build can+ /// recover the other's windows).+ pub fn file_json(&self) -> Value {+ json!({+ "sessionId": self.id, "profile": self.profile, "url": self.active().map(|t| t.url.clone()),+ "pid": self.pid, "cdpPort": self.cdp_port, "foreground": self.foreground, "owner": self.owner,+ "appId": self.app_id, "_hwnd": self.hwnd, "createdAt": self.created_iso, "updatedAt": pup_core::iso_now(),+ "targetId": self.active().map(|t| t.target_id.clone()), "shell": "rust",+ })+ }+}++pub fn sanitize_sid(s: &str) -> String { s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '-' }).collect() }++/// A thread's own window is keyed on its --ai-thread name (Node: threadDefaultSessionId).+pub fn thread_default_session_id(thread: &str) -> String { let s = sanitize_sid(thread.trim()); if s.is_empty() { "default".into() } else { s } }++pub struct Sessions { list: Mutex<Vec<Session>>, active: Mutex<Option<String>>, pub dir: PathBuf }++impl Sessions {+ pub fn new(dir: &Path) -> Sessions { let _ = fs::create_dir_all(dir); Sessions { list: Mutex::new(Vec::new()), active: Mutex::new(None), dir: dir.to_path_buf() } }+ pub fn insert(&self, s: Session) { let id = s.id.clone(); self.list.lock().unwrap().retain(|x| x.id != id); self.list.lock().unwrap().push(s); *self.active.lock().unwrap() = Some(id); }+ pub fn get(&self, id: &str) -> Option<Session> { self.list.lock().unwrap().iter().find(|s| s.id == id).cloned() }+ pub fn exists(&self, id: &str) -> bool { self.get(id).is_some() }+ pub fn with_mut<R>(&self, id: &str, f: impl FnOnce(&mut Session) -> R) -> Option<R> { self.list.lock().unwrap().iter_mut().find(|s| s.id == id).map(f) }+ pub fn remove(&self, id: &str) -> Option<Session> {+ let mut l = self.list.lock().unwrap();+ let pos = l.iter().position(|s| s.id == id)?;+ let s = l.remove(pos);+ let mut a = self.active.lock().unwrap();+ if a.as_deref() == Some(id) { *a = l.last().map(|x| x.id.clone()); }+ Some(s)+ }+ pub fn all(&self) -> Vec<Session> { self.list.lock().unwrap().clone() }+ pub fn count(&self) -> usize { self.list.lock().unwrap().len() }+ pub fn active_id(&self) -> Option<String> { self.active.lock().unwrap().clone() }+ pub fn set_active(&self, id: &str) { *self.active.lock().unwrap() = Some(id.into()); }+ pub fn for_profile(&self, profile: &str) -> Vec<Session> { self.all().into_iter().filter(|s| s.profile == profile).collect() }+ pub fn owned_by(&self, thread: &str) -> Vec<Session> { self.all().into_iter().filter(|s| s.owner.as_deref() == Some(thread)).collect() }++ pub fn file_path(&self, id: &str) -> PathBuf { self.dir.join(format!("{}.json", sanitize_sid(id))) }+ pub fn save_file(&self, s: &Session) { let _ = fs::write(self.file_path(&s.id), serde_json::to_string_pretty(&s.file_json()).unwrap_or_default()); }+ pub fn delete_file(&self, id: &str) { let _ = fs::remove_file(self.file_path(id)); }+ /// Every session file on disk (Node and Rust builds share the folder and the shape).+ pub fn read_files(&self) -> Vec<Value> {+ let mut out = Vec::new();+ if let Ok(rd) = fs::read_dir(&self.dir) { for e in rd.flatten() { if e.path().extension().map(|x| x == "json").unwrap_or(false) { if let Ok(s) = fs::read_to_string(e.path()) { if let Ok(v) = serde_json::from_str::<Value>(&s) { out.push(v); } } } } }+ out+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test] fn ids() { assert_eq!(thread_default_session_id("my thread/x"), "my-thread-x"); assert_eq!(thread_default_session_id(" "), "default"); }+ #[test] fn files_roundtrip() {+ let dir = std::env::temp_dir().join(format!("pup-sess-{}", std::process::id())); let ss = Sessions::new(&dir);+ let mut s = Session::new("t1", "adom-you", Some("thread"), None, 9222, Some(42));+ let tid = s.next_tab_id(); s.tabs.push(Tab { tab_id: tid, target_id: "T".into(), cdp_session: "S".into(), url: "https://x".into(), title: "X".into(), error_count: 0 });+ ss.insert(s.clone()); ss.save_file(&s);+ let files = ss.read_files(); assert_eq!(files.len(), 1); assert_eq!(files[0]["sessionId"], json!("t1")); assert_eq!(files[0]["cdpPort"], json!(9222)); assert_eq!(files[0]["url"], json!("https://x"));+ assert_eq!(ss.active_id().as_deref(), Some("t1")); ss.remove("t1"); assert_eq!(ss.count(), 0); assert!(ss.active_id().is_none());+ ss.delete_file("t1"); assert!(ss.read_files().is_empty()); let _ = fs::remove_dir_all(&dir);+ }+}
rust/vault/Cargo.tomladded+11@@ -0,0 +1,11 @@+[package]+name = "pup-vault"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "Credentials and payment cards: keyring storage, Luhn, brand, expiry, autofill selectors, the reveal gate."++[dependencies]+serde.workspace = true+serde_json.workspace = true+thiserror.workspace = true
rust/vault/src/lib.rsadded+3@@ -0,0 +1,3 @@+//! pup-vault: Credentials and payment cards: keyring storage, Luhn, brand, expiry, autofill selectors, the reveal gate.+//!+//! Phase status: SKELETON (pup Rust port plan, docs/PLAN-rust-port.md).
rust/verbs/Cargo.tomladded+16@@ -0,0 +1,16 @@+[package]+name = "pup-verbs"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "The verb handlers, one module per family. Each handler is a function of (ctx, args) -> Response. No HTTP here."++[dependencies]+serde_json.workspace = true+base64.workspace = true+png.workspace = true+pup-core = { path = "../core" }+pup-host = { path = "../host" }+pup-cdp = { path = "../cdp" }+pup-browser = { path = "../browser" }+pup-session = { path = "../session" }
rust/verbs/src/image.rsadded+71@@ -0,0 +1,71 @@+//! PNG fit-inside resize without the `image` crate: decode with `png`, box-filter downscale on+//! RGBA, re-encode. Replaces the Node build's sharp dependency for screenshots (Node: processScreenshot).++use std::io::Cursor;++/// Returns (png bytes, width, height, origWidth, origHeight, resized). Images already within+/// `max_dim` on both edges are returned re-encoded only when they needed expansion, else as-is.+pub fn fit_png(bytes: &[u8], max_dim: u32) -> Result<(Vec<u8>, u32, u32, u32, u32, bool), String> {+ let mut dec = png::Decoder::new(Cursor::new(bytes));+ dec.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16 | png::Transformations::ALPHA);+ let mut reader = dec.read_info().map_err(|e| format!("png decode: {e}"))?;+ let info = reader.info().clone();+ let (ow, oh) = (info.width, info.height);+ if ow <= max_dim && oh <= max_dim { return Ok((bytes.to_vec(), ow, oh, ow, oh, false)); }+ let mut buf = vec![0u8; reader.output_buffer_size()];+ let out = reader.next_frame(&mut buf).map_err(|e| format!("png frame: {e}"))?;+ let bpp = match out.color_type { png::ColorType::Rgba => 4, png::ColorType::GrayscaleAlpha => 2, png::ColorType::Rgb => 3, png::ColorType::Grayscale => 1, _ => return Err("unsupported png colour type".into()) };+ let rgba: Vec<u8> = match bpp {+ 4 => buf[..out.buffer_size()].to_vec(),+ 3 => buf[..out.buffer_size()].chunks(3).flat_map(|p| [p[0], p[1], p[2], 255]).collect(),+ 2 => buf[..out.buffer_size()].chunks(2).flat_map(|p| [p[0], p[0], p[0], p[1]]).collect(),+ _ => buf[..out.buffer_size()].iter().flat_map(|&g| [g, g, g, 255]).collect(),+ };+ let scale = (max_dim as f64 / ow as f64).min(max_dim as f64 / oh as f64);+ let nw = ((ow as f64 * scale).round() as u32).max(1);+ let nh = ((oh as f64 * scale).round() as u32).max(1);+ let small = downscale_rgba(&rgba, ow, oh, nw, nh);+ let mut out_bytes = Vec::new();+ {+ let mut enc = png::Encoder::new(&mut out_bytes, nw, nh);+ enc.set_color(png::ColorType::Rgba); enc.set_depth(png::BitDepth::Eight);+ enc.set_compression(png::Compression::Best); enc.set_filter(png::FilterType::Paeth);+ let mut w = enc.write_header().map_err(|e| format!("png encode: {e}"))?;+ w.write_image_data(&small).map_err(|e| format!("png write: {e}"))?;+ }+ Ok((out_bytes, nw, nh, ow, oh, true))+}++/// Area-averaging downscale (each output pixel averages the source box it covers).+pub fn downscale_rgba(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {+ let mut out = vec![0u8; (dw * dh * 4) as usize];+ let xs = sw as f64 / dw as f64; let ys = sh as f64 / dh as f64;+ for dy in 0..dh {+ let y0 = (dy as f64 * ys).floor() as u32; let y1 = (((dy + 1) as f64 * ys).ceil() as u32).min(sh).max(y0 + 1);+ for dx in 0..dw {+ let x0 = (dx as f64 * xs).floor() as u32; let x1 = (((dx + 1) as f64 * xs).ceil() as u32).min(sw).max(x0 + 1);+ let mut acc = [0u64; 4]; let mut n = 0u64;+ for y in y0..y1 { let row = (y * sw) as usize * 4; for x in x0..x1 { let i = row + (x as usize) * 4; for c in 0..4 { acc[c] += src[i + c] as u64; } n += 1; } }+ let o = ((dy * dw + dx) * 4) as usize;+ for c in 0..4 { out[o + c] = (acc[c] / n.max(1)) as u8; }+ }+ }+ out+}++#[cfg(test)]+mod tests {+ use super::*;+ fn solid_png(w: u32, h: u32) -> Vec<u8> {+ let mut v = Vec::new();+ { let mut e = png::Encoder::new(&mut v, w, h); e.set_color(png::ColorType::Rgb); e.set_depth(png::BitDepth::Eight); let mut wr = e.write_header().unwrap(); wr.write_image_data(&vec![120u8; (w * h * 3) as usize]).unwrap(); }+ v+ }+ #[test] fn fits_inside() {+ let p = solid_png(3000, 1000);+ let (out, w, h, ow, oh, resized) = fit_png(&p, 1568).unwrap();+ assert!(resized); assert_eq!((ow, oh), (3000, 1000)); assert_eq!(w, 1568); assert_eq!(h, 523); assert!(!out.is_empty());+ let (_, w2, h2, _, _, r2) = fit_png(&solid_png(800, 600), 1568).unwrap(); assert!(!r2); assert_eq!((w2, h2), (800, 600));+ let (o2, ..) = fit_png(&out, 1568).unwrap(); assert_eq!(o2.len(), out.len());+ }+}
rust/verbs/src/lib.rsadded+401@@ -0,0 +1,401 @@+//! pup-verbs: the verb handlers. Each is a function of (ctx, caller, args) -> Value and knows+//! nothing about HTTP. Phase 1 carries the ten verbs that open, drive, capture and close a+//! window; every other name in the contract answers `not_in_rust_yet` with the Node fallback hint.+//! Response shapes follow docs/contract/*.json (recorded from the Node build) field for field.++use pup_browser::{Candidate, Installer, Launched};+use pup_cdp::Cdp;+use pup_core::{blocked_on_user, err, ok, AbClient, Caller, Settings};+use pup_host::Host;+use pup_session::{thread_default_session_id, Session, Sessions, Tab};+use serde_json::{json, Value};+use std::collections::HashMap;+use std::path::PathBuf;+use std::sync::{Arc, Mutex};+use std::time::{Duration, Instant};++pub mod image;++pub const MAX_SCREENSHOT_DIM: u32 = 1568; // Node: MAX_SCREENSHOT_DIM (Claude's optimal max)+const OFFSCREEN: i32 = -32000; // Node: _OFFX/_OFFY birth position++/// A running browser pup is connected to (one per profile).+pub struct BrowserHandle { pub cdp: Cdp, pub launched: Launched }++pub struct Ctx {+ pub host: Arc<dyn Host>,+ pub ab: Arc<dyn AbClient>,+ pub settings: Arc<Settings>,+ pub installer: Arc<Installer>,+ pub sessions: Arc<Sessions>,+ pub browsers: Mutex<HashMap<String, Arc<BrowserHandle>>>,+ pub bridge_dir: PathBuf,+ pub port: u16,+ pub started: Instant,+ pub ad_version: Mutex<Option<String>>,+}++impl Ctx {+ pub fn prefer_cft(&self) -> bool { self.settings.get("toolbarExtension", "on") != "off" }+ pub fn shots_dir(&self) -> PathBuf { let d = self.bridge_dir.join("screenshots"); let _ = std::fs::create_dir_all(&d); d }+}++/// The verbs this build serves natively.+pub const PHASE1_VERBS: &[&str] = &["pup_describe", "pup_readiness", "pup_prewarm", "pup_status", "pup_list_windows", "pup_my_windows", "pup_open_window", "pup_navigate", "pup_eval", "pup_evaluate", "pup_screenshot", "pup_close_window", "pup_close", "pup_window_info"];++pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {+ let t0 = Instant::now();+ let mut r = match verb {+ "pup_describe" => describe(ctx),+ "pup_readiness" => readiness(ctx),+ "pup_prewarm" => prewarm(ctx, args),+ "pup_status" => status(ctx),+ "pup_list_windows" => list_windows(ctx),+ "pup_my_windows" => my_windows(ctx, caller),+ "pup_open_window" => open_window(ctx, caller, args),+ "pup_navigate" => navigate(ctx, caller, args),+ "pup_eval" | "pup_evaluate" => eval(ctx, caller, args),+ "pup_screenshot" => screenshot(ctx, caller, args),+ "pup_close_window" | "pup_close" => close_window(ctx, caller, args),+ "pup_window_info" => window_info(ctx, caller, args),+ v if v.starts_with("pup_") || v.starts_with("desktop_record") || v.starts_with("desktop_recorder") || v.starts_with("credential_") => err("not_in_rust_yet", format!("{v} is not served by the Rust bridge yet (Phase 1 carries {} verbs)", PHASE1_VERBS.len()), Some("This is the Rust port of pup (docs/PLAN-rust-port.md). The verb exists in the contract and lands in a later phase; the Node build still serves it on the public tier.")),+ v => err("unknown_verb", format!("unknown verb {v}"), Some("pup_describe lists every verb this bridge serves.")),+ };+ if let Some(o) = r.as_object_mut() { o.entry("durationMs").or_insert(json!(t0.elapsed().as_millis() as u64)); o.insert("shell".into(), json!("rust")); }+ r+}++// ── resolution: window | sessionId | the caller's own window ───────────────────────────++fn window_arg(args: &Value) -> Option<String> {+ for k in ["window", "sessionId", "session", "ai-thread", "aiThread"] { if let Some(s) = args.get(k).and_then(|v| v.as_str()) { if !s.trim().is_empty() { return Some(if k == "ai-thread" || k == "aiThread" { thread_default_session_id(s) } else { s.trim().to_string() }); } } }+ None+}++fn resolve(ctx: &Ctx, caller: &Caller, args: &Value) -> Result<Session, Value> {+ let explicit = window_arg(args);+ let id = match explicit.clone() {+ Some(id) => id,+ None => {+ if caller.thread.is_empty() {+ return Err(err("caller_identity_required", "no window given and no --ai-thread to key one on", Some("Pass --ai-thread <your thread name> (the CLI flag) so pup can resolve your own window, or name one with {window:\"...\"}.")));+ }+ let d = thread_default_session_id(&caller.thread);+ if ctx.sessions.exists(&d) { d } else {+ let mine = ctx.sessions.owned_by(&caller.thread);+ if mine.len() == 1 { mine[0].id.clone() } else { return Err(err("no_window", format!("thread \"{}\" has no pup window", caller.thread), Some("pup_open_window {url} first; later verbs with no ids resolve to that window. pup_my_windows {} lists what you own."))); }+ }+ }+ };+ match ctx.sessions.get(&id) {+ Some(s) => {+ // Ownership guard (Node: ownershipRefusal): another thread's window needs takeover:true.+ if let Some(owner) = s.owner.as_deref() { if !caller.thread.is_empty() && owner != caller.thread && args.get("takeover").and_then(|t| t.as_bool()) != Some(true) {+ return Err(pup_core::merge(err("not_your_window", format!("window \"{}\" belongs to thread \"{}\"", id, owner), Some("Pass takeover:true to drive another thread's window deliberately, or open your own with pup_open_window.")), json!({ "owner": owner, "window": id })));+ } }+ Ok(s)+ }+ None => Err(pup_core::merge(err("session_not_found", format!("no pup window named \"{id}\""), Some("pup_list_windows {} shows what is open; pup_open_window creates one.")), json!({ "window": id }))),+ }+}++fn browser_for(ctx: &Ctx, profile: &str) -> Option<Arc<BrowserHandle>> { ctx.browsers.lock().unwrap().get(profile).cloned().filter(|b| b.cdp.is_alive()) }++/// Get or launch the browser for a profile: reuse a live DevTools port, else spawn-verify each+/// candidate in order (Node: getOrLaunchBrowser).+fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) -> Result<Arc<BrowserHandle>, Value> {+ if let Some(b) = browser_for(ctx, profile) { return Ok(b); }+ let profile_dir = pup_browser::profiles_dir(&*ctx.host).join(profile);+ if let Some(port) = pup_browser::existing_port(&profile_dir) {+ if let Ok(cdp) = Cdp::connect_port(port, Duration::from_secs(5)) {+ let kind = cdp.version().ok().and_then(|v| v.get("product").and_then(|p| p.as_str()).map(|s| s.to_string())).unwrap_or_default();+ let cand = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft()).into_iter().find(|c| kind.contains("Chrome") && (c.kind == "chrome-for-testing" || c.kind == "chrome") || kind.contains("Edg") && c.kind == "edge").unwrap_or(Candidate { kind: "chrome".into(), source: "reconnect".into(), executable_path: PathBuf::new(), build_id: None });+ pup_core::log(format!("[launch] reconnected to a live {} on :{port} for profile \"{profile}\"", cand.kind));+ let h = Arc::new(BrowserHandle { cdp, launched: Launched { pid: 0, cdp_port: port, candidate: cand, profile_dir, reused: true } });+ ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());+ return Ok(h);+ }+ }+ let cands = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft());+ if cands.is_empty() {+ let rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());+ if rd.get("lowDisk").and_then(|b| b.as_bool()) == Some(true) {+ return Err(pup_core::merge(err("chrome_install_no_disk", format!("No Chrome/Edge on this machine and only {} MB free — not enough to install Chrome for Testing (~800 MB needed).", rd.get("diskFreeMb").cloned().unwrap_or(Value::Null)), Some("Free up disk space, or install Chrome or Edge, then retry.")), json!({ "readiness": rd })));+ }+ ctx.installer.ensure_background(ctx.host.clone());+ return Err(pup_core::merge(err("chrome_for_testing_installing", "This box has no installed Chrome or Edge, so pup is fetching Chrome for Testing as the last-resort browser (~150 MB, one time).", Some("LAST-RESORT PROVISIONING (not an error): poll pup_readiness {} until ready:true (it shows percent + ETA), then call pup_open_window again.")), json!({ "installing": true, "stillRunning": true, "statusVerb": "pup_readiness", "readiness": rd })));+ }+ pup_core::log(format!("[launch] {} browser candidate(s): {}", cands.len(), cands.iter().map(|c| format!("{}({})", c.kind, c.source)).collect::<Vec<_>>().join(" -> ")));+ let mut errors = Vec::new();+ for c in &cands {+ let t0 = Instant::now();+ match pup_browser::launch(&*ctx.host, c, &profile_dir, high_fps, insecure_dev) {+ Ok(l) => match Cdp::connect_port(l.cdp_port, Duration::from_secs(10)) {+ Ok(cdp) => {+ pup_core::log(format!("[launch] {}({}) up on :{} pid {} in {}ms", c.kind, c.source, l.cdp_port, l.pid, t0.elapsed().as_millis()));+ pup_browser::write_default(&*ctx.host, c, false);+ let h = Arc::new(BrowserHandle { cdp, launched: l });+ ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());+ return Ok(h);+ }+ Err(e) => { errors.push(format!("{}: connect {e}", c.kind)); let _ = ctx.host.kill(l.pid); }+ },+ Err(e) => errors.push(format!("{}: {e}", c.kind)),+ }+ pup_core::log(format!("[launch] {} failed, falling through: {}", c.kind, errors.last().cloned().unwrap_or_default()));+ }+ Err(err("browser_launch_failed", format!("every browser candidate failed to launch: {}", errors.join(" | ")), Some("pup_readiness {} shows the candidates and the last install error; pup_use can pin a different browser.")))+}++fn attach_tab(s: &mut Session, cdp: &Cdp, target_id: &str) -> Result<String, Value> {+ let sid = cdp.attach(target_id).map_err(|e| err("cdp_attach_failed", e.to_string(), None))?;+ let _ = cdp.page_enable(&sid);+ let tab_id = s.next_tab_id();+ s.tabs.push(Tab { tab_id: tab_id.clone(), target_id: target_id.into(), cdp_session: sid, url: "about:blank".into(), title: String::new(), error_count: 0 });+ s.active_tab = s.tabs.len() - 1;+ Ok(tab_id)+}++fn refresh_tab(cdp: &Cdp, t: &mut Tab) {+ if let Ok(info) = cdp.target_info(&t.cdp_session) {+ if let Some(u) = info.get("url").and_then(|u| u.as_str()) { t.url = u.into(); }+ if let Some(ti) = info.get("title").and_then(|u| u.as_str()) { t.title = ti.into(); }+ }+}++// ── the verbs ──────────────────────────────────────────────────────────────────────────++fn describe(ctx: &Ctx) -> Value {+ let rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());+ ok(json!({+ "version": pup_core::version(), "shell": "rust", "phase": "1 of 4 (docs/PLAN-rust-port.md)",+ "verbs": PHASE1_VERBS, "nativeCount": PHASE1_VERBS.len(),+ "notYetInRust": "every other pup_* / credential_* / desktop_record* verb answers errorCode not_in_rust_yet; the Node build on the public tier still serves them",+ "readiness": rd,+ "progressContract": { "statusVerb": "pup_readiness", "activeKey": "active", "slowVerbs": ["pup_open_window", "pup_prewarm"], "blockedByPhases": ["consent", "dialog"], "blockedOnUser": { "errorCode": "blocked_on_user", "rule": "a verb parked on a HUMAN never bare-timeouts; it answers this shape", "stillRunning": "true = re-call the SAME verb with the SAME args; false = the human must act first" } },+ "_hint": "This is pup built as one Rust exe (spawn.kind exe): no Node, no npm, no postinstall. Same contract as the Node build; the verbs listed are native, the rest land phase by phase.",+ }))+}++fn active_rows(ctx: &Ctx) -> Vec<Value> {+ let rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());+ let mut rows = Vec::new();+ if rd.get("installing").and_then(|b| b.as_bool()) == Some(true) {+ if rd.get("installPhase").and_then(|p| p.as_str()) == Some("unpacking") {+ let total = rd.get("bytesTotal").and_then(|b| b.as_u64()).unwrap_or(0) as f64 * 2.3;+ let pct = if total > 0.0 { Some(((rd.get("unpackBytes").and_then(|b| b.as_u64()).unwrap_or(0) as f64 / total * 100.0).round() as u64).min(99)) } else { None };+ rows.push(json!({ "id": "cft-unpack", "verb": "pup_prewarm", "phase": "unpack", "stepLabel": format!("Unpacking Chrome for Testing{}", rd.get("unpackMethod").and_then(|m| m.as_str()).map(|m| format!(" ({m})")).unwrap_or_default()), "percent": pct, "bytes": rd.get("unpackBytes"), "elapsedSec": rd.get("unpackElapsedSec"), "estimatedSec": Value::Null, "etaSec": Value::Null, "confidence": "estimated", "startKind": "cold" }));+ } else {+ rows.push(json!({ "id": "cft-download", "verb": "pup_prewarm", "phase": "download", "stepLabel": "Downloading Chrome for Testing", "percent": rd.get("installProgressPct"), "elapsedSec": rd.get("downloadElapsedSec"), "estimatedSec": rd.get("downloadElapsedSec").and_then(|e| e.as_u64()).zip(rd.get("downloadEtaSec").and_then(|e| e.as_u64())).map(|(a, b)| a + b), "etaSec": rd.get("downloadEtaSec"), "confidence": if rd.get("downloadEtaSec").is_some_and(|v| !v.is_null()) { "measured" } else { "estimated" }, "startKind": "cold" }));+ }+ }+ rows+}++fn readiness(ctx: &Ctx) -> Value {+ let rd = ctx.installer.readiness(&*ctx.host, ctx.prefer_cft());+ let ready = rd.get("ready").and_then(|b| b.as_bool()).unwrap_or(false);+ let installing = rd.get("installing").and_then(|b| b.as_bool()).unwrap_or(false);+ let hint = if ready { "A drivable browser exists: pup_open_window {url} will open in the background. chromeForTestingInstalled tells you whether the toolbar build is cached (pup fetches it in the background on every box)." }+ else if installing { "Chrome for Testing is downloading (active[] carries percent, elapsed and ETA). Poll this verb; do not start a second download. A system Chrome/Edge, if present, is drivable already." }+ else if rd.get("installPhase").and_then(|p| p.as_str()) == Some("failed") { "The Chrome for Testing install FAILED (lastError). Retry pup_prewarm once the cause (network/proxy, disk) is fixed; a system Chrome/Edge is drivable meanwhile." }+ else { "No browser yet: pup_prewarm {} starts the Chrome for Testing download; pup_open_window starts it too and answers chrome_for_testing_installing until it lands." };+ pup_core::merge(ok(rd), json!({ "success": true, "statusVerb": "pup_readiness", "active": active_rows(ctx), "_hint": hint, "_next": ["pup_open_window", "pup_prewarm"] }))+}++fn prewarm(ctx: &Ctx, args: &Value) -> Value {+ let wait = args.get("wait").and_then(|b| b.as_bool()).unwrap_or(false);+ if wait { let done = ctx.installer.ensure_blocking(ctx.host.clone(), Duration::from_secs(600)); let rd = readiness(ctx); return pup_core::merge(rd, json!({ "waited": true, "installed": done })); }+ ctx.installer.ensure_background(ctx.host.clone());+ let rd = readiness(ctx);+ pup_core::merge(rd, json!({ "started": true, "_hint": "Chrome for Testing provisioning is running in the background (or already cached). Poll pup_readiness for percent + ETA; pass wait:true to block until it lands." }))+}++fn session_row(ctx: &Ctx, s: &Session) -> Value {+ let active_tab = s.active();+ json!({ "sessionId": s.id, "window": s.id, "url": active_tab.map(|t| t.url.clone()), "title": active_tab.map(|t| t.title.clone()), "active": ctx.sessions.active_id().as_deref() == Some(&s.id), "owner": s.owner, "aiThread": s.owner, "profile": s.profile, "tabCount": s.tabs.len(), "hwnd": s.hwnd, "foreground": s.foreground, "ageMinutes": s.age_minutes() })+}++fn status(ctx: &Ctx) -> Value {+ let list: Vec<Value> = ctx.sessions.all().iter().map(|s| session_row(ctx, s)).collect();+ ok(json!({+ "version": pup_core::version(), "shell": "rust", "uptimeSec": ctx.started.elapsed().as_secs(),+ "sessionCount": list.len(), "activeSession": ctx.sessions.active_id(), "sessions": list,+ "settings": { "taskbarMode": if ctx.settings.get("aumidIcons", "on") != "on" { "plain" } else if ctx.settings.get("threadTiles", "on") == "on" { "tiles" } else { "aumid" }, "aumidIcons": ctx.settings.get("aumidIcons", "on"), "threadTiles": ctx.settings.get("threadTiles", "on"), "overlayBadges": ctx.settings.get("overlayBadges", "on"), "overlaySize": ctx.settings.get("overlaySize", "75"), "jumpLists": ctx.settings.get("jumpLists", "on") },+ "win32": { "inProcess": true, "why": Value::Null }, "enumWindows": { "total": 0, "lastMin": 0 }, "win32Calls": {}, "log": [], "loops": [], "iconAudit": Value::Null,+ "adVersion": ctx.ad_version.lock().unwrap().clone(),+ }))+}++fn list_windows(ctx: &Ctx) -> Value {+ let mut all = ctx.sessions.all();+ for s in all.iter_mut() { if let Some(b) = browser_for(ctx, &s.profile) { for t in s.tabs.iter_mut() { refresh_tab(&b.cdp, t); } ctx.sessions.with_mut(&s.id, |x| x.tabs = s.tabs.clone()); } }+ let rows: Vec<Value> = all.iter().map(|s| session_row(ctx, s)).collect();+ ok(json!({ "count": rows.len(), "sessions": rows, "activeSession": ctx.sessions.active_id() }))+}++fn my_windows(ctx: &Ctx, caller: &Caller) -> Value {+ if caller.thread.is_empty() { return err("caller_identity_required", "pup_my_windows needs --ai-thread", Some("Pass --ai-thread <your thread name>; windows are keyed on it.")); }+ let mine: Vec<Value> = ctx.sessions.owned_by(&caller.thread).iter().map(|s| session_row(ctx, s)).collect();+ let default_id = thread_default_session_id(&caller.thread);+ ok(json!({ "aiThread": caller.thread, "count": mine.len(), "windows": mine, "defaultWindow": default_id, "_hint": if mine.is_empty() { "You own no pup window yet: pup_open_window {url} creates yours (no ids needed), later verbs resolve to it." } else { "Verbs with no ids resolve to your window; name another with {window:\"<sessionId>\"}." }, "_next": ["pup_open_window", "pup_screenshot", "pup_navigate"] }))+}++fn birth_rect(ctx: &Ctx) -> (i32, i32, i32, i32) {+ match ctx.host.work_area() { Some((x, y, w, h)) => (x + 10, y + 10, (w - 20).max(400), (h - 20).max(300)), None => (10, 10, 1280, 800) }+}++fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let t0 = Instant::now();+ let url = args.get("url").and_then(|u| u.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "about:blank".into());+ let profile = args.get("profile").and_then(|p| p.as_str()).unwrap_or("adom-you").to_string();+ if args.get("isolated").and_then(|b| b.as_bool()) == Some(true) && args.get("isolationReason").and_then(|r| r.as_str()).map(|r| r.len() >= 10) != Some(true) {+ return err("isolation_reason_required", "isolated:true needs an isolationReason (min 10 chars): why must this window NOT be adom-you?", Some("Surf as adom-you by default (its logins accumulate). Isolation is for logged-out tests, acting as a different user, or untrusted pages: say which."));+ }+ if args.get("foreground").and_then(|b| b.as_bool()) == Some(true) && args.get("foregroundReason").and_then(|r| r.as_str()).map(|r| r.len() >= 8) != Some(true) {+ return err("foreground_reason_required", "foreground:true needs a foregroundReason shown to the user on screen", Some("pup windows are born in the BACKGROUND. Only a demo / 'show me' earns the foreground, and the reason is captioned for the user."));+ }+ let sid = window_arg(args).unwrap_or_else(|| if caller.thread.is_empty() { format!("pup-{}", pup_core::now_ms() % 100000) } else { thread_default_session_id(&caller.thread) });+ let high_fps = args.get("highFps").and_then(|b| b.as_bool()).unwrap_or(false);+ let insecure = args.get("webSecurity").and_then(|b| b.as_bool()) == Some(false);+ let b = match get_or_launch(ctx, &profile, high_fps, insecure) { Ok(b) => b, Err(e) => return e };++ // An existing window of that name: later opens become TABS in it (the Node contract).+ if let Some(existing) = ctx.sessions.get(&sid) {+ if existing.profile == profile {+ let tid = match b.cdp.create_target(&url, false, None) { Ok(t) => t, Err(e) => return err("cdp_create_target_failed", e.to_string(), None) };+ let mut s = existing.clone();+ let tab_id = match attach_tab(&mut s, &b.cdp, &tid) { Ok(t) => t, Err(e) => return e };+ if let Some(t) = s.tabs.last_mut() { let _ = b.cdp.navigate(&t.cdp_session, &url, Duration::from_secs(15)); refresh_tab(&b.cdp, t); }+ let title = s.active().map(|t| t.title.clone()).unwrap_or_default();+ let tab_count = s.tabs.len();+ ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);+ return ok(json!({ "sessionId": sid, "window": sid, "aiThread": s.owner, "url": url, "title": title, "tabId": tab_id, "activeTabId": tab_id, "tabCount": tab_count, "reused": true, "active": true,+ "background": "backgrounded", "windowEtiquette": "background", "placement": "background", "browser": b.launched.candidate.to_json(), "profile": profile, "owner": s.owner, "ownerSource": "session",+ "progress": { "phase": "done", "elapsedMs": t0.elapsed().as_millis() as u64, "startKind": "warm" },+ "_hint": "This window already existed for your thread, so the URL opened as a NEW TAB in it (pup_list_tabs / pup_switch_tab drive tabs). Screenshot to verify the render.", "_verifyRender": VERIFY_RENDER, "_next": ["pup_screenshot", "pup_navigate", "pup_eval"] }));+ }+ }++ let (bl, bt, bw, bh) = birth_rect(ctx);+ let before: std::collections::HashSet<i64> = ctx.host.windows_for_pids(&[], Some("Chrome_WidgetWin_1")).into_iter().map(|w| w.hwnd).collect();+ let target = match b.cdp.create_target("about:blank", true, Some((OFFSCREEN, OFFSCREEN, bw, bh))) { Ok(t) => t, Err(e) => return err("cdp_create_target_failed", format!("Target.createTarget: {e}"), Some("The browser is up but refused a new window; pup_readiness shows its state, pup_close {} restarts it.")) };+ let mut s = Session::new(&sid, &profile, if caller.thread.is_empty() { None } else { Some(&caller.thread) }, Some(&caller.container), b.launched.cdp_port, if b.launched.pid > 0 { Some(b.launched.pid) } else { None });+ let tab_id = match attach_tab(&mut s, &b.cdp, &target) { Ok(t) => t, Err(e) => return e };+ // park: on-screen rect via CDP (moves without z-order change), then bottom + tag via the host+ let mut hwnd: Option<i64> = None;+ if let Ok((wid, _)) = b.cdp.window_for_target(&target) { let _ = b.cdp.set_window_bounds(wid, json!({ "left": bl, "top": bt, "width": bw, "height": bh })); }+ for _ in 0..25 {+ let now = ctx.host.windows_for_pids(&[], Some("Chrome_WidgetWin_1"));+ if let Some(w) = now.iter().find(|w| !before.contains(&w.hwnd)) { hwnd = Some(w.hwnd); break; }+ std::thread::sleep(Duration::from_millis(80));+ }+ let mut parked = false;+ if let Some(h) = hwnd {+ s.hwnd = Some(h);+ ctx.host.tag(h, &s.tag_string());+ ctx.host.set_bounds_no_activate(h, bl, bt, bw, bh);+ parked = ctx.host.send_to_bottom_no_activate(h);+ let _ = ctx.ab.call("desktop_set_window_state", json!({ "hwnd": h, "state": "bottom", "force": true }), caller, Duration::from_secs(5));+ }+ let loaded = if let Some(t) = s.tabs.last_mut() { let r = b.cdp.navigate(&t.cdp_session, &url, Duration::from_secs(20)).map(|(_, l)| l).unwrap_or(false); refresh_tab(&b.cdp, t); r } else { false };+ if let Some(h) = hwnd { ctx.host.send_to_bottom_no_activate(h); } // the first cross-origin paint can raise it: re-assert (Node: reassertBottomAfterNav)+ let title = s.active().map(|t| t.title.clone()).unwrap_or_default();+ ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);+ pup_core::log(format!("[window] \"{sid}\" opened for \"{}\" on {}({}) hwnd={:?} parked={parked} loaded={loaded} in {}ms", caller.thread, b.launched.candidate.kind, b.launched.candidate.source, hwnd, t0.elapsed().as_millis()));+ let cands = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft());+ let mut resp = ok(json!({+ "sessionId": sid, "window": sid, "aiThread": s.owner, "url": url, "title": title, "tabId": tab_id, "activeTabId": tab_id, "tabCount": 1, "reused": false, "active": true, "loaded": loaded,+ "background": "backgrounded", "windowEtiquette": "background", "placement": "background", "hwnd": hwnd, "parked": parked,+ "browser": b.launched.candidate.to_json(), "availableBrowsers": cands.iter().map(|c| format!("{}({})", c.kind, c.source)).collect::<Vec<_>>(),+ "defaultBrowser": pup_browser::read_default(&*ctx.host).map(|d| json!({ "kind": d.get("kind"), "source": d.get("source"), "forced": d.get("forced").and_then(|f| f.as_bool()).unwrap_or(false), "pendingInstall": false })),+ "profile": profile, "owner": s.owner, "ownerSource": if caller.thread.is_empty() { "none" } else { "caller" }, "ageMinutes": 0, "highFps": high_fps, "emulatedViewport": Value::Null, "errorCount": 0,+ "size": { "width": bw, "height": bh }, "progress": { "phase": "done", "elapsedMs": t0.elapsed().as_millis() as u64, "startKind": if b.launched.reused { "warm" } else { "cold" } },+ "_verifyRender": VERIFY_RENDER, "_next": ["pup_screenshot", "pup_navigate", "pup_eval", "pup_close_window"],+ "_hint": "Opened in the BACKGROUND (behind the user's windows, did NOT steal focus). Fully drivable while hidden: pup_screenshot / pup_eval / pup_navigate resolve to this window with no ids. Only bring it to the user's screen if they explicitly want to watch.",+ }));+ if b.launched.candidate.kind != "chrome-for-testing" { resp["_toolbarNotice"] = json!(format!("This window launched on {}: the Adom toolbar (pen/dots/identity tiles) rides ONLY Chrome for Testing, so it has NO toolbar. pup_readiness shows why that browser was picked.", b.launched.candidate.kind)); }+ if hwnd.is_none() { resp["_windowNotice"] = json!("pup could not resolve the OS window handle for this window (no Chrome_WidgetWin_1 appeared), so it was not parked or tagged; it may be on top. This is the Phase 1 Rust bridge; report it with the box name."); }+ resp+}++const VERIFY_RENDER: &str = "VERIFY WHAT YOU JUST PUT ON SCREEN — do not assume it rendered. ok:true means the NAVIGATION succeeded; it says NOTHING about whether the page actually painted. Take a screenshot and READ the image: pup_screenshot {} returns a real PNG path. Look for a blank page, an unstyled flash of plain HTML, a framework error overlay, missing images, a dev-server 'Cannot GET /' page, or the wrong build. If it is wrong: fix the source, reload, screenshot AGAIN.";++fn navigate(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ let url = match args.get("url").and_then(|u| u.as_str()).map(|u| u.trim()).filter(|u| !u.is_empty()) { Some(u) => u.to_string(), None => return err("url_required", "pup_navigate needs {url}", None) };+ let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", Some("pup_close_window then pup_open_window; Phase 2 brings automatic recovery.")) };+ let tab = match s.active() { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };+ match b.cdp.navigate(&tab.cdp_session, &url, Duration::from_secs(25)) {+ Ok((_, loaded)) => {+ let mut t = tab.clone(); refresh_tab(&b.cdp, &mut t);+ ctx.sessions.with_mut(&s.id, |x| { if let Some(tt) = x.tabs.iter_mut().find(|tt| tt.tab_id == t.tab_id) { *tt = t.clone(); } x.last_agent_update_ms = pup_core::now_ms(); });+ if let Some(h) = s.hwnd { ctx.host.send_to_bottom_no_activate(h); }+ if let Some(ss) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&ss); }+ ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "url": t.url, "title": t.title, "loaded": loaded, "_verifyRender": VERIFY_RENDER, "_hint": if loaded { "Navigation finished (load event). Verify with a screenshot before asserting success." } else { "Page.navigate returned but the load event did not fire within 25s: the page is still moving (SPA, slow site). Screenshot to see where it is." }, "_next": ["pup_screenshot", "pup_eval"] }))+ }+ Err(e) => err("navigate_failed", e.to_string(), Some("Check the URL; a container-local localhost is not reachable from the desktop (use the proxy URL).")),+ }+}++fn eval(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let expr = ["expr", "expression", "script", "js", "code"].iter().find_map(|k| args.get(*k).and_then(|v| v.as_str())).map(|s| s.to_string());+ let Some(expr) = expr else { return err("eval_expr_missing", "Missing required parameter \"expr\" (aliases: expression, script, js, code)", Some("pup_eval {expr:\"document.title\"}")) };+ let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", None) };+ let tab = match args.get("tabId").and_then(|t| t.as_str()).and_then(|id| s.tab_by_id(id)).or(s.active()) { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };+ match b.cdp.evaluate(&tab.cdp_session, &expr, Duration::from_secs(20)) {+ Ok(v) if v.get("__error").is_some() => err("eval_failed", format!("pup_eval could not run your code: {}", v["__error"].as_str().unwrap_or("evaluation threw")), Some("Expression and statement forms both run (devtools-console semantics). If this names a detached frame or closed tab, re-list windows.")),+ Ok(v) => ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "result": v })),+ Err(e) => err("eval_failed", e.to_string(), None),+ }+}++fn screenshot(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", None) };+ let tab = match s.active() { Some(t) => t.clone(), None => return err("no_tab", "the window has no tabs", None) };+ let full = args.get("fullPage").and_then(|b| b.as_bool()).unwrap_or(false);+ let png = match b.cdp.screenshot_png(&tab.cdp_session, full, Duration::from_secs(20)) {+ Ok(p) => p,+ Err(e) => {+ // OS fallback (Node 2.0.38x): ab captures the window even when the renderer is stuck.+ if let Some(h) = s.hwnd { if let Ok(r) = ctx.ab.call("desktop_screenshot_window", json!({ "hwnd": h }), caller, Duration::from_secs(15)) { return pup_core::merge(ok(r), json!({ "capturedVia": "os", "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "_hint": "CDP timed out; this is an OS window capture (window chrome included)." })); } }+ return err("screenshot_timeout", format!("page.screenshot: {e}"), Some("The page did not answer the capture. Reload it, or pup_eval document.readyState to see whether the renderer is alive."));+ }+ };+ let max_w = args.get("maxWidth").and_then(|m| m.as_u64()).map(|m| m as u32).unwrap_or(MAX_SCREENSHOT_DIM).min(MAX_SCREENSHOT_DIM);+ let (buf, w, h, ow, oh, resized) = match image::fit_png(&png, max_w) { Ok(r) => r, Err(e) => return err("screenshot_decode_failed", e, None) };+ let path = ctx.shots_dir().join(format!("pup-{}.png", pup_core::now_ms()));+ if let Err(e) = std::fs::write(&path, &buf) { return err("screenshot_write_failed", e.to_string(), None); }+ ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "success": true, "filePath": path.to_string_lossy().replace('\\', "/"), "width": w, "height": h, "origWidth": ow, "origHeight": oh, "resized": resized, "sizeKB": buf.len() / 1024, "fullPage": full, "capturedVia": "cdp", "_hint": "PNG of the page. READ the image before asserting anything rendered.", "_next": ["pup_eval", "pup_navigate"] }))+}++fn window_info(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+ let fg = ctx.host.foreground();+ ok(pup_core::merge(session_row(ctx, &s), json!({ "output": json!({ "sessionId": s.id, "hwnd": s.hwnd, "isForeground": s.hwnd.is_some() && s.hwnd == Some(fg), "tabs": s.tabs.iter().map(|t| json!({ "tabId": t.tab_id, "url": t.url, "title": t.title })).collect::<Vec<_>>() }).to_string(), "lastAgentUpdate": s.last_agent_update_ms, "ownerContainer": s.owner_container })))+}++fn close_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ // pup_close {} with no ids closes the caller's window; pup_close {all:true} closes everything of theirs.+ let mut targets: Vec<Session> = Vec::new();+ if args.get("all").and_then(|b| b.as_bool()) == Some(true) { targets = if caller.thread.is_empty() { ctx.sessions.all() } else { ctx.sessions.owned_by(&caller.thread) }; }+ else { match resolve(ctx, caller, args) { Ok(s) => targets.push(s), Err(e) => return e } }+ let mut closed = Vec::new(); let mut chrome_killed = 0;+ for s in targets {+ if let Some(b) = browser_for(ctx, &s.profile) { for t in &s.tabs { let _ = b.cdp.close_target(&t.target_id); } }+ ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id); closed.push(s.id.clone());+ // last window on that profile: let the browser go (Node: killProfileBrowserIfUnused)+ if ctx.sessions.for_profile(&s.profile).is_empty() {+ if let Some(b) = ctx.browsers.lock().unwrap().remove(&s.profile) {+ let _ = b.cdp.send(None, "Browser.close", json!({}), Duration::from_secs(5)); b.cdp.close();+ if b.launched.pid > 0 { std::thread::sleep(Duration::from_millis(800)); if ctx.host.alive(b.launched.pid) { ctx.host.kill(b.launched.pid); } chrome_killed += 1; }+ }+ }+ }+ ok(json!({ "closed": closed, "remaining": ctx.sessions.count(), "activeSession": ctx.sessions.active_id(), "chromeProcsKilled": chrome_killed, "chromeProcsRemaining": ctx.browsers.lock().unwrap().len(), "_hint": "Closed. The sessionId is no longer valid; pup_open_window creates a fresh one.", "_next": ["pup_list_windows", "pup_open_window"] }))+}++#[allow(dead_code)]+fn _unused(_: Value) -> Value { blocked_on_user(false, "", "", json!({})) }
rust/wiki/Cargo.tomladded+10@@ -0,0 +1,10 @@+[package]+name = "pup-wiki"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "Wiki auth from the desktop token, magic-link sign-in, the public/authed view flip, URL lint."++[dependencies]+serde_json.workspace = true+ureq.workspace = true
rust/wiki/src/lib.rsadded+3@@ -0,0 +1,3 @@+//! pup-wiki: Wiki auth from the desktop token, magic-link sign-in, the public/authed view flip, URL lint.+//!+//! Phase status: SKELETON (pup Rust port plan, docs/PLAN-rust-port.md).
tools/check-borders.shadded+21@@ -0,0 +1,21 @@+#!/usr/bin/env bash+# The host boundary of the Rust port (docs/PLAN-rust-port.md section 4): only rust/host-win may+# name Win32; only rust/host-mac may name macOS APIs. Everything else goes through pup-host traits+# or ab's verbs. Runs in tools/ship.sh; a leak fails the ship.+set -u+cd "$(dirname "$0")/../rust" || exit 2+fail=0+win_pat='windows_sys|windows-sys|winapi|SetWindowPos|HWND_BOTTOM|SHGetPropertyStore|powershell|HKCU|user32|shell32|GetForegroundWindow|EnumWindows'+mac_pat='osascript|NSWindow|NSApp|CGWindowList|AXUIElement|cocoa'+for d in core host cdp browser session identity vault wiki annotate record verbs dashboard bridge; do+ hits=$(grep -rnE "$win_pat" "$d" --include='*.rs' --include='Cargo.toml' 2>/dev/null | grep -vE '^\S+:\s*//' | grep -v 'check-borders' | grep -v 'todo!(')+ # bridge/main.rs may select the impl by target, nothing more+ if [ "$d" = "bridge" ]; then hits=$(echo "$hits" | grep -vE 'pup_host_win::WinHost|pup-host-win|cfg\(windows\)|cfg\(not\(any\(windows' ); fi+ if [ -n "$hits" ]; then echo "WIN32 LEAK outside host-win in $d:"; echo "$hits" | head -5; fail=1; fi+done+for d in core host cdp browser session identity vault wiki annotate record verbs dashboard bridge host-win; do+ hits=$(grep -rnE "$mac_pat" "$d" --include='*.rs' 2>/dev/null | grep -vE '^\S+:\s*//' | grep -v 'todo!(')+ if [ -n "$hits" ]; then echo "MAC API LEAK outside host-mac in $d:"; echo "$hits" | head -5; fail=1; fi+done+[ $fail -eq 0 ] && echo "borders OK: Win32 only in host-win, macOS only in host-mac"+exit $fail